feat(03-11): GREEN — durable create-before-delete gating + drain concurrency guard (CR-04, CR-05)

- CR-04: delete rows with groupId query DB for sibling create status before dispatch
  - sibling 'pending': defer delete to later cycle (leave row pending)
  - sibling 'failed'/'dead': mark delete failed permanently (original event preserved, D-04)
  - sibling 'done': dispatch delete normally
- CR-05: module-level isDraining guard; overlapping 15s cycles are no-ops
  - SINGLE-PROCESS ONLY — documented limitation for multi-replica deployments
- Fix mockFromFn to use Symbol.for('drizzle:Name') instead of JSON.stringify (circular)
- Update D-04 ordering test to queue sibling-status mock response
This commit is contained in:
Lucas Berger
2026-06-05 21:01:57 -04:00
parent 6b2cdf3683
commit b409c09e25
2 changed files with 174 additions and 94 deletions
+78 -4
View File
@@ -26,7 +26,7 @@
import { schedule } from 'node-cron' import { schedule } from 'node-cron'
import { and, eq, lte } from 'drizzle-orm' import { and, eq, lte } from 'drizzle-orm'
import { db } from '../db/client.js' import { db } from '../db/client.js'
import { calendarOutbox, memberCredentials } from '../db/schema.js' import { calendarEvents, calendarOutbox, memberCredentials } from '../db/schema.js'
import { createFastmailClient } from './client.js' import { createFastmailClient } from './client.js'
import { decryptPassword } from './crypto.js' import { decryptPassword } from './crypto.js'
import { syncCalendar } from './sync.js' import { syncCalendar } from './sync.js'
@@ -53,6 +53,24 @@ const HARD_FAIL_STATUSES = new Set([400, 401, 403])
/** HTTP status code for CalDAV If-Match conflict — D-08 conflict flow. */ /** HTTP status code for CalDAV If-Match conflict — D-08 conflict flow. */
const CONFLICT_STATUS = 412 const CONFLICT_STATUS = 412
// ── Drain concurrency guard (CR-05) ──────────────────────────────────────────
/**
* Module-level drain guard — prevents overlapping 15s drain cycles from
* double-dispatching the same still-pending outbox row.
*
* SINGLE-PROCESS LIMITATION: This guard is valid ONLY for the single-process
* Unraid deployment of this two-user app where all drain cycles share the same
* Node.js module instance. A multi-process or multi-replica deployment (e.g.
* running multiple API containers behind a load balancer) would require a
* durable DB row-claim instead:
* UPDATE calendar_outbox SET status='processing'
* WHERE id=? AND status='pending'
* and only the process that wins the affected-rows check would dispatch the row.
* Do not remove this comment if deploying to multi-process infrastructure.
*/
let isDraining = false
// ── Credential + client loading ────────────────────────────────────────────── // ── Credential + client loading ──────────────────────────────────────────────
/** /**
@@ -269,9 +287,21 @@ async function dispatchRow(row: OutboxRow): Promise<DispatchResult> {
* are sorted before operation='delete'. If the create fails, the linked delete is * are sorted before operation='delete'. If the create fails, the linked delete is
* skipped (duplicate is recoverable; lost event is not — D-04 / T-03-14). * skipped (duplicate is recoverable; lost event is not — D-04 / T-03-14).
* *
* Concurrency guard (CR-05): the module-level `isDraining` flag ensures overlapping
* 15s scheduler invocations are no-ops for the single-process deployment.
*
* Durable create-before-delete gate (CR-04): for delete rows with a groupId, the
* worker queries the DB for the sibling create row's status. It does NOT rely on
* both rows co-occurring in the same in-memory batch.
*
* Per-row errors are caught and logged so one bad row cannot crash the loop. * Per-row errors are caught and logged so one bad row cannot crash the loop.
*/ */
export async function runOutboxDrain(): Promise<void> { export async function runOutboxDrain(): Promise<void> {
// CR-05: single-process concurrency guard (see isDraining declaration for limitations)
if (isDraining) return
isDraining = true
try {
// Fetch pending rows: WHERE status='pending' AND next_attempt_at <= NOW() // Fetch pending rows: WHERE status='pending' AND next_attempt_at <= NOW()
const pending = (await db const pending = (await db
.select() .select()
@@ -287,6 +317,7 @@ export async function runOutboxDrain(): Promise<void> {
// D-04 edit-as-move ordering: sort so create rows come before delete rows within the same groupId. // D-04 edit-as-move ordering: sort so create rows come before delete rows within the same groupId.
// Rows without a groupId are unaffected (stable relative order preserved). // Rows without a groupId are unaffected (stable relative order preserved).
// This is a fast path; the authoritative gate is the durable DB sibling-status check below.
const sorted = [...pending].sort((a, b) => { const sorted = [...pending].sort((a, b) => {
if (a.groupId && b.groupId && a.groupId === b.groupId) { if (a.groupId && b.groupId && a.groupId === b.groupId) {
if (a.operation === 'create' && b.operation === 'delete') return -1 if (a.operation === 'create' && b.operation === 'delete') return -1
@@ -295,18 +326,58 @@ export async function runOutboxDrain(): Promise<void> {
return 0 return 0
}) })
// Track groupIds where the create failed so the linked delete is skipped (D-04 / T-03-14) // Track groupIds where the create failed within this batch (fast path for same-batch pairs).
// Cross-batch ordering is enforced durably by the DB sibling-status check inside the loop.
const failedCreateGroups = new Set<string>() const failedCreateGroups = new Set<string>()
for (const row of sorted) { for (const row of sorted) {
// D-04: if the create for this group failed, skip the paired delete // D-04 fast path: if the create for this group already failed in this batch, skip the delete
if (row.operation === 'delete' && row.groupId && failedCreateGroups.has(row.groupId)) { if (row.operation === 'delete' && row.groupId && failedCreateGroups.has(row.groupId)) {
console.warn( console.warn(
`[outboxWorker] Skipping delete row.id=${row.id} — create for groupId=${row.groupId} failed (D-04)`, `[outboxWorker] Skipping delete row.id=${row.id} — create for groupId=${row.groupId} failed this batch (D-04)`,
) )
continue continue
} }
// CR-04: Durable create-before-delete gate — query DB for sibling create status.
// This prevents the delete from running when the create pair straddles drain batches.
if (row.operation === 'delete' && row.groupId) {
const siblingRows = (await db
.select({ status: calendarOutbox.status })
.from(calendarOutbox)
.where(
and(
eq(calendarOutbox.groupId, row.groupId),
eq(calendarOutbox.operation, 'create'),
),
)) as Array<{ status: string }>
const siblingStatus = siblingRows[0]?.status
if (siblingStatus !== 'done') {
if (siblingStatus === 'failed' || siblingStatus === 'dead') {
// Sibling create failed permanently — skip this delete forever (D-04: original preserved)
console.warn(
`[outboxWorker] Paired create for groupId=${row.groupId} is ${siblingStatus} — marking delete row.id=${row.id} failed (original event preserved, D-04)`,
)
await db
.update(calendarOutbox)
.set({
status: 'failed',
lastError: 'paired create did not succeed — original preserved',
})
.where(eq(calendarOutbox.id, row.id))
} else {
// Sibling create is still pending/processing — defer this delete to a later cycle
console.warn(
`[outboxWorker] Deferring delete row.id=${row.id} — sibling create (groupId=${row.groupId}) is not yet done (status=${siblingStatus ?? 'not found'})`,
)
// Leave the delete row pending; do NOT update its status
}
continue
}
}
try { try {
const result = await dispatchRow(row) const result = await dispatchRow(row)
@@ -377,6 +448,9 @@ export async function runOutboxDrain(): Promise<void> {
) )
} }
} }
} finally {
isDraining = false
}
} }
// ── Scheduler ──────────────────────────────────────────────────────────────── // ── Scheduler ────────────────────────────────────────────────────────────────
+15 -9
View File
@@ -126,16 +126,12 @@ function wireMockChain() {
mockUpdate.mockReturnValue({ set: mockUpdateSet }) mockUpdate.mockReturnValue({ set: mockUpdateSet })
// mockFromFn differentiates by table argument: // mockFromFn differentiates by table argument:
// - memberCredentials table → returns FAKE_CRED_ROW (so loadClientForUser succeeds by default) // - memberCredentials table → returns FAKE_CRED_ROW (so loadClientForUser succeeds by default)
// - anything else → returns mockPendingRows (outbox query) // - anything else (calendarOutbox, calendarEvents) → returns mockWherePending
// Use Symbol.for('drizzle:Name') to identify the table — JSON.stringify throws on circular Drizzle
// table structures so it cannot be used for table identification.
mockFromFn.mockImplementation((table: unknown) => { mockFromFn.mockImplementation((table: unknown) => {
// Drizzle table objects have a Symbol.for('drizzle:Name') property and a [Table.Symbol.Name]. const tableName = (table as Record<symbol, string>)[Symbol.for('drizzle:Name')] ?? ''
// The safest approach: JSON.stringify often includes the table config name. const isCred = tableName === 'member_credentials'
let isCred = false
try {
isCred = JSON.stringify(table).includes('member_credentials')
} catch {
// table not serializable — not a credential table
}
return { return {
where: isCred where: isCred
? vi.fn().mockResolvedValue([FAKE_CRED_ROW]) ? vi.fn().mockResolvedValue([FAKE_CRED_ROW])
@@ -307,6 +303,16 @@ describe('runOutboxDrain — edit-as-move ordering (D-04)', () => {
// Both rows in the pending list // Both rows in the pending list
mockPendingRows = [deleteRow, createRow] mockPendingRows = [deleteRow, createRow]
// The durable sibling-status check (CR-04) runs for the delete row with groupId.
// It queries calendarOutbox for the sibling create's status. By the time the delete
// is processed (create was sorted and dispatched first), we simulate the sibling as 'done'.
// The base mockWherePending returns mockPendingRows for all calendarOutbox selects; we
// override just the sibling-status call with mockImplementationOnce queued after the
// pending-rows select call.
mockWherePending
.mockImplementationOnce(() => Promise.resolve([deleteRow, createRow])) // pending-rows select
.mockImplementationOnce(() => Promise.resolve([{ status: 'done' }])) // sibling-status select
await runOutboxDrain() await runOutboxDrain()
// CREATE must be called before DELETE // CREATE must be called before DELETE