Skip to content

Leave a trace for every lost capture claim - #2067

Merged
ppXD merged 1 commit into
mainfrom
fix/leave-a-trace-for-every-lost-capture-claim
Oct 4, 2026
Merged

ppXD merged 1 commit into
mainfrom
fix/leave-a-trace-for-every-lost-capture-claim

Conversation

@ppXD

@ppXD ppXD commented Oct 4, 2026

Copy link
Copy Markdown
Owner

Summary

  • RecoverClaimAsync turned any exception from the recovery step into a recovery-operation-exception retry and dropped the exception. It is now logged as a warning with the run, the intent, the exception and any SQLSTATE and message text. The line comes before the settlement runs. So it names the step's outcome without promising it: the settlement may exhaust the intent (recovery-exhausted on the last allowed attempt), supersede it (worker fence changed), or not settle it at all.
  • SettleAsync used to return a lost lease without logging anything, both when its lease re-check failed and when its write matched no row. Each exit now logs a warning with the run, the intent, the outcome it discarded and a cause: lease-expired, reclaimed, row-version-changed or claim-row-missing. The row-version-changed line names the write it discarded (possibly already exhausted or superseded), not the outcome it observed.
  • The recurring job throws away the reconcile summary, so nothing read the LostLease tally. ReconcileAsync now logs the summary at Information when its wave claimed anything, as AgentRunReconcilerService and AgentRunSpoolReaper do. Every path still returns the same settlement or retry as before.

Test plan

  • Integration: A_recovery_step_that_fails_outside_the_database_..._its_settlement_decides_the_retry is a Theory over MaxAttempts 8 and 1. In both cases the intent settles as Expected/recovery-operation-exception or ExternalStateIndeterminate/recovery-exhausted, and the single line names the step's outcome without promising a retry.
  • Integration: A_settlement_whose_row_changed_under_its_lock_is_logged_with_the_write_it_discarded_... is a Theory over MaxAttempts 8 and 1. It fails if the concurrency catch logs the observed outcome instead of the write it discarded (checked by mutation).
  • Integration: lease-expired/reclaimed Theory, database-refused recovery read, refused and timed-out settlements, and one summary per claiming wave (AgentRunLogCaptureRecoveryFlowTests 32/32).
  • Regression: related capture and spool recovery integration suites (84/84) and the full unit suite (11598 passed, 1 skipped).

After #2058, AgentRunLogCaptureRecoveryService still had three paths
that dropped the cause of a claim it could not settle.

RecoverClaimAsync turned any exception from the recovery step into a
typed retry, recovery-operation-exception, and discarded the exception.
A deterministic fault then repeats on every retry until the intent is
exhausted into ExternalStateIndeterminate, and nothing says why. It is
now logged as a warning in the shape #2058 uses: run, intent, the
exception, and the SQLSTATE and message text of any PostgresException
in its chain, plus the retry the step's outcome became. The line is
written before the settlement runs, so it names that outcome without
promising it: on the last allowed attempt the settlement exhausts the
intent instead, a changed worker fence supersedes it, and a lost lease
writes nothing, and the line says so rather than "recovered again once
the retry falls due".

SettleAsync returned a lost lease with no log line when its lease
re-check failed and when its write matched no row
(DbUpdateConcurrencyException). Each exit now logs a warning naming the
run, the intent, the outcome it discarded, and why: lease-expired,
reclaimed, row-version-changed, or claim-row-missing (which the
ON DELETE RESTRICT key and the DELETE guard make unreachable today).
The concurrency exit names the write it discarded, which the settlement
may already have replaced with an exhausted or superseded outcome, not
the outcome it observed. The level is Warning, not Information: the
constructor makes a lease outlive both bounded steps of a claim, so a
live worker loses one only when a step overruns the bound its
cancellation sets, the process stalls, or the row changes under the
settlement's own FOR UPDATE lock. None of that is routine. Each loss
costs a re-claim and another recovery attempt, and for a legacy intent
that attempt counts toward exhaustion. It is not an Error either,
because the fence keeps the loss safe.

Nothing read the LostLease tally, because the recurring job discards
the reconcile summary. ReconcileAsync now logs the summary at
Information when its wave claimed anything, as AgentRunReconcilerService
and AgentRunSpoolReaper log theirs, so the job and its handler stay
thin dispatchers.

Every path still returns the same settlement or retry as before.
@ppXD
ppXD merged commit 583efb0 into main Oct 4, 2026
7 checks passed
@ppXD
ppXD deleted the fix/leave-a-trace-for-every-lost-capture-claim branch October 4, 2026 07:07
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant