Files
accounted/lib/pending-operations/recover-stuck-committing.ts
T
Jakob WennbergandClaude Fable 5 25a7261eda fix(pending-ops): recovery sweep for operations stuck in committing (#843) (#1108)
The commit dispatcher claims an op with an atomic pending -> committing
CAS; if the process dies after side-effects post but before the terminal
committed write (or that write fails, the PR #841 log line), the row sat
in status='committing' forever: the expire cron only sweeps 'pending'.

Add lib/pending-operations/recover-stuck-committing.ts, invoked from the
existing daily expire cron (no new vercel.json entry):

- Only rows whose updated_at (the claim timestamp: the CAS bumps it via
  the update_updated_at_column trigger) is older than 15 minutes, well
  past the 300s Vercel function ceiling, so in-flight executors are
  never raced.
- Positive evidence that side-effects posted finalizes the row to
  committed with result_data.recovered=true. Evidence exists only where
  params identify a target with an unambiguous posted state:
  categorize_transaction (is_transaction_booked RPC, skipped for
  allow_duplicate), link_transaction_journal_entry (exact tx+entry
  link), match_transaction_invoice (invoice_payments pair row).
- No evidence: terminal rejected with an explanatory result_data,
  never back to pending (re-execution could duplicate side-effects
  that posted without a trace). Reason 'stuck_committing' is distinct
  from 'expired' so the UI badge never claims these rows.
- Every terminal write is CAS-guarded on status='committing'; probe
  errors skip the row for the next run.
- One structured 'pending_op_recovery' warn per row (count by outcome);
  runbook comment added next to the #841 finalize-failure log line.

Tests: unit coverage for the decision logic and cron wiring (401, sweep
invoked, failure isolation), plus a pg-real test proving row selection,
the trustworthy updated_at anchor, committing -> terminal transitions
through the real immutability/input-frozen triggers, and the
is_transaction_booked evidence substrate.

Fixes #843

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-22 18:30:33 +02:00

319 lines
12 KiB
TypeScript

/**
* Recovery sweep for pending_operations stuck in 'committing' (issue #843).
*
* The commit dispatcher (lib/pending-operations/commit.ts) claims an op by
* flipping pending -> committing in an atomic CAS, runs the executor's
* side-effects, then writes the terminal 'committed' status. If the process
* dies between those steps, or the terminal write itself fails (the
* "failed to finalize pending_operation to committed" log line from PR #841),
* the row stays status='committing' forever: the expire cron only sweeps
* status='pending', so stuck rows were invisible until this sweep.
*
* Semantics (deliberately conservative, no new status values; see #842 for
* the failed_partial half):
*
* - Only rows whose updated_at is older than STUCK_COMMITTING_THRESHOLD_MINUTES
* are touched. The claim CAS is an UPDATE, and pending_operations has the
* standard update_updated_at_column() BEFORE UPDATE trigger, so updated_at
* IS the claim timestamp on a stuck row (nothing else updates a
* 'committing' row without also changing its status). 15 minutes is well
* past the Vercel function ceiling (300s), so no in-flight executor can
* still be running when a row qualifies.
*
* - Rows with positive evidence that the side-effects posted are finalized
* to 'committed' with result_data.recovered=true. Evidence is per
* operation_type and only exists where the op's params identify a target
* row whose posted state is observable (see findPostedEvidence). There is
* no generic side-effect -> pending_op linkage today (that is #842's
* "record posted ids" work), so most types have no probe.
*
* - Rows with no detectable evidence go to terminal 'rejected' with a
* result_data explanation. NEVER back to 'pending': re-executing could
* duplicate side-effects that posted without leaving a detectable trace
* (sent emails, journal entries not referenced by the params). A rejected
* proposal is safe: nothing re-runs, and the user can re-stage after
* verifying manually.
*
* - Every terminal write is CAS-guarded on status='committing' so a
* concurrent finalize (or a second sweep run) can never clobber a row that
* just resolved. The DB immutability trigger
* (enforce_pending_operations_immutability) additionally blocks UPDATEs on
* terminal rows; the CAS means we never even hit it.
*
* Observability: one structured warn per recovered row with the constant
* message 'pending_op_recovery' (metric-style; count by `outcome`).
* Runbook: grep Vercel logs for pending_op_recovery. outcome='committed'
* needs no action (side-effects verified present). outcome='rejected' means
* side-effects could not be verified: check the entities named in params and,
* if something did post, leave the rejected row as the audit record and do
* not re-stage the operation.
*/
import type { SupabaseClient } from '@supabase/supabase-js'
import { createLogger, type Logger } from '@/lib/logger'
import { fetchAllRows } from '@/lib/supabase/fetch-all'
/**
* Minutes a row may sit in 'committing' before the sweep considers it stuck.
* Must stay comfortably above the max request duration (Vercel: 300s) so the
* sweep can never race an in-flight executor.
*/
export const STUCK_COMMITTING_THRESHOLD_MINUTES = 15
export interface StuckCommittingRow {
id: string
company_id: string
operation_type: string
params: Record<string, unknown>
updated_at: string
}
/**
* Evidence signals, in the "what did we actually observe" sense. Serialized
* into result_data.recovery.evidence so an auditor can see the basis for a
* recovered 'committed'.
*/
export type PostedEvidence =
| 'transaction_booked'
| 'transaction_linked_to_target_entry'
| 'invoice_payment_recorded'
export interface RecoverySummary {
scanned: number
committed: number
rejected: number
/** Probe errors + lost CAS races: rows left for the next run. */
skipped: number
}
/**
* Probe for positive evidence that a stuck op's side-effects posted.
*
* Returns an evidence label when the target state is observably present,
* null when nothing detectable posted (or the type has no reliable probe).
* Throws on probe/database errors: the caller must then SKIP the row (leave
* it 'committing' for the next run) rather than reject on a transient error.
*
* Probes exist only where params identify the target and the posted state is
* unambiguous:
*
* - categorize_transaction: the staged transaction is anchored to a
* verifikat per the canonical is_transaction_booked(uuid) predicate
* (transactions.journal_entry_id, payment rows, or voucher links).
* Skipped when params.allow_duplicate=true: those ops intentionally post
* on an already-booked transaction, so "booked" proves nothing.
* - link_transaction_journal_entry: the exact (transaction, journal entry)
* pair from params is linked, via transactions.journal_entry_id or a
* transaction_voucher_links row.
* - match_transaction_invoice: an invoice_payments row exists for the exact
* (transaction, invoice) pair from params (unique index guarantees the
* pair is only ever written by a match).
*
* Everything else returns null by design: create_* ops don't know the id of
* the row they would have created, and state-flag types (period locks,
* invoice statuses) can't be distinguished from a user doing the same thing
* manually while the row sat stuck. Be conservative: no evidence, no
* 'committed'.
*/
export async function findPostedEvidence(
supabase: SupabaseClient,
row: StuckCommittingRow,
): Promise<PostedEvidence | null> {
const params = row.params ?? {}
switch (row.operation_type) {
case 'categorize_transaction': {
const transactionId = params.transaction_id
if (typeof transactionId !== 'string' || transactionId.length === 0) return null
// allow_duplicate ops post a second entry on an already-booked tx:
// "booked" would be true before the executor ever ran.
if (params.allow_duplicate === true) return null
const { data, error } = await supabase.rpc('is_transaction_booked', {
p_transaction_id: transactionId,
})
if (error) throw new Error(`is_transaction_booked probe failed: ${error.message}`)
return data === true ? 'transaction_booked' : null
}
case 'link_transaction_journal_entry': {
const transactionId = params.transaction_id
const journalEntryId = params.journal_entry_id
if (typeof transactionId !== 'string' || typeof journalEntryId !== 'string') return null
const { data: tx, error: txError } = await supabase
.from('transactions')
.select('id')
.eq('id', transactionId)
.eq('company_id', row.company_id)
.eq('journal_entry_id', journalEntryId)
.maybeSingle()
if (txError) throw new Error(`transactions probe failed: ${txError.message}`)
if (tx) return 'transaction_linked_to_target_entry'
const { data: link, error: linkError } = await supabase
.from('transaction_voucher_links')
.select('id')
.eq('company_id', row.company_id)
.eq('transaction_id', transactionId)
.eq('journal_entry_id', journalEntryId)
.maybeSingle()
if (linkError) throw new Error(`transaction_voucher_links probe failed: ${linkError.message}`)
return link ? 'transaction_linked_to_target_entry' : null
}
case 'match_transaction_invoice': {
const transactionId = params.transaction_id
const invoiceId = params.invoice_id
if (typeof transactionId !== 'string' || typeof invoiceId !== 'string') return null
const { data, error } = await supabase
.from('invoice_payments')
.select('id')
.eq('company_id', row.company_id)
.eq('transaction_id', transactionId)
.eq('invoice_id', invoiceId)
.maybeSingle()
if (error) throw new Error(`invoice_payments probe failed: ${error.message}`)
return data ? 'invoice_payment_recorded' : null
}
default:
return null
}
}
/**
* Pure builder for the terminal update payload. Exported so unit tests (and
* the pg-real test, which mirrors the exact payload through real triggers)
* pin the shape.
*
* The rejected shape reuses the { auto_rejected, reason } marker family the
* dispatcher and expire cron already write; reason='stuck_committing' is
* distinct from 'expired' so the /pending UI's "Utgick automatiskt" badge
* (strict on reason === 'expired') never claims these rows.
*/
export function buildRecoveryUpdate(
row: StuckCommittingRow,
evidence: PostedEvidence | null,
sweptAtIso: string,
): {
status: 'committed' | 'rejected'
resolved_at: string
result_data: Record<string, unknown>
} {
const recovery = {
reason: 'stuck_committing',
evidence,
stuck_since: row.updated_at,
swept_at: sweptAtIso,
}
if (evidence) {
return {
status: 'committed',
resolved_at: sweptAtIso,
result_data: { recovered: true, recovery },
}
}
return {
status: 'rejected',
resolved_at: sweptAtIso,
result_data: {
auto_rejected: true,
reason: 'stuck_committing',
recovery: {
...recovery,
note:
'Operation was stuck in committing and no trace of posted side effects was found. ' +
'Closed without re-execution; verify the target entities manually before re-staging.',
},
},
}
}
/**
* Sweep rows stuck in 'committing' beyond the threshold and drive each to a
* terminal status. Runs with the service-role client from the expire cron
* (app/api/pending-operations/expire/cron/route.ts); one failing row never
* aborts the rest.
*/
export async function recoverStuckCommittingOperations(
supabase: SupabaseClient,
opts: { log?: Logger; now?: Date } = {},
): Promise<RecoverySummary> {
const log = opts.log ?? createLogger('pending-operations/recover-stuck-committing')
const now = opts.now ?? new Date()
const cutoff = new Date(now.getTime() - STUCK_COMMITTING_THRESHOLD_MINUTES * 60_000)
const stuck = await fetchAllRows<StuckCommittingRow>(({ from, to }) =>
supabase
.from('pending_operations')
.select('id, company_id, operation_type, params, updated_at')
.eq('status', 'committing')
.lt('updated_at', cutoff.toISOString())
.order('id', { ascending: true })
.range(from, to),
)
const summary: RecoverySummary = {
scanned: stuck.length,
committed: 0,
rejected: 0,
skipped: 0,
}
for (const row of stuck) {
const baseCtx = {
pendingOperationId: row.id,
companyId: row.company_id,
operationType: row.operation_type,
stuckSince: row.updated_at,
ageMinutes: Math.round((now.getTime() - new Date(row.updated_at).getTime()) / 60_000),
}
let evidence: PostedEvidence | null
try {
evidence = await findPostedEvidence(supabase, row)
} catch (err) {
// Transient probe failure: leave the row 'committing' for the next
// run rather than rejecting on incomplete information.
summary.skipped++
log.error('pending_op_recovery', err as Error, { ...baseCtx, outcome: 'skipped_probe_error' })
continue
}
const update = buildRecoveryUpdate(row, evidence, new Date().toISOString())
// CAS on status='committing': if a concurrent finalize resolved the row
// between the listing and this write, zero rows match and we skip. The
// immutability trigger never fires because OLD.status is not terminal on
// any row we actually update.
const { data: updated, error: updateError } = await supabase
.from('pending_operations')
.update(update)
.eq('id', row.id)
.eq('status', 'committing')
.select('id')
.maybeSingle()
if (updateError) {
summary.skipped++
log.error('pending_op_recovery', updateError, { ...baseCtx, outcome: 'skipped_write_error' })
continue
}
if (!updated) {
summary.skipped++
log.warn('pending_op_recovery', { ...baseCtx, outcome: 'lost_cas' })
continue
}
if (update.status === 'committed') summary.committed++
else summary.rejected++
log.warn('pending_op_recovery', {
...baseCtx,
outcome: update.status,
evidence,
})
}
return summary
}