Skip to content

Commit d257370

Browse files
committed
fix(outbox): start a pass from the oldest row when its stored cursor names no real time
A cursor whose JSON parsed but whose creation time was not a calendar time reached the batch's ::timestamp cast and failed the run, which stopped every later type's prune until the cursor expired. The cursor's creation time is now checked as PostgreSQL's ISO text with a calendar round trip, so such a cursor is dropped like an unreadable one and the pass starts from the oldest row.
1 parent b186c33 commit d257370

2 files changed

Lines changed: 26 additions & 1 deletion

File tree

‎apps/sim/lib/core/outbox/retention.integration.ts‎

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -260,6 +260,20 @@ describe('completed outbox retention in PostgreSQL', () => {
260260
},
261261
0,
262262
],
263+
[
264+
'the cursor names no real creation time',
265+
async (client: Redis) => {
266+
await client.set(
267+
`outbox-prune-cursor:v1:${RECOVER}`,
268+
JSON.stringify({
269+
createdAt: '2026-02-30 00:00:00',
270+
id: 'row:1',
271+
passStartedAt: Date.now(),
272+
})
273+
)
274+
},
275+
0,
276+
],
263277
])('starts again from the oldest row when %s', async (_, corrupt, laterMs) => {
264278
const client = resumeThroughRedis()
265279
await seedFullBatchPlusOne(RECOVER)

‎apps/sim/lib/core/outbox/retention.ts‎

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -67,11 +67,22 @@ const PRUNABLE_OUTBOX_EVENT_TYPES = [
6767
*/
6868
export const OUTBOX_PRUNE_PASS_MAX_MS = 60 * 60_000
6969

70+
/**
71+
* `created_at::text` under PostgreSQL's ISO date style, to the microsecond. A stored value that is
72+
* not a real calendar time fails the round trip here instead of the batch's `::timestamp` cast.
73+
*/
74+
const postgresTimestampSchema = z.string().refine((value) => {
75+
if (!/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}(\.\d{1,6})?$/.test(value)) return false
76+
const seconds = value.slice(0, 19).replace(' ', 'T')
77+
const parsed = new Date(`${seconds}Z`)
78+
return !Number.isNaN(parsed.getTime()) && parsed.toISOString().startsWith(seconds)
79+
})
80+
7081
/**
7182
* Where a type's pass resumes: the creation time, as PostgreSQL prints it so no microseconds are
7283
* lost, and id of the last row the previous batch deleted.
7384
*/
74-
const resumePointSchema = z.object({ createdAt: z.string(), id: z.string() })
85+
const resumePointSchema = z.object({ createdAt: postgresTimestampSchema, id: z.string() })
7586
const pruneCursorSchema = resumePointSchema.extend({ passStartedAt: z.number() })
7687

7788
type ResumePoint = z.infer<typeof resumePointSchema>

0 commit comments

Comments
 (0)