Skip to content

fix(agent): autonomously retry durable finalization inbox - #2002

Merged
Jurij89 merged 9 commits into
testnet-canaryfrom
codex/finalization-inbox-retry-worker
Jul 31, 2026
Merged

fix(agent): autonomously retry durable finalization inbox#2002
Jurij89 merged 9 commits into
testnet-canaryfrom
codex/finalization-inbox-retry-worker

Conversation

@lupuszr

@lupuszr lupuszr commented Jul 30, 2026

Copy link
Copy Markdown
Contributor

Summary

This PR turns the durable SQLite finalization inbox into an executable retry queue.

It follows the crash-safe admission and guarded replay foundation introduced in #1939. That work persists the raw finalization message as RECEIVED before store-heavy processing and requires canonical chain evidence before promotion. The missing piece was autonomous progress: a durable row was reconsidered only if chain reconciliation happened to revisit the same graph, UAL, and KA.

This PR adds a lifecycle-owned asynchronous recovery worker that:

  • reads due SQLite rows in bounded batches of 16;
  • processes worker rows serially, contributing at most one recovery graph operation at a time;
  • reuses the existing fix(agent): persist finalization recovery in SQLite #1939 receipt-validation, authority, placement, and generation-CAS path;
  • persists exponential retry deadlines for busy or deferred attempts;
  • starts independently of chain-cursor progress and drains cleanly during shutdown;
  • serializes live gossip, worker replay, and reconciliation replay for the same durable entry;
  • bounds poison-row lifetime and exposes due depth, age, and retry outcomes.

The worker is an asynchronous Node.js event-loop task, not an OS worker thread. It does not block startup. The per-entry mutex is intentionally not a global mutex: independent live finalizations remain governed by the existing store scheduler.

Problem

The inbox already prevented finalization messages from being lost under store pressure, but persistence alone did not guarantee progress:

  1. Finalization gossip was durably inserted as RECEIVED.
  2. Immediate processing encountered store-scheduler pressure or was interrupted before recording an outcome.
  3. The row remained live with next_attempt_at = NULL or an expired deadline.
  4. No autonomous consumer scanned due rows.
  5. Replay depended on reconciliation revisiting the exact graph, UAL, and KA.
  6. If the reconciliation watermark already covered that ordinal, the sweep could return current without revisiting it.
  7. Old live rows kept consuming the per-context-graph capacity of 64 and global capacity of 128.
  8. New finalizations were rejected as capacity-exhausted even after Blazegraph and the normal store queue recovered.

next_attempt_at was an eligibility gate, not a schedule.

Failure sequence before this PR

sequenceDiagram
    participant G as Finalization gossip
    participant H as Finalization handler
    participant I as SQLite inbox
    participant S as Store scheduler
    participant R as Chain reconciler

    G->>H: finalization message
    H->>I: persist RECEIVED
    I-->>H: durable admission succeeds
    H->>S: immediate verification and materialization
    S-->>H: busy, deferred, or interrupted
    opt outcome is recorded
        H->>I: record attempt and optional deadline
    end

    R->>R: watermark already covers ordinal
    R-->>R: return current without exact replay
    Note over I,R: No autonomous due-row consumer
    Note over I: RECEIVED or VERIFIED remains live
    G->>I: later finalizations consume the graph quota
    I-->>G: capacity-exhausted at 64 live graph rows
Loading

Base implementation

Finding Consequence Fix
No bounded global due-work query existed. A scheduler had no safe way to discover retryable rows. Add oldest-first listDue(limit) using the existing retention bound.
Due rows were reconsidered only by another explicit replay path. Eligible rows could remain untouched forever. Start a lifecycle-owned worker independent of cursor movement.
Concurrent backlog drain could recreate store pressure. Recovery itself could overload Blazegraph. Fetch 16 SQLite rows but process the worker snapshot serially.
Busy/deferred retries could hot-loop. Recovery could continuously contend with normal work. Persist 1s, 2s, 4s, 8s, 16s, 32s, then 60s maximum backoff.
Shutdown could race an active timer. Dependencies could close under recovery. Stop new batches, cancel the timer, and await the active batch before teardown.

Review findings addressed

Commit 5c1d430ea addresses every actionable finding from the post-implementation review.

Severity Finding Fix Regression proof
HIGH Retrying a permanently failing row updated updated_at every minute, preventing raw TTL eviction and making the capacity leak immortal. Autonomous live retries require both the default 10,080-attempt ceiling and a seven-day minimum wall-clock age. Duplicate live gossip or reconciliation may increase the attempt count, but cannot accelerate terminal rejection before the age window. Once both bounds expire, recovery generation-CASes RECEIVED, VERIFIED, or REORGED to audited terminal REJECTED, immediately releasing live capacity. Terminal retention still bounds disk use. A real SQLite test proves the count ceiling alone does not reject, then advances the clock to the age boundary, proves REJECTED, proves a replacement is admitted at maxEntries: 1, and proves duplicate gossip cannot revive materialization.
HIGH Worker replay and duplicate live gossip could both enter the graph write before either generation CAS settled the row. A FIFO async mutex keyed by the durable entry key wraps live processing, autonomous replay, and reconciliation replay. Every lane reloads the row after acquiring the lock. A gated concurrency test starts live and worker paths together and shares one concurrency counter across both apply and replayVerified; maximum same-key materialization concurrency is 1.
MEDIUM VERIFIED had no age-eviction path and could permanently consume capacity. The bounded live-retry terminalization covers VERIFIED as well as RECEIVED and REORGED; it does not silently delete live evidence by created_at. The poison-row test reaches the ceiling specifically from VERIFIED.
MEDIUM Duplicate live gossip could clear a worker deadline and make a backed-off row due immediately. SQLite attempt recording is monotonic: no delay preserves the deadline, a shorter deadline cannot narrow it, and only a later deadline extends it. Store tests cover omitted, shorter, and longer delays; integration proves gossip preserves the worker deadline.
MEDIUM Worker lifecycle wiring was not pinned through the real handler. A lifecycle test attaches the real handler, spies startRecoveryWorker and stopRecoveryWorker, and drives actual agent start() and stop(). Removing either lifecycle call now fails the test.
LOW The worker due gate accidentally delayed authoritative chain reconciliation. Only autonomous replay observes next_attempt_at; reconciliation remains immediate while sharing the entry lock. A row with a future worker deadline is recovered by reconciliation before the deadline.
LOW Queue depth, oldest due age, and outcomes were invisible. Health reports dueEntries and oldestDueAgeMs. OpenTelemetry exports dkg.finalization_recovery.due_entries, dkg.finalization_recovery.oldest_due_age_ms, and dkg.finalization_recovery.attempts_total{outcome}. Health tests prove due count and age use the same predicate as listDue; builds type-check the metric surface.

Round 2 review findings addressed

Commits c98c796a5 and 5f23ae4e1 address the new review findings against 92e0efeca.

Severity Finding Disposition Regression proof
HIGH The 10,080-attempt budget was incremented by live gossip and reconciliation as well as the one-minute worker, so duplicate traffic could consume a nominal seven-day budget immediately and irreversibly reject the row. Terminal rejection now requires both the attempt ceiling and a seven-day wall-clock window measured from durable receipt. This preserves the count safety bound without allowing traffic frequency to shorten evidence lifetime. No schema migration is required. A duplicate-gossip test crosses the count ceiling before the age window and proves the row remains live. Removing the age predicate makes the test fail because canonical receipt calls stop early.
MEDIUM The same-entry lock test counted only apply; if the live lane promoted the row to VERIFIED, the worker could enter replayVerified concurrently without the test noticing. The test now uses one shared active/max counter across apply and replayVerified. Replacing the awaited lock predecessor with a non-awaited promise makes the test fail with maximum concurrency 2 instead of 1.
MEDIUM Canonical-receipt capability reporting overwrote the more actionable store reason capacity-exhausted. canonical-finalization-receipt-unsupported is now a fallback only when the store has no degraded reason. A lifecycle health test supplies both conditions and proves capacity-exhausted wins.
LOW Due gauges could freeze when the chain was unavailable or listDue failed because metric refresh occurred only on the success path. Due-depth and age gauges are refreshed in finally whenever a store exists. Metric snapshot failures remain contained and logged. A real SQLite test forces listDue to reject and proves the health snapshot still runs.
NIT A Set equality assertion verified distinct chain targets but not the number of calls. The test now asserts exactly two calls before comparing the target set. A duplicate target call can no longer satisfy the test.
MEDIUM A transient processDueBatch exception was handled in production but the worker reschedule path was not regression-tested. Added deterministic fake-timer coverage proving the worker logs the failure, remains running, and invokes the next poll without overlap. The focused worker suite now fails if catch/finally stops scheduling after one rejection.
MEDIUM, reported follow-up Autonomous replay routes an already-selected due row through the KA-wide reconciliation selector, adding a repeated query/filter and orchestration coupling. Intentionally not refactored here. A direct-entry canonical snapshot helper changes the central replay decomposition and should be reviewed separately; the current bounded serial worker remains correct. Follow-up should share the canonical chain checks between exact due replay and cursor replay without routing either orchestration mode through the other.
MEDIUM, reported follow-up Optional deferredRetryDelay also acts as an implicit autonomous-vs-reconciliation mode flag. Intentionally not refactored here. Replacing it with a typed replay-policy union spans live, settled upgrade, reorg, deadline, and outcome behavior and is broader than a minor review correction. Follow-up should introduce an explicit reconciliation/autonomous policy object with characterization tests before changing signatures.
MEDIUM, reported follow-up oldestDueAgeMs is based on created_at, so it reports age since receipt rather than age since the last retry/progress. Intentionally not expanded in this patch. Adding a separate progress-age field is an additive health/telemetry/status/alerting contract change, not a local minor correction. The existing metric keeps its documented receipt-age semantics. Follow-up should add a separately named metric based on MIN(updated_at) rather than silently changing the meaning of the existing series.

Retry-budget sequence after Round 2

sequenceDiagram
    participant G as Duplicate live gossip
    participant I as Durable inbox
    participant W as Recovery worker
    participant C as Live capacity

    loop duplicate or reconciliation traffic
        G->>I: serialize by entry key
        G->>I: record outcome and monotonic deadline
        Note over I: attempt count may cross 10,080
    end

    W->>I: reload due row under the same entry lock
    alt attempt ceiling reached but age is under seven days
        I-->>W: keep row live
        W->>I: guarded replay and backoff
    else attempt ceiling and age window both expired
        W->>I: generation-CAS live row to REJECTED
        I-->>C: release live quota
    end
Loading

Round 2 validation

  • Changed agent tests: 42/42 passed.
  • Strict current/finalized chain transport tests: 30/30 passed.
  • Full chain unit suite: 829 passed; 1 skipped.
  • Agent build: passed TypeScript, type tests, and package-root validation.
  • Full agent suite: 1,988 passed; 5 skipped with the one unrelated loaded-run SIGKILL fixture mismatch described below; isolated rerun passed 3/3.
  • Mutation proof: removing the wall-clock predicate fails the duplicate-gossip test; bypassing the awaited entry lock fails the cross-branch concurrency test.

Round 3 review findings addressed

Commit 697c7308a addresses the standalone findings added against 5f23ae4e1.

Severity Finding Disposition Regression proof
HIGH One due entry throwing outside its internal retryable-error path could abort the serial batch before later independent entries were processed. If the failed row stayed oldest and due, it could repeatedly starve the queue. Each due entry now has its own error boundary. An escaped failure is logged, best-effort generation-CASed into the normal monotonic backoff, reported as retry-pending, and the worker continues with the remaining snapshot. A real SQLite test admits two due rows, forces the first replay to throw, proves the first row gains a one-second retry deadline, and proves the second row still reaches SETTLED. Removing the boundary makes the batch reject immediately.
HIGH The due-metrics failure-path test observed only store.health(), so deleting or corrupting the actual gauge writes would not fail the test. The test installs an in-memory OpenTelemetry meter, rebuilds the real metric facade, forces a failed listDue path with known health values, flushes, and asserts both exported gauge datapoints. Replacing the due-depth recording with zero fails with expected 7 / received 0.
MEDIUM The lifecycle test proved stopRecoveryWorker() was called, but not that DKGAgent.stop() awaited it before dependent teardown. The test now returns a deferred worker-stop promise and proves the next catalog teardown method is not invoked until the gate is released. Replacing the production await with void makes teardown advance and the test fail before the gate is released.
MEDIUM, repeated follow-up Extract autonomous orchestration from the already large recovery state machine. Still a valid maintainability follow-up, but not a contained correctness fix. It overlaps the previously documented direct-entry/canonical-snapshot refactor and should be reviewed as a separate structural PR. Existing behavior remains pinned by autonomous drain, backoff, lock, reconciliation, and lifecycle tests.
MEDIUM, repeated follow-up Replace optional retry-delay plumbing with an explicit replay mode or policy. Still intentionally deferred to a characterization-first refactor because it spans reconciliation, live recovery, settled upgrades, reorgs, deadlines, and outcome mapping. The current distinct deadline semantics remain covered; the follow-up can change the representation without changing behavior.

Per-entry failure isolation

sequenceDiagram
    participant W as Recovery worker
    participant I as Durable inbox
    participant P as Poison entry
    participant V as Later valid entry

    W->>I: listDue limit 16
    I-->>W: poison entry, then valid entry
    W->>P: guarded replay
    P-->>W: escaped error
    W->>I: best-effort record failure and backoff
    Note over W: keep processing this bounded snapshot
    W->>V: guarded replay
    V-->>W: recovered
    W->>I: generation-CAS to SETTLED
Loading

Round 3 validation

  • Focused recovery, SQLite store, worker, and lifecycle suites: 67/67 passed.
  • Agent build: passed TypeScript, type tests, and package-root validation.
  • Mutation proof: escaping the poison error fails the two-row test; zeroing the due gauge fails the telemetry assertion; removing the shutdown await fails the lifecycle gate.
  • git diff --check: passed.

Round 5 small review fix

The Round 4 review found one blocking defect in the new metrics regression test. The production recovery path already refreshes due-inbox metrics from a finally block, but the test could both hide its real assertion and bind its instruments to the wrong OpenTelemetry provider.

Finding Why it mattered Fix
SQLite was closed only at the end of the successful try path If an assertion failed, the database stayed open. On Windows, recursive cleanup then raised EBUSY and replaced the useful assertion failure. Keep the store reference outside the try and close it first in finally, before removing the temporary directory.
Global meter registration was not verified OpenTelemetry can reject a second global provider registration. The test would then rebuild instruments against a previously registered provider and the local exporter could contain no datapoint. Disable any prior provider, assert that registration succeeds, and only then rebuild the metric instruments.
The health mock was one-shot and metric warnings were discarded An unexpected extra health read could consume the only mocked result, while the no-op logger hid a metrics-snapshot failure. Make the health result stable for the test and assert that no metrics snapshot failed warning occurs.
Export inspection read only the first point This was unnecessarily coupled to exporter point ordering. Inspect every point exported for each metric descriptor.

No production behavior changed in this round. The test now proves the intended failure-path sequence without allowing cleanup or global telemetry state to obscure the result:

sequenceDiagram
    participant Test
    participant Recovery as FinalizationRecovery
    participant Store as SQLite inbox
    participant Meter as OTel meter provider
    participant Exporter

    Test->>Meter: disable prior provider
    Test->>Meter: register provider and assert success
    Test->>Recovery: processDueBatch(16)
    Recovery->>Store: listDue(16)
    Store-->>Recovery: throw due-read error
    Recovery->>Store: health() from finally
    Store-->>Recovery: dueEntries=7, oldestDueAgeMs=4321
    Recovery->>Meter: record both gauges
    Recovery-->>Test: return 0
    Test->>Meter: forceFlush()
    Meter->>Exporter: export datapoints
    Test->>Exporter: assert both gauge values
    Test->>Store: close() in finally
    Test->>Test: remove temporary directory
Loading

Validation on 6d20e82d1:

  • finalization-recovery.test.ts: 25/25 passed.
  • Focused recovery and lifecycle set: 68/68 passed across four files.
  • git diff --check: passed.

Round 4 medium review

Commit 9622adc28 addresses the contained test gap added after Round 3.

Finding Disposition Evidence
Autonomous SETTLED recovery lacked direct coverage. Fixed. A real SQLite row is first settled without publisher authority, marked with a pending trusted-publisher upgrade, closed, reopened, and processed only through processDueBatch(16). The test proves the worker selects the due SETTLED row, applies upgraded access semantics once, advances generation 0 to 1, and clears publisherUpgradePending.
Due-queue semantics are split between the store and recovery layers. Reported follow-up. A higher-level due-work repository is a cross-layer ownership refactor, not a local correctness patch. The current generation reload, due gate, and transition behavior remain explicitly tested. Follow-up should consolidate the SQL predicate, freshness reload, and retry clock without weakening generation-CAS behavior.
Recovery-worker lifecycle knowledge is distributed across handler, lifecycle, agent stop, and persistence cleanup. Reported follow-up. Consolidating ownership behind a handler/runtime lifecycle API is reasonable but changes startup, stop, and fail-safe teardown boundaries. Current shutdown ordering is now pinned by a deferred-gate test proving the worker is awaited before dependent teardown.

Round 4 validation

  • Focused recovery, SQLite store, worker, and lifecycle suites: 68/68 passed.
  • The new SETTLED case uses a real close/reopen boundary and autonomous processDueBatch; it does not call replayMatching.
  • git diff --check: passed.

Recovery sequence

sequenceDiagram
    participant L as Agent lifecycle
    participant W as Recovery worker
    participant I as SQLite inbox
    participant C as Chain adapter
    participant R as Guarded recovery path
    participant S as Graph store

    L->>W: start after inbox and runtime are ready

    loop while agent is running
        W->>I: listDue limit 16
        I-->>W: oldest due snapshot

        loop each row, strictly serial
            W->>C: resolve KA context-graph binding
            C-->>W: current on-chain graph id
            W->>R: replay persisted raw finalization
            R->>C: resolve canonical receipt
            R->>R: validate placement, authority, and target
            R->>S: existing verified materialization

            alt applied or already applied
                R->>I: generation-CAS to SETTLED
            else busy, deferred, or chain evidence pending
                R->>I: record outcome and monotonic backoff
            end
        end

        alt batch contained 16 rows
            W->>W: schedule next batch immediately
        else partial or empty batch
            W->>W: poll after 5 seconds
        end
    end

    L->>W: stop
    W->>W: cancel timer and await active batch
    W-->>L: drained
    L->>S: continue graph-store shutdown
Loading

Same-entry serialization

sequenceDiagram
    participant L as Live gossip
    participant W as Recovery worker
    participant M as Per-entry FIFO mutex
    participant I as SQLite inbox
    participant S as Graph store

    L->>M: acquire entry key
    W->>M: wait for same entry key
    L->>I: receive or reload durable row
    L->>S: verify and materialize
    L->>I: generation-CAS to SETTLED
    L-->>M: release
    M-->>W: acquire
    W->>I: reload after lock
    I-->>W: state is SETTLED
    W-->>M: no duplicate graph write; release
Loading

The reverse ordering has the same result: whichever lane acquires the key first may materialize; the second reloads current durable state rather than trusting a stale snapshot.

Bounded poison-row lifecycle

sequenceDiagram
    participant W as Recovery worker
    participant I as SQLite inbox
    participant C as Live capacity

    loop due while count or age budget remains
        W->>I: reload due live row
        W->>W: canonical validation or materialization
        W->>I: record failure and monotonic backoff
    end
    W->>I: reload after count ceiling and age window
    W->>I: generation-CAS live state to REJECTED
    I-->>C: row no longer counts against live quota
    Note over I: Terminal row remains auditable and is bounded by terminal retention
    C-->>W: new finalizations can be admitted
Loading

This deliberately avoids changing raw pruning to created_at: a long but legitimate RPC or reorg outage retains durable evidence, duplicate traffic cannot burn the lifetime early, and a poison row still has a finite autonomous lifecycle once both the count and wall-clock bounds expire. Identical gossip for a terminal row is handled without graph work until retention removes that row.

Batching and load bounds

Control Value Purpose
SQLite due-query batch 16 rows Avoid one SQL round trip per row while keeping snapshots bounded.
Worker recovery operations 1 at a time Prevent backlog recovery from multiplying store pressure.
Same-entry concurrency 1 across live, worker, and reconciliation Prevent duplicate writes before state CAS.
Idle/partial poll 5 seconds Bound retry latency without polling an empty database continuously.
Full-batch continuation immediate Drain a real backlog without a 5-second gap per 16 rows.
Deferred retry 1s exponential to 60s Move pressured rows out of the hot set.
Live retry budget 10,080 attempts and a seven-day minimum wall-clock age Duplicate traffic cannot accelerate audited terminalization; both bounds must expire.

At the existing global cap of 128, a fully due inbox needs at most eight 16-row selections to inspect every row. A graph at its 64-row cap spans at most four full selections. Completion time remains dominated by serial chain and graph work.

Measured recovery and concurrency evidence

Scenario Result What it proves
Incident-shaped backlog: 64 RECEIVED rows with attempt_count=0, last_error=NULL, and next_attempt_at=NULL 64 rows reached SETTLED in 416 ms; health changed from capacity-exhausted to ready; the 65th row changed from rejected to admitted The worker drains the exact reported inbox shape and releases capacity.
Fail-before control with due discovery disabled All 64 rows remained RECEIVED with zero attempts after 20 seconds The recovery genuinely depends on autonomous due scanning.
Pre-hardening same-key live/worker probe 2 concurrent apply entries in 3 of 5 runs Reproduces the review's graph-write race.
Post-hardening gated test max active same-key materializations = 1 across apply and replayVerified The entry mutex removes the race, and the test observes both materialization branches.
Focused recovery run 93/93 passed in 25.01 seconds Pins selection, bounds, backoff, reconciliation, lifecycle, concurrency, and terminal duplicates.

The 416 ms result is an in-process real-SQLite review benchmark, not a production Blazegraph latency claim.

Consistency and security invariants

This does not create an alternate or weaker finalization path. The worker calls the #1939 recovery machinery:

  • persisted raw bytes remain the replay source;
  • chain ID, graph, UAL, KA ID, Merkle root, and transaction identity must match the durable entry;
  • the KA-to-graph binding is resolved from the active chain before autonomous replay;
  • canonical receipt placement and publisher/author evidence are revalidated before materialization;
  • trusted-publisher upgrade and settled-reorg handling keep their existing guarded paths;
  • state transitions remain generation-checked;
  • live, worker, and reconciliation paths share per-entry FIFO serialization;
  • raw envelopes are not exposed through status or metric surfaces.

Lifecycle and SQLite compatibility

  • Startup schedules the first pass asynchronously, so backlog size does not block agent startup.
  • Only one worker batch can be active; timer ticks do not overlap.
  • stop() cancels the timer and awaits the active batch.
  • Shutdown stops the worker while chain and graph dependencies are still available.
  • Recovery-store close retains an idempotent fail-safe stop.
  • No schema migration is required.
  • Existing inbox databases created by fix(agent): persist finalization recovery in SQLite #1939 are consumed directly.
  • Due selection includes live RECEIVED, VERIFIED, and REORGED, plus eligible SETTLED publisher-upgrade or receipt-retry work.
  • Ordinary terminal rows are not replayed.

Validation

New coverage

  • bounded oldest-first due selection and exact NULL-due incident shape;
  • future-due and ordinary terminal exclusion;
  • autonomous restart recovery and capacity release;
  • poison-row terminalization and terminal duplicate inertness;
  • same-key live/worker serialization;
  • monotonic retry deadlines;
  • immediate chain reconciliation despite worker backoff;
  • real lifecycle start/stop wiring;
  • due depth and oldest-age health;
  • immediate full-batch continuation, non-overlapping batches, and shutdown drain.

Commands and results

  • Focused finalization recovery suite: 3 files, 93 tests passed; 0 failed.
  • Previous full agent unit suite: 148 files passed; 1,987 tests passed; 5 skipped; 0 failed.
  • Round 2 full agent run: 147 files and 1,988 tests passed; 5 skipped. One unrelated child-process fault-injection test expected SIGKILL but observed exit code 13 under the loaded full run; its immediate isolated rerun passed 3/3.
  • Full core suite: 103 files and 1,580 tests completed successfully; after completion the runner reported one unrelated existing StreamStateError unhandled rejection from test/libp2p-network.test.ts and exited non-zero.
  • pnpm --filter @origintrail-official/dkg-core build: passed.
  • pnpm --filter @origintrail-official/dkg-agent build: passed (tsc, type tests, package-root test).
  • git diff --check: passed.

Risk and rollout

The worker uses conservative fixed bounds. It does not raise inbox capacity, silently delete live evidence, or bypass chain evidence. It contributes at most one serial recovery operation; same-entry live/replay work is mutexed, while independent live operations remain under the existing store scheduler.

After deployment, old eligible rows will begin moving through attempts. Rows whose chain evidence or store capacity remains unavailable keep durable evidence with monotonic deadlines. A row that exhausts both the default attempt ceiling and wall-clock window becomes audited terminal REJECTED and releases live capacity.

Operators can alert on:

  • dkg.finalization_recovery.due_entries;
  • dkg.finalization_recovery.oldest_due_age_ms;
  • dkg.finalization_recovery.attempts_total{outcome};
  • inbox state counts and capacity health.

Scope boundary

This PR fixes autonomous progress for the durable finalization inbox. It does not change Blazegraph atomic graph replacement, store-scheduler admission, reconciliation watermark semantics, or inbox capacity limits. It does not claim that every atomic graph-replace 500/deadline has this root cause; it removes the code-level condition that can leave safely persisted finalizations permanently unconsumed after transient pressure.

Related

  • Durable finalization inbox and guarded replay foundation: #1939

@Jurij89

Jurij89 commented Jul 30, 2026

Copy link
Copy Markdown
Contributor

Review — fix(agent): autonomously retry durable finalization inbox

Reviewed 2ca1f46dd..05124bcad (one commit, 13 files). Three lenses — does it drain a node already stuck, worker correctness/concurrency, and wiring/coverage — each executing the real worker against a real SQLite inbox and mutation-testing the new tests.

The core fix works, and it is well-built. The drain is proven end-to-end with a genuine fail-before, every failure path now records an outcome, and the batch loop is smarter than it looks. But it introduces two HIGH issues, and the first one makes the incident's own failure mode worse in the case that matters. I'd request changes rather than merge as-is.


What genuinely works (proven, not assumed)

  • It drains a node in the exact incident state. 64 rows shaped like the production evidence (RECEIVED, attemptCount=0, lastError=null, nextAttemptAt=NULL) at the per-CG cap of 64: before → health.ready=false, degraded=capacity-exhausted, 65th receive REJECTED. Real FinalizationRecoveryWorker + real SQLite store → byState={"SETTLED":64} in 416 ms, health.ready=true, 65th receive ACCEPTED. Retiring rows really does free the quota.
  • Genuine fail-before. Mutating listDue to return [] — i.e. reinstating fix(agent): persist finalization recovery in SQLite #1939's "nothing scans due rows" — leaves all 64 rows RECEIVED with attemptCount=0 after 20 s. And dropping just the next_attempt_at IS NULL arm turns 3 tests red, so the incident's exact row shape is pinned.
  • A real end-to-end drain test exists at ka-graph-finalization-handler.test.ts:1212-1288 — real inbox, real handler, maxPerContextGraph:1 → capacity-exhausted → reopen → startRecoveryWorker()SETTLED + quads + health.ready. No-op'ing FinalizationRecoveryWorker.start() turns it red. This is the layer that would have caught fix(agent): persist finalization recovery in SQLite #1939.
  • The incident's signature cannot recur. replayDueEntry's catch (finalization-recovery.ts:373) records a deferred attempt with backoff on every failure path, so attempt_count=0, last_error=null can no longer be left behind. I verified this myself.
  • Drain is fast and doesn't hot-loop. runBatch reschedules at 0 ms when a batch comes back full (worker.ts:92), so 64 rows is ~4 back-to-back batches, not 4×5 s. Backoff is clamped to 60 s on both paths — the Math.min(30,…)/Math.min(…,16) exponents only prevent 2**n overflow before the clamp.
  • Suites: 89 passed, 1 skipped. CI does execute the new file on both lanes.

HIGH 1 — a permanently-failing row is now immortal, and this PR removes the only self-heal that existed

This is the one to fix before merge, because it recreates the exact failure the PR targets.

Three facts, each verified by reading the code myself:

  1. recordAttempt writes updated_at = now on every attempt (finalization-recovery-sqlite-store.ts:~530).
  2. The only age eviction for live rows is DELETE … WHERE state IN ('RECEIVED','REORGED') AND updated_at < now - rawTtlMs (finalization-recovery-sqlite-policy.ts:88-90, default 7 days).
  3. Retry backoff is capped at 60 s.

So the worker touches a permanently-failing row at least once a minute, updated_at is always within 60 s of now, and updated_at < now - 7 days can never be true. There is no max attempt count and no terminal state for RECEIVED/VERIFIED/REORGED — the bounded SETTLED_NOT_FOUND_RETRY_LIMIT = 5 only covers SETTLED.

Before this PR, nothing touched the row, so updated_at froze at insert and the 7-day prune eventually reclaimed the slot. Reproduced with a control in the same run (chain receipt permanently pending, rawTtlMs shortened to 300 s):

POISON   state=RECEIVED attemptCount=40   backoff ladder 1000,2000,…,32000,60000,60000,…
         updated_at=3043000  now=3103000  → age 60s < 300s ⇒ never pruned
         health.ready=false degraded=capacity-exhausted
         new finalization → REJECTED (capacity)          [after 35 simulated minutes]

CONTROL  identical row, no worker (= base behaviour)
         new finalization → ACCEPTED (old row evicted after TTL)
flowchart LR
    P["Poison row<br/>state=RECEIVED"] --> W["worker retries<br/>every ≤ 60 s"]
    W --> U["recordAttempt sets<br/>updated_at = now"]
    U --> T{"prune:<br/>updated_at &lt; now − 7 d ?"}
    T -->|"never true"| P
    T -.->|"before #2002:<br/>updated_at frozen"| E["evicted after 7 d<br/>slot reclaimed"]
Loading

Fix direction: bound the live lifetime — after N attempts, or created_at + maxLiveAgeMs, transition the row to a terminal state (REJECTED/UNSUPPORTED) so the terminal TTL and cap reclaim it. Do not simply re-key the raw prune to created_at: that silently discards finalizations during a legitimately long RPC or reorg outage, which is the durability property #1939 exists to provide. Worth checking alongside: receive() (finalization-recovery-sqlite-store.ts:109-116) returns {status:'existing'} for an existing terminal row, so a re-gossiped finalization would be handed back a REJECTED entry.

HIGH 2 — the worker and the inline gossip path can both enter the graph write

The worker's own docstring (finalization-recovery-worker.ts:20) claims it "never introduces concurrent Blazegraph finalization writes." That invariant does not hold. materializer.apply is at finalization-recovery.ts:640; the generation-CAS transition(entry,'SETTLED') is at :701after the write. So the CAS de-duplicates the bookkeeping, not the write. replaySingleFlights (:1172-1181) is populated only inside replayMatching, so processLive never joins it.

Driving Promise.allSettled([processLive(input), processDueBatch(16)]) against a real SQLite store with apply gated so the lanes overlap, 5 runs:

run1 apply=1 enteredGate=1
run2 apply=1 enteredGate=1
run3 apply=2 enteredGate=2   ← both lanes inside the graph write
run4 apply=2 enteredGate=2
run5 apply=2 enteredGate=2

The trigger is precisely the incident state: a row left NULL-due by an earlier busy attempt, plus duplicate gossip for the same KA, plus a 5 s worker tick. The loser's transition returns false and it records a deferred attempt — after its graph write already landed.

In fairness, what this does not establish: whether a double apply is actually harmful depends on whether materialization of the same quads into the same graph is idempotent, and nobody proved it isn't. So the concrete claim is that the documented invariant is false and two concurrent writes occur; the blast radius needs your judgement on apply's idempotency.

Fix direction: a per-entry-key async mutex in FinalizationRecovery wrapping both processLive and replayDueEntry. Don't just reuse replaySingleFlightsprocessLive must return its own boolean for the legacy fallback at finalization-handler.ts:489, so it can share the lock but not the promise.


MEDIUM

  • VERIFIED rows have no age eviction at all. The raw prune covers only ('RECEIVED','REORGED') and the terminal prune only ('SETTLED','SUPERSEDED','REJECTED','UNSUPPORTED')VERIFIED is in neither, yet counts toward capacity. Pre-existing from fix(agent): persist finalization recovery in SQLite #1939, but this PR makes VERIFIED the resting state of a poisoned row (worker verifies, then defers forever), so it moves from theoretical to reachable. Reproduced at 100× the TTL with the clock frozen: still blocking, degraded=capacity-exhausted.
  • Duplicate live gossip erases the worker's backoff. finalization-recovery.ts:277 and :292 call recordDeferred with no delay, and recordAttempt then writes next_attempt_at = NULL unconditionally. Six busy worker rounds → {attemptCount:6, backoff:60000}; one processLive of the same envelope → {attemptCount:7, nextAttemptAt:NULL, dueNow:true}. Backoff resets to zero while the attempt count keeps climbing. Fix: pass a delay at both sites, or make recordAttempt only ever widen the deadline.
  • The lifecycle wiring is pinned by nothing — the exact class that let fix(agent): persist finalization recovery in SQLite #1939 ship. Commenting out all three call sites (dkg-agent-lifecycle.ts:3280-3282, dkg-agent.ts:1760, dkg-agent-base.ts:1667) leaves the relevant suites green: Tests 79 passed | 3 skipped. rfc64-agent-inventory-lifecycle.test.ts is the only file that calls agent.start() with a recovery store attached, and it stays green. Note the fix must not be applied naively — that test attaches a { close } stub as the store, so an assertion there passes vacuously unless it reaches a real FinalizationRecoveryWorker (needs an accessor, or a spy on FinalizationHandler.startRecoveryWorker).

LOW

  • The new due-gate quietly changes fix(agent): persist finalization recovery in SQLite #1939's reconciliation path, unpinned. finalization-recovery.ts:1470-1473 makes chain-reconciliation replayMatching return 'none' for any non-SETTLED entry sitting under the worker's backoff, where it previously replayed immediately. Deleting the guard leaves 92 tests green.
  • No telemetry. The worker emits one log line per non-empty batch and a warn on batch failure — there is no metric for inbox depth, age of the oldest due row, or retry/success counts, and I found no gauge precedent to hook into. This incident stayed invisible through both review and testnet precisely because nothing exposed queue depth; after this PR a queue that silently stops draining again is still unobservable. A depth gauge plus oldest-due-age would make the whole class alertable, and is the cheapest insurance against a third round of this.

Merge readiness

Request changes. HIGH 1 is the blocker: as written, this PR converts a slow self-healing case (7-day eviction) into a permanent one, and capacity exhaustion is the exact symptom that took the node down. HIGH 2 needs at least an explicit decision on apply idempotency before it ships. The three MEDIUMs are all small, and the wiring one deserves attention on principle — an unpinned start call is how #1939 shipped broken in the first place.

None of that detracts from the main point: the diagnosis is right, the drain is real, and the fail-before test is genuine.

Method: 3 lenses over the delta, schema-free, executing the real worker against real SQLite inboxes; every claimed test mutation-tested with a proven restore. One caveat I'll state rather than hide: the lenses shared a worktree and one left a listDue mutant live briefly while another was measuring — so I re-verified both HIGH mechanisms myself by reading (recordAttempt's updated_at write vs the prune predicate; apply at :640 vs the CAS at :701), and confirmed packages/agent/src/ is clean at review end. I also independently verified the wiring, the listDue selector, the 60 s backoff clamp, the 0 ms full-batch reschedule, and that #2002's base already contains the merged #1991/#1994 store-scheduler work.

@Jurij89

Jurij89 commented Jul 30, 2026

Copy link
Copy Markdown
Contributor

Round 2 — re-review at 92e0efeca

Reviewed 05124bcad..92e0efeca. Every round-1 finding is fixed, and the two HIGHs were fixed properly rather than patched. One new HIGH, which I'll own up front: it is a direct consequence of the remedy I recommended.

The delta beyond b048b4b36 is one test-only commit (92e0efeca, packages/chain), which I reviewed directly rather than through the lenses — see the note at the end.

Round 1 Status
H1 immortal poison row Fixed. finalization-recovery.ts:390-402 transitions to REJECTED once isLiveEntry && attemptCount >= liveRetryLimit. Capacity counts only live states, so the slot frees immediately, and REJECTED falls under the terminal prune. Mutating >= this.liveRetryLimit to + 1000 turns it red.
H2 double-apply Genuinely fixed. A union-counter probe over {apply, replayVerified} gives maxSection: 1; removing await previous gives maxSection: 2. No re-entrancy deadlock — replayDueEntryLocked deliberately inlines matchingEntries + replayEntry rather than calling replayMatching. No map leak, unrelated keys not serialised.
M VERIFIED unbounded Fixed — isLiveEntry (:471-475) covers VERIFIED, so the VERIFIED-poison case is bounded too.
M backoff wipe Fixed — recordAttempt now CASE … ELSE MAX(next_attempt_at, ?), widen-only. Pinned by a new test.
M lifecycle wiring unpinned Fixed, and pinned the way I hoped. finalization-recovery.test.ts:565+ builds a real DKGAgent, spies on the handler before agent.start(), asserts startWorker called once, then await agent.stop() and asserts stopWorker. It also asserts handler identity, so it can't pass vacuously — which was the trap I flagged.
L due-gate/reconciliation Pinned by a new test.
L no telemetry Addressed — dueEntries + oldestDueAgeMs in health, telemetry-api.ts gauges, status-route depth.

Suite: 94 passed / 1 skipped.


HIGH — the retry budget is an attempt count burned by two unthrottled lanes, so a legitimate finalization can be permanently rejected

This one is on me. In round 1 I wrote "after N attempts, or created_at + maxLiveAgeMs". The count form was chosen, and it is the fragile half — because attemptCount is not a clock.

FINALIZATION_RECOVERY_LIVE_RETRY_LIMIT = 7*24*60 = 10080 is documented as "approximately seven days", which is only true if the worker is the sole consumer at one attempt per 60 s. It isn't. Three callers increment it and only one is time-spaced:

  • processLive has no due gate and passes no delay to recordDeferred (:305, :323 — I verified both call sites). Under the new widen-only CASE … ELSE MAX(...), a NULL delay leaves next_attempt_at untouched, so duplicate gossip now increments the counter for free.
  • replayMatching (chain reconciler) likewise bypasses the due gate by design (:1592-1596).
  • The processedUals dedupe at finalization-handler.ts:715 sits in processFinalization, which is only reached when processLive returns false — with the durable inbox configured, every duplicate reaches processLive first.

So the real budget is 10080 / (1 + duplicates-per-minute). And exhaustion is terminal for both lanes — I verified this myself:

  • :299if (!this.isLiveEntry(entry)) return true; — re-gossip is swallowed as "handled" and never materialized. The comment states the intent: "duplicate gossip must not revive an entry that exhausted the autonomous retry budget."
  • :1589replayEntry returns 'none' for a non-live, non-SETTLED entry, so authoritative chain reconciliation refuses it too.

Reproduced with liveRetryLimit: 5 and a frozen clock (zero ms elapsed throughout):

AFTER 3 CHAIN-RECONCILIATION PASSES:  { state:'RECEIVED', attemptCount:3 }
AFTER 5 DUPLICATE GOSSIP DELIVERIES:  { state:'RECEIVED', attemptCount:5, applyCalls:0 }
AFTER ONE WORKER TICK, HEALTHY CHAIN: { state:'REJECTED',
                                        lastError:'autonomous retry limit exhausted after 5 attempts',
                                        applyCalls:0 }
AFTER RE-GOSSIP + CHAIN RECONCILIATION: { state:'REJECTED', replayOutcome:'none', applyCalls:0 }

The chain was healthy at the moment of rejection, the KA was never materialized, and nothing can revive it. Pre-PR this row self-healed via the 7-day raw prune. The realistic trigger is a KA gossiped by many peers during a multi-hour chain outage — no adversary required.

Fix: make the gate wall-clock, which is the other half of what I suggested in round 1 — reject only when now - entry.createdAt >= liveRetryWindowMs, optionally && attemptCount >= limit as a belt-and-braces bound. createdAt is already on the entry, so no schema change. Applied literally it will break the author's liveRetryLimit: 2 test, which advances a fake clock only to nextAttemptAt; that test must also advance now past the window.

MEDIUM — the new lock test does not actually pin the lock

finalization-recovery.test.ts "serializes live and autonomous materialization for the same inbox key" counts concurrency inside apply only. Once the live lane promotes the row to VERIFIED, the worker lane takes replayVerified and never enters apply, so the counter is structurally capped at 1. Mutating :487 await previous.catch(…)void previous (lock disabled) leaves the test passing.

Fix: count apply and replayVerified in one shared counter — that variant does go red with the lock disabled, so it is a true guard for the H2 fix.

MEDIUM — degradedReason overwrite masks capacity-exhausted, the loudest incident signal

dkg-agent-base.ts:1691-1703 spreads ...health then conditionally rewrites degradedReason: 'canonical-finalization-receipt-unsupported', clobbering the store's 'capacity-exhausted'. In the incident replay, capacity-exhausted was reported at every sample — it is the single clearest signal the store emits, and this PR's own status-route fixture is exactly the masking configuration. Pre-existing, but it blunts this PR's stated purpose. Fix: make the reason additive (degradedReasons: string[]) or prefer the store's. A literal string swap breaks two toEqual assertions.

MEDIUM — oldestDueAgeMs measures age-since-receipt, not age-of-non-progress

finalization-recovery-sqlite-store.ts:596-609 uses MIN(created_at), which never moves. A row being retried correctly still ages monotonically, and with a 60 s backoff cap and a 10,080 limit a healthy-but-slow row legitimately climbs toward days. So "oldest due age is large" ≠ "stuck", which is precisely the discrimination an operator needs. Fix: expose MIN(updated_at) over the due set as well (attempts touch updated_at) — emit both, since updated_at alone loses the never-attempted case.

LOW — the new gauges silently freeze on the two paths that matter

recordDueMetrics runs only at finalization-recovery.ts:356; the early returns at :340 and :344-351 skip it. So when listDue throws (store read failing) or the chain binding is absent — exactly when you want the signal — the gauges retain their last-good value and only a log.warn remains. Move it into a finally.

Nit — the new packages/chain test commit

92e0efeca is a correct fix: two eth_getCode probes run in parallel, so asserting their server arrival order was testing an implementation accident. Swapping to set-equality is right. One small thing — the Set drops the count, so a duplicated probe (TO, TO, OTHER_TO) collapses to the same set and passes. expect(server.calls.slice(2)).toHaveLength(2) alongside it keeps the flake fix without losing that.


Merge readiness

One thing to fix. The HIGH is a genuine data-loss path for a legitimate finalization, and the remedy is small — swap the count gate for a wall-clock window. The lock-test MEDIUM is worth doing at the same time, since it currently gives false confidence in the H2 fix that is correct.

Everything else is small, and the substance of this round is good: H1 and H2 are properly fixed, the wiring is pinned end to end, and the observability additions are real. My round-1 finding about the lifecycle wiring was valid and is now closed by a test that can't pass vacuously.

Method: 2 lenses over the delta, schema-free, executing the real modules and mutation-testing each new test with proven restores. I independently verified the wiring test's start/stop assertions, the processLive no-delay call sites, both terminal-refusal paths (:299, :1589), isLiveEntry's VERIFIED coverage, the capacity/prune state sets, and the packages/chain commit.

@lupuszr
lupuszr marked this pull request as ready for review July 31, 2026 07:06
if (!store.isAttemptDue(entry)) return 'retry-pending';
if (
this.isLiveEntry(entry)
&& entry.attemptCount >= this.liveRetryLimit

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 Bug: Live duplicate gossip can exhaust the autonomous retry budget

What's wrong
The new exhaustion check treats attemptCount as if it only counted autonomous worker retries. It is shared with live processing, and live duplicate gossip still calls materialization and recordDeferred even while a future retry deadline is present. That allows ordinary duplicate traffic, or a peer replaying the same message, to turn a recoverable inbox row into a terminal REJECTED row much sooner than the documented retry window.

Example
A valid finalization has a temporarily pending receipt. The worker sets nextAttemptAt and attemptCount=1. Repeated duplicate gossip before that deadline calls processLive, records more deferrals, and can drive attemptCount to 10080 in minutes or hours. When the worker next sees the entry due, it rejects it as exhausted instead of continuing the intended seven-day autonomous retry window.

Suggested direction
Track autonomous worker attempts separately from live/reconciliation deferrals, or only apply liveRetryLimit to attempts actually made by the worker. Another acceptable direction is to avoid incrementing the shared attemptCount for live duplicates that arrive before the persisted retry deadline.

For Agents
Look in FinalizationRecovery around processLive, recordDeferred, and replayDueEntryLocked. Preserve the worker's poison-entry cap, but do not let duplicate live gossip or chain reconciliation spend the autonomous retry budget. Add a test with a low liveRetryLimit where live duplicates during backoff do not cause the next due worker pass to REJECT a still-valid entry.

merkleRoot: entry.merkleRoot,
kaId: entry.kaId,
};
const matches = await this.matchingEntries(replayInput);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Issue: The due worker should not route one due row through the KA-wide reconciliation selector

What's wrong
This makes the new autonomous worker depend on the chain-cursor reconciliation abstraction even though it already has an exact due entry. That adds an avoidable query, a synthetic FinalizationRecoveryReplayInput, another filter by key, and more coupling between two different orchestration modes. It also keeps growing an already very large recovery class instead of deleting complexity around a clearer entry-level replay primitive.

Example
A batch with 16 due entries for the same KA now runs the KA-wide reconciliation selector 16 times, then discards every match except the original key each time. The resulting flow is harder to reason about than an entry-centric replay path that validates the one due row directly.

Suggested direction
Extract the canonical eligibility/read logic from matchingEntries into a smaller helper, then let autonomous due replay process the already-selected entry directly. If batch efficiency matters, group due rows by KA and share the chain reads rather than repeatedly calling the reconciliation selector.

For Agents
Look at replayDueEntryLocked, matchingEntries, and replayEntry in packages/agent/src/finalization-recovery.ts. Preserve autonomous recovery outcomes and reconciliation behavior, but split out a single-entry due replay path or shared canonical-chain snapshot helper so the worker does not synthesize a reconciliation request and re-query/filter the same inbox row. Existing autonomous recovery and reconciliation tests should still pass, with coverage proving due replay and cursor replay share the same canonical checks without looping through each other.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Issue: Autonomous worker orchestration is being added to an already oversized recovery state machine

What's wrong
This makes the central recovery class harder to scan and reason about. The PR is not just adding a method; it is mixing lifecycle/timing policy with canonical replay and state-transition logic, which is the kind of growth that turns this file into the only place where finalization behavior can be safely changed.

Example
A single due inbox row now flows through processDueBatch -> replayDueEntryLocked -> matchingEntries -> replayEntry -> replaySettled/recover..., while the same class also records telemetry and manages retry-budget policy.

Suggested direction
Extract the autonomous due-replay runner/policy into a focused module that owns listDue, due metrics, retry budgets, and entry serialization, and have it call a narrow recovery API for state transitions/materialization. That would keep FinalizationRecovery closer to a durable state machine instead of making it also own worker orchestration.

For Agents
Look in packages/agent/src/finalization-recovery.ts around processDueBatch, replayDueEntryLocked, recordDueMetrics, withEntryLock, and the new retry options. Preserve live finalization, chain reconciliation, autonomous drain, backoff, and same-key serialization behavior. Prove the refactor with the existing autonomous drain/backoff/entry-lock tests plus a reconciliation replay test.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Issue: The autonomous due-worker workflow is being bolted into an already oversized recovery class

What's wrong
This change substantially widens the responsibilities of a class that was already too large to scan comfortably. The new worker class is mostly a timer wrapper; the real worker behavior is embedded in FinalizationRecovery, so the codebase gains a new concept without a real ownership boundary. That makes future changes to live finalization, chain reconciliation, retry policy, and autonomous replay more likely to interfere with each other.

Example
A reader trying to understand one due worker tick now has to follow processDueBatch -> replayDueEntry -> replayDueEntryLocked -> matchingEntries -> replayEntry -> replaySettled, while also tracking live-gossip locking and metrics from the same 2k-line class.

Suggested direction
Move the new autonomous retry workflow behind a dedicated replayer/policy module instead of growing FinalizationRecovery into a scheduler, metrics emitter, lock manager, replay selector, and state machine all at once.

For Agents
Look in packages/agent/src/finalization-recovery.ts. Preserve the current live admission and replay behavior, but extract the autonomous due-entry workflow into a focused collaborator, for example FinalizationRecoveryDueReplayer, that owns processDueBatch, due metrics, retry budget/backoff policy, and per-entry serialization. Keep FinalizationRecovery as the canonical owner of durable entry transitions and materialization primitives. Existing finalization recovery tests should still pass without changing observable outcomes.

snapshot: FinalizationRecoveryEntry,
input: FinalizationRecoveryReplayInput,
replayKey: string,
deferredRetryDelay?: number,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Issue: Make replay mode explicit instead of using an optional delay as a control flag

What's wrong
The optional deferredRetryDelay parameter is doing too much: it carries a delay value, selects autonomous-vs-reconciliation behavior, controls whether persisted deadlines are honored, and changes returned outcomes. Threading that nullable flag through several settled/live recovery helpers makes the boundary muddy and invites future special-case branching.

Example
deferredRetryDelay !== undefined currently means both “this is autonomous replay” and “persist backoff / return retry-pending / respect attempt deadlines”. A future settled-retry change has to know that passing undefined is not just “no delay”; it changes the replay mode.

Suggested direction
Introduce a small typed replay policy/context object or split autonomous and reconciliation replay into separate entry points. That would make deadline handling, retry outcome mapping, and persisted backoff explicit instead of relying on undefined as hidden control flow.

For Agents
Replace the optional numeric mode flag in replayMatching, replayEntry, replaySettled, recoverSettledPublisherUpgrade, and recoverSettledReorg with an explicit replay context, for example a union like { kind: 'reconciliation' } | { kind: 'autonomous'; backoffMs: number; respectDeadline: true }. Preserve existing live, reconciliation, and worker behavior, and keep tests around worker backoff plus immediate reconciliation before deadline.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Issue: The worker mode leaks through replay as optional retry-delay plumbing

What's wrong
The new optional parameter is doing too much: it controls due-deadline checks, whether deferrals persist backoff, and how outcomes are mapped. That makes the replay path harder to modify because a future change must understand a hidden mode protocol rather than a named abstraction.

Example
deferredRetryDelay === undefined means reconciliation mode: ignore live-entry deadlines and map deferrals to none. A number means autonomous mode: honor deadlines, persist backoff, and return retry-pending. That implicit mode contract is spread across several branches.

Suggested direction
Replace the nullable delay flag with an explicit policy boundary, or split reconciliation replay from autonomous due replay so each path has direct return/outcome handling. The lower-level helpers should not need to infer caller intent from whether a number is undefined.

For Agents
Refactor packages/agent/src/finalization-recovery.ts around replayMatching, replayEntry, replaySettled, and the settled recovery helpers. Preserve the different reconciliation-vs-worker retry semantics, but model them explicitly with a small ReplayPolicy/ReplayMode object or separate entry points. Tests should cover chain reconciliation before deadline and worker deferral/backoff.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Issue: Autonomous replay mode is threaded through the main replay path as optional flags

What's wrong
This PR adds a second replay mode into an already very large FinalizationRecovery class by using optional parameters as hidden mode switches. The result is harder to scan and easier to extend incorrectly because worker-specific backoff, reconciliation semantics, settled retry handling, and outcome mapping are now interleaved across the same helper chain.

Example
A reader now has to know that undefined means chain reconciliation/live replay semantics, while a number means autonomous worker semantics. That mode controls due gating at lines 1616-1620 and result mapping at lines 1681-1684 and 1716-1718.

Suggested direction
Introduce an explicit ReplayPolicy/ReplayTrigger model, or move autonomous replay into a small FinalizationDueReplayer that owns due gating, backoff, and retry-budget handling. Keep the core replay helpers focused on replaying one fresh entry rather than inferring caller mode from optional parameters.

For Agents
Refactor packages/agent/src/finalization-recovery.ts around processDueBatch, replayMatching, and replayEntry. Preserve behavior where chain reconciliation ignores persisted retry deadlines and the worker respects them, but represent that with an explicit replay policy/trigger or extract a focused background replayer instead of passing optional delay flags through settled/live helpers. Existing recovery tests should continue proving worker backoff and reconciliation-before-deadline behavior.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Issue: The replay mode is encoded as an optional retry delay

What's wrong
Using undefined as a mode flag makes the replay flow harder to reason about and spreads autonomous-worker policy across settled replay, live-entry replay, and deferred recording branches. This is exactly the kind of nullable mode that tends to accumulate more special cases over time.

Example
deferredRetryDelay !== undefined currently means “this is autonomous replay, enforce persisted retry deadlines, persist backoff, and surface retry-pending.” That invariant is implicit and must be rediscovered at every call site that receives the optional number.

Suggested direction
Model the replay trigger/backoff policy explicitly, or isolate autonomous replay so the regular replay path does not need nullable mode flags threaded through several private helpers.

For Agents
Replace the optional deferredRetryDelay / persistDeferredBackoff threading in packages/agent/src/finalization-recovery.ts with an explicit replay context, such as { trigger: 'reconciliation' | 'autonomous-worker', deferredRetryDelayMs?: number }, or split the autonomous path into its own helper that computes backoff once. Preserve that chain reconciliation can ignore worker deadlines while autonomous replay respects them. Tests around replayMatching, SETTLED replay, and due worker backoff should continue to prove both modes.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Issue: Make replay mode explicit instead of encoding it as an optional delay

What's wrong
This change adds a hidden mode flag across the replay stack. The optional number is doing too much: it is both data and a control-flow selector. That makes the recovery path harder to modify safely because every replay helper now has to understand both chain reconciliation semantics and autonomous worker semantics.

Example
replayMatching(input, { persistDeferredBackoff: true }) actually means: observe the persisted due deadline, persist exponential backoff on deferral, and return retry-pending instead of none. A reader only discovers that by following the optional number several methods down the call stack.

Suggested direction
Introduce an explicit replay policy object or split the autonomous due-replay processor from reconciliation replay. The helpers should receive a named mode with clear operations like shouldObserveDeadline, deferredDelayMs, and deferredOutcome, rather than inferring mode from whether a number is undefined.

For Agents
In packages/agent/src/finalization-recovery.ts, replace the optional backoff plumbing with an explicit replay context/policy, e.g. a discriminated ReplayTrigger for reconciliation vs autonomous. Preserve current worker and reconciliation behavior, and keep the existing deadline/backoff tests proving the two modes differ intentionally.

`Finalization recovery worker batch failed: `
+ `${error instanceof Error ? error.message : String(error)}`,
);
} finally {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Issue: Worker failure recovery is not verified

What's wrong
The autonomous inbox worker is meant to be the durable retry mechanism, but the added tests only cover successful batches and shutdown. A transient exception from the batch callback could stop future retries if the catch/finally scheduling regressed, leaving due finalization entries stuck with all current tests still green.

Example
A focused test could use vi.fn().mockRejectedValueOnce(new Error('busy')).mockResolvedValueOnce(0), start a worker with a tiny pollIntervalMs, and assert the batch function is called a second time and warn is emitted. Without the catch/finally reschedule behavior, that test would fail while the current worker tests still pass.

Suggested direction
Add a worker test that exercises the catch/finally path after a transient processDueBatch rejection and proves the retry loop continues.

For Agents
Add coverage in packages/agent/test/finalization-recovery-worker.test.ts for a rejected processDueBatch: preserve that the worker logs the failure, remains running, and schedules another poll without overlapping work. Use a short poll interval or fake timers so the test is deterministic.

@Jurij89

Jurij89 commented Jul 31, 2026

Copy link
Copy Markdown
Contributor

Round 3 — re-review at 5f23ae4e1

Reviewed 92e0efeca..5f23ae4e1 (two commits, 6 files, +159/-34). Five of my six round-2 findings are fixed, each with a test that pins it. Approve.

The HIGH is correctly fixed

finalization-recovery.ts:405-412 now requires both bounds:

this.isLiveEntry(entry)
  && entry.attemptCount >= this.liveRetryLimit
  && liveRetryAgeMs >= this.liveRetryWindowMs        // new

with FINALIZATION_RECOVERY_LIVE_RETRY_WINDOW_MS = 7 * 24 * 60 * 60 * 1000 and liveRetryAgeMs = Math.max(0, this.now() - entry.createdAt) — the Math.max also guards clock skew. That closes the data-loss path: duplicate gossip and reconciliation can still burn the count early, but the row cannot become terminal until seven wall-clock days have actually elapsed, so a legitimate finalization during a long chain outage survives.

Worth noting the two bounds are coherent rather than coincidentally similar: at the 60 s backoff ceiling the worker makes ~1,440 attempts/day, so 10,080 attempts is seven days for the worker-only case. The count now bounds the healthy path and the window bounds the adversarial one.

And the trap I flagged is handled. I warned that this fix would break the liveRetryLimit: 2 test, which drove a fake clock only to nextAttemptAt. The test now injects now: () => now into the recovery and sets now = entry!.createdAt + 10_000 — deriving the target from the stored createdAt rather than an independent constant, which is the right way to keep the two clocks reconciled.

The rest

  • The lock test now genuinely pins the lock. enterMaterializationSection() is a single shared section entered by both apply and replayVerified, with maximumConcurrentMaterializations asserted to be 1 — exactly the union-counter shape needed, since the old version was structurally capped at 1 by the worker never reaching apply. It also asserts applyCalls === 1 and replayVerifiedCalls === 1, so it can't pass by one lane never running.

  • degradedReason no longer masks capacity-exhausteddkg-agent-base.ts:1694-1701 adds && health.degradedReason === undefined, preferring the store's reason. Pinned by a new test (rfc64-agent-inventory-lifecycle.test.ts) that uses the exact 64-row incident shape and asserts capacity-exhausted survives canonicalReceiptCapability: 'unsupported'.

  • The gauge freeze is fixedrecordDueMetrics moved into a finally, and the early returns moved inside the try, so it now runs on the store-read-failure and missing-chain-binding paths too. I checked the obvious hazard of putting work in a finally: recordDueMetrics has its own try/catch (:378-388) that warns and swallows, so a metrics failure cannot break the batch or mask the return value.

  • Nit takenexpect(server.calls.slice(2)).toHaveLength(2) added alongside the set comparison, so a duplicated probe no longer collapses into a passing set.

  • 5f23ae4e1 adds a worker resilience test, and it's a real one. It pins the property that a throwing batch must not kill the loop: processDueBatch rejects once, the exact warn is asserted, worker.running stays true, and after advancing the fake clock by the poll interval it is called a second time. Removing either the catch or the finally reschedule turns it red. It also incidentally pins that a failed batch reschedules at pollIntervalMs rather than the full-batch 0 ms — selected stays 0 on that path, which the 25 ms advance confirms.

Suites: finalization-recovery, finalization-recovery-worker, finalization-recovery-sqlite-store48 passed, 1 skipped (run at c98c796a5; 5f23ae4e1 only adds the test above).

Still open (from round 2)

  • oldestDueAgeMs measures age-since-receipt, not age-of-non-progress. finalization-recovery-sqlite-store.ts:594 is still MIN(created_at), which never moves — so a row being retried correctly ages identically to one that is stuck, and that's precisely the discrimination the metric exists to provide. Emitting MIN(updated_at) over the due set alongside it (attempts touch updated_at) gives the operator the "nothing has progressed in N minutes" signal; keep created_at too, since updated_at alone loses the never-attempted case.

One new note, not a finding

The age gate compares this.now() (defaults Date.now) against entry.createdAt, which is written by the store's policy.now(). In production both are Date.now, and the new test reconciles them by deriving from the stored value — so nothing is wrong today. But they are two independent knobs, and a future caller that injects a clock into only one of them would silently get a meaningless age and either an immediate or an unreachable rejection. Worth a comment at the constructor, or sourcing both from one clock.

Merge readiness

Approve. The data-loss path is closed, the fix carries a real regression test, and the two tests I called decorative now kill their mutants. The remaining MEDIUM is an observability sharpening, not a correctness issue, and is fine as a follow-up.

Method: reviewed directly rather than via agents — the delta is 130 lines and every claim above is checkable by reading. I verified the gate condition, the clock wiring in both production and test, the shared-counter lock test, the degradedReason guard and its new test, the finally/try-catch interaction, and ran the three finalization suites.

return 0;
}
for (const entry of entries) {
const outcome = await this.replayDueEntry(entry);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 Bug: One poison due entry can block the autonomous recovery queue

What's wrong
The new autonomous worker is supposed to drain a durable retry queue, but a single due entry that throws outside the retryable-error path aborts the entire batch. Because that row is not updated, it remains due and ordered ahead of later rows, so unrelated finalizations can be starved indefinitely.

Example
If listDue(16) returns [poisonEntry, validEntry] and materializer.apply or replayVerified throws a non-retryable error for poisonEntry, processDueBatch exits before validEntry is replayed. On the next poll the same poisonEntry is still oldest/due, so the autonomous worker can keep retrying it and never drain later valid finalizations.

Suggested direction
Handle replay failures per entry instead of letting one exception abort the whole batch; make sure the failed entry is not immediately selected forever without updated retry state.

For Agents
In packages/agent/src/finalization-recovery.ts, add a per-entry error boundary around replayDueEntry inside processDueBatch. Preserve fail-closed behavior for the bad entry, but record a deferred/backoff or terminal state when appropriate and continue processing the rest of the batch. Add a test with two due rows where the first replay throws a non-retryable error and the second still settles or is attempted.

);

await expect(recovery.processDueBatch(16)).resolves.toBe(0);
expect(health).toHaveBeenCalledTimes(1);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 Bug: Due-metrics test never observes the emitted metrics

What's wrong
The added test claims to verify metric refresh on a due-inbox read failure, but it only spies on the intermediate health read. That gives false confidence for the new telemetry contract: incorrect, missing, or zeroed metric recordings would not be caught.

Example
Removing the two gauge writes in recordDueMetrics while leaving the store.health() call intact would keep this test green, even though dkg.finalization_recovery.due_entries and oldest_due_age_ms would stop being emitted.

Suggested direction
Assert the actual metric datapoints or mocked gauge calls, not only that the health snapshot was fetched.

For Agents
Use the existing OpenTelemetry test pattern from the metrics tests: install an in-memory meter provider, call rebuildMetrics(), drive processDueBatch() through the failed listDue path with known health() values, force-flush, and assert the finalization recovery gauge datapoints. Consider also asserting finalizationRecoveryAttemptsTotal labels on a successful due batch.

stateCounts: { RECEIVED: 1 },
});
await agent.stop();
expect(stopWorker).toHaveBeenCalled();

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Issue: Shutdown test does not prove the recovery worker is awaited

What's wrong
The change adds an awaited shutdown boundary so no autonomous finalization retry continues while chain and graph-store dependencies are being torn down. The current test would pass if the call were made without awaiting it, so it does not verify the behavior that prevents shutdown races.

Example
A regression such as changing the production call to void this.finalizationHandler?.stopRecoveryWorker() would still satisfy this assertion, but shutdown could continue while a recovery batch is still using chain/store dependencies.

Suggested direction
Add an integration assertion that DKGAgent.stop() awaits the worker stop promise before continuing shutdown, not just that it invokes the method.

For Agents
In finalization-recovery.test.ts or an agent lifecycle test, mock stopRecoveryWorker to return a deferred promise, call agent.stop(), assert stop does not complete or dependent teardown does not proceed until the deferred promise is released, then assert it completes. Preserve the existing start/handler reuse assertions.

clearSettledRetry(key: string, generation: number): Promise<void>;
rejectSettled(key: string, generation: number, lastError: string): Promise<boolean>;
isAttemptDue(entry: FinalizationRecoveryEntry): boolean;
/**

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Issue: Due-queue semantics leak across the store and recovery layers

What's wrong
The durable inbox is now both a persistence store and a retry queue, but the queue contract is spread across low-level methods and comments. That makes the implementation depend on callers remembering to re-read snapshots, re-check deadlines, and rely on generation transitions in the right order. This is maintainability debt in the new worker path rather than a clean ownership boundary.

Example
Adding a new retryable state or changing due eligibility now requires keeping DUE_FINALIZATION_SQL_PREDICATE, isLiveEntry, listDue, isAttemptDue, and replayDueEntryLocked aligned by convention.

Suggested direction
Replace the loose trio of listDue/get/isAttemptDue with a higher-level due-work API, such as loadDueBatch(limit) returning fresh due entries under the store’s serialization boundary, or a small queue repository that owns the SQL predicate, freshness check, and retry clock. That would let recovery process entries without reassembling queue invariants manually.

Confidence note
This is a structural concern from the diff shape; the generation-checked transitions may make behavior safe, but the queue ownership boundary is still unnecessarily leaky.

For Agents
Look at packages/agent/src/finalization-recovery-store.ts, packages/agent/src/finalization-recovery-sqlite-store.ts, and processDueBatch/replayDueEntryLocked in finalization-recovery.ts. Preserve bounded oldest-first processing and generation-checked transitions, but move due-entry selection/freshness into a single store/repository-level operation or a dedicated due-queue abstraction.

// write-ahead journal. Its lifecycle is independent of chain-cursor
// progress so entries received after a watermark advance are still
// reconsidered. The worker batches SQLite reads but serializes graph work.
if (this.finalizationRuntime.getRecoveryStore()) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Issue: Recovery worker lifecycle is scattered across agent layers

What's wrong
This couples a handler-internal retry loop to broad agent startup, stop, and persistence teardown code. It works, but it makes future lifecycle changes brittle because every new stop/reconfigure path has to remember the same resource and ordering rule.

Example
The lifecycle of one timer-backed resource is now split across three classes: FinalizationHandler owns it, LifecycleSyncMethods starts it, and both DKGAgent plus DKGAgentBase know to stop it.

Suggested direction
Give the owner a single lifecycle method, for example FinalizationHandler.start()/close() or a runtime-owned finalization lifecycle hook, and call that from the existing agent lifecycle boundary. Avoid making generic persistence cleanup know about a handler-internal worker.

For Agents
Consolidate worker lifecycle ownership around FinalizationHandler or the finalization runtime. Preserve the shutdown ordering that stops the worker before chain/graph-store dependencies close, but expose one lifecycle boundary rather than requiring multiple agent layers to know about the worker.

const now = this.#policy.now();
return this.database.prepare(`
SELECT * FROM finalization_inbox_v1
WHERE ${DUE_FINALIZATION_SQL_PREDICATE}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Issue: Autonomous SETTLED recovery lacks direct regression coverage

What's wrong
The PR expands the due queue beyond live entries, but the tests do not exercise the new autonomous replay path for SETTLED entries. That leaves a meaningful gap: regressions in the worker-only setup of replay input, matching, or pending publisher-upgrade handling could still pass because the current SETTLED tests enter through chain reconciliation instead.

Example
A persisted SETTLED row with publisherUpgradePending: true after restart should be picked up by processDueBatch(16), validate the trusted publisher, apply the upgraded access semantics, and clear publisherUpgradePending. Likewise, a SETTLED receipt retry whose nextAttemptAt is due should be replayed by the worker, not only by replayMatching.

Suggested direction
Add a worker-driven test for at least one due SETTLED row, preferably covering the publisher-upgrade case because it is newly admitted by publisher_upgrade_pending even without a retry timestamp.

For Agents
Look in packages/agent/test/finalization-recovery.test.ts or the handler integration tests. Seed or create a SETTLED row that is due, then call processDueBatch or startRecoveryWorker; assert the row is recovered/updated and that retry state is cleared or advanced as appropriate. Preserve the existing chain-reconciliation tests; this should specifically prove the autonomous path.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Issue: Bounded due-batch behavior is not directly tested

What's wrong
This PR makes the finalization inbox an autonomous retry queue, and the safety story depends on bounded SQLite reads plus serialized graph work. The new tests cover due filtering and ordering, but they do not prove that a positive limit is honored, so a future regression could process an unbounded backlog while this suite stays green.

Example
Seed three due RECEIVED rows, call await store.listDue(1), and expect exactly the oldest single key. A companion processDueBatch(1) assertion could prove only one due row is processed per batch.

Suggested direction
Add a regression test with more due entries than the requested positive limit, not just the zero-limit edge case.

For Agents
Look at packages/agent/test/finalization-recovery-sqlite-store.test.ts near the new listDue coverage. Add a case where due rows exceed the requested positive limit and assert oldest-first truncation; optionally verify FinalizationRecovery.processDueBatch(1) only processes one row to preserve worker backpressure.

@Jurij89

Jurij89 commented Jul 31, 2026

Copy link
Copy Markdown
Contributor

Round 4 — re-review at 9622adc28

Reviewed 5f23ae4e1..9622adc28 (two commits: 697c7308a batch-failure isolation, 9622adc28 settled-upgrade replay test).

The suite is currently red, and the failure is masked twice. That's the headline — everything else here is good.

HIGH (blocking) — finalization-recovery.test.ts fails on this head

$ pnpm exec vitest run --config vitest.unit.config.ts test/finalization-recovery.test.ts
 Test Files  1 failed (1)
      Tests  1 failed | 24 passed (25)

 FAIL  … > emits due metrics even when the due-inbox read fails
 Error: EBUSY: resource busy or locked, unlink
   '…\dkg-finalization-due-metrics-At7Wsr\finalization-inbox-v1.sqlite3'

The EBUSY is not the real failure — it is a teardown bug hiding one. In that test await store.close() is the last statement inside the try rather than in the finally, unlike the eight sibling tests in the same file which all use await store?.close().catch(() => {}) in teardown. So when an assertion throws, close() is skipped, the finally's rm(directory, …) hits a live SQLite handle, and on Windows the EBUSY replaces the assertion error.

Moving close() into the finally locally and re-running surfaces the actual failure:

AssertionError: expected undefined to be 7 // Object.is equality
 ❯ test/finalization-recovery.test.ts:320
   expect(datapoints.get('dkg.finalization_recovery.due_entries')).toBe(7)

So on the listDue-throws path the due_entries gauge produces no exported datapoint. That matters more than a flaky test, because this is precisely the test added to cover my round-2 finding that the gauges freeze on the early-return paths — so either that fix does not work on this path, or the harness does not observe it. I did not isolate which, and I'd rather say so than guess.

Two things make it hard to tell apart, and both are worth knowing:

  1. recordDueMetrics (finalization-recovery.ts:377-389) catches everything and logs a warn. That is correct for production — I approved it in round 3 precisely so a metrics failure cannot break a batch — but this test injects { info: () => {}, warn: () => {} }, so if recordDueMetrics is throwing, the reason is silently discarded. Asserting on a captured warn (or using a spy logger) would make the next failure self-diagnosing.
  2. store.health() is mocked with mockResolvedValueOnce, so it answers exactly one call. If anything else in the path consumes it first, the real health() runs instead and the mocked dueEntries: 7 never reaches the gauge.

This is not Windows-only. The EBUSY is, but the underlying assertion is platform-independent — on Linux the rm of an open file succeeds and the same AssertionError propagates. CI should be red too.

Fix: move await store?.close() into the finally (matching the sibling pattern) so the real error is never masked again, then diagnose the undefined datapoint.

The rest of the delta is good

697c7308a is the right hardening, and it closes a residual I raised in round 1. I had noted that processDueBatch had no per-entry guard, so a throwing replayDueEntry would abort the whole batch — and since ordering is stable oldest-first, a poison row at the head would block everything behind it. At the time I verified replayDueEntry's own catch was comprehensive, so it was unreachable in practice; this makes it defence-in-depth:

try { outcome = await this.replayDueEntry(entry); }
catch (error) {
  this.log.warn(`… replay failed for ${entry.ual}: ${reason}`);
  await this.recordDeferred(entry, `background replay failed: ${reason}`,
    deferredRetryDelayMs(entry.attemptCount));
  outcome = 'retry-pending';
}

Critically it records a deferred attempt with backoff rather than just swallowing. That's the part that matters: without it the entry would stay due at next_attempt_at = NULL, selected would stay at the batch size, and runBatch's full-batch 0 ms reschedule would spin the worker against an already-failing store. The outcome is still counted in finalizationRecoveryAttemptsTotal, so the metric doesn't silently lose those attempts either.

One residual, and it's acceptable: the recordDeferred inside this new catch can itself throw if the store is the thing failing, which re-aborts the batch. Worst case is then a warn plus a 5 s reschedule — strictly better than before, and not worth further nesting.

9622adc28 adds three tests — batch-continues-after-one-entry-throws, the due-metrics case above, and autonomous SETTLED publisher-upgrade replay after restart. The first and third pass and pin real behaviour.

Still open from earlier rounds

oldestDueAgeMs is still MIN(created_at) (finalization-recovery-sqlite-store.ts:594), so a row being retried correctly ages identically to a stuck one.

Merge readiness

Blocked on the red test, which is a small fix plus a diagnosis. Everything else in these two commits is sound, and the batch isolation is a genuine improvement over what I reviewed in round 3.

Method: reviewed directly. I ran the suite, applied a local teardown fix to unmask the real assertion, and restored it — restore proven by git diff returning 0 lines and a clean git status. The batch-isolation change was read against the round-3 code it replaces.

@Jurij89

Jurij89 commented Jul 31, 2026

Copy link
Copy Markdown
Contributor

Round 5 — 6d20e82d1

The teardown fix is right and the hardening around it is better than what I suggested — but the test is still red at the same assertion, and the new assertions have usefully narrowed why.

Fixed

await store?.close().catch(() => {}) is now in the finally, matching the eight sibling tests, so the real error is no longer replaced by EBUSY. Three further improvements worth noting, all of which I'd have missed:

  • mockResolvedValueOncemockResolvedValue on health — my second hypothesis was right, one-shot wasn't enough for the number of calls on this path.
  • metrics.disable() before setGlobalMeterProvider, plus expect(...).toBe(true). This is the one I'd underweighted: OTel returns false and silently ignores the registration if a global provider is already set, so leaked global state from an earlier test would make the exporter see nothing. Asserting the registration is the right guard.
  • const [point] → iterating all metric.dataPoints, so a gauge whose point isn't at index 0 is still found.

Still red — and the new assertions tell us where it isn't

FAIL … > emits due metrics even when the due-inbox read fails
AssertionError: expected undefined to be 7
 ❯ test/finalization-recovery.test.ts:329
   expect(datapoints.get('dkg.finalization_recovery.due_entries')).toBe(7)

The two new warn assertions sit before that line and both pass, which rules out a lot:

  • warn was called with due-inbox read failed → the listDue-throws path really was taken.
  • warn was not called with metrics snapshot failedrecordDueMetrics ran to completion without throwing.

So recordDueMetrics executed, store.health() returned the mocked dueEntries: 7, and the record call was reached — yet no datapoint is exported. That moves the problem off the recovery logic (my round-2 "gauges freeze on early returns" fix is doing its job here) and onto the metric plumbing.

The discriminator I'd check first: recordDueMetrics uses optional chaining —

metrics.finalizationRecoveryDueEntries?.record(health.dueEntries);

If getMetrics().finalizationRecoveryDueEntries is undefined after rebuildMetrics(), that line silently no-ops, which is indistinguishable at the assertion from "recorded but not exported". One line separates the two worlds:

expect(getMetrics().finalizationRecoveryDueEntries).toBeDefined();

placed just before processDueBatch. If it fails, the gauge isn't being built against the test's meter and the fix is in rebuildMetrics()/telemetry-api.ts. If it passes, the record genuinely happens and the issue is export-side (aggregation/temporality for a synchronous Gauge).

For what it's worth I ruled out the obvious version explanation: @opentelemetry/api ^1.9.1 and @opentelemetry/sdk-metrics ^2.8.0 both support synchronous createGauge (added in api 1.9.0), so this isn't an unsupported-API no-op.

Suites at this head: finalization-recovery 24 passed / 1 failed, finalization-recovery-worker and finalization-recovery-sqlite-store green — 50 passed / 1 failed / 1 skipped overall.

Merge readiness

Unchanged from round 4 — blocked only on this one test. Everything else in the PR has held up across four rounds, and the failure is now well-localised rather than masked.

}
const store = this.getStore();
if (!store) return 'none';
const entry = await store.get(snapshot.key);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 Bug: New store methods break old recoveryStore implementations

What's wrong
The PR extends the recovery-store contract and then calls the new method unconditionally. Existing callers that inject a custom recovery store compiled or written against the previous contract will either fail TypeScript compatibility or crash at runtime in reconciliation/background replay paths.

Example
A JS integration that provided the previous recovery store shape can still pass an object at runtime. handleChainReconciledKC() now enters replayMatching(), reaches replayEntry(), and throws TypeError: store.get is not a function, aborting reconciliation instead of preserving the prior store behavior.

Suggested direction
Keep get/listDue optional for externally supplied stores or gate autonomous replay on a store capability check, so older stores degrade to the previous reconciliation-only behavior instead of throwing.

Confidence note
This matters if callers can supply their own FinalizationRecoveryStore through FinalizationHandlerOptions.recoveryStore or deep imports from dist/*; internal SQLite usage is updated.

For Agents
Look at FinalizationRecoveryStore, FinalizationHandlerOptions.recoveryStore, FinalizationRecovery.replayEntry, and worker startup. Preserve old custom-store behavior by feature-detecting the new autonomous-retry methods, splitting the autonomous worker interface from the base store interface, or explicitly gating this behind a documented major-version contract change. Add a compatibility test with an old-shape recovery store proving chain reconciliation does not throw.

}

describe('graph-scoped finalization recovery admission', () => {
it('autonomously settles a durable RECEIVED entry without a chain-cursor replay', async () => {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Issue: Decompose the finalization recovery test file before letting it sprawl further

What's wrong
This PR appears to push an already broad test module well past the 1k-line maintainability boundary by adding a large autonomous-worker section directly into it. The result is harder to navigate and makes unrelated recovery concepts compete in one file.

Example
The first nine tests in this describe block all exercise the autonomous due-worker path, but they live in the same 1.5k-line file as parsing, admission, settled replacement, reorg, and unsupported-capability tests. A future change to worker backoff now requires scanning a broad mixed-purpose suite.

Suggested direction
Move the worker-specific tests into a dedicated file and keep this file focused on admission/replay semantics. Shared setup helpers would remove much of the repeated store/recovery construction without changing behavior.

For Agents
Split the autonomous due-worker/backoff/metrics/restart/concurrency cases from packages/agent/test/finalization-recovery.test.ts into a focused test file such as finalization-recovery-autonomous.test.ts, and extract shared store/recovery factory helpers if needed. Preserve the same assertions and vitest unit config inclusion.

@lupuszr
lupuszr changed the base branch from main to testnet-canary July 31, 2026 09:39
@Jurij89
Jurij89 merged commit 9f8d52c into testnet-canary Jul 31, 2026
63 checks passed
@Jurij89

Jurij89 commented Jul 31, 2026

Copy link
Copy Markdown
Contributor

Correction — my round-4/round-5 "red test" finding was wrong

I reported finalization-recovery.test.ts as failing and called the PR blocked. That was my environment, not your code. CI was right and I was wrong. Apologies for the noise.

Root cause. In my review worktree, packages/core/dist/telemetry-api.js was built at 07-30 19:15 while packages/core/src/telemetry-api.ts changed at 07-31 00:10. The stale dist contained zero occurrences of finalizationRecoveryDueEntries:

$ grep -c finalizationRecoveryDueEntries packages/core/dist/telemetry-api.js   # 0
$ grep -c finalizationRecoveryDueEntries packages/core/src/telemetry-api.ts    # 2

The agent test resolves getMetrics from the built @origintrail-official/dkg-core, so the gauge simply did not exist at runtime — and because recordDueMetrics uses optional chaining:

metrics.finalizationRecoveryDueEntries?.record(health.dueEntries);

it silently no-opped. That produces exactly the symptom I chased: no exported datapoint, expected undefined to be 7, with no thrown error and therefore no metrics snapshot failed warn. Every observation I reported was real; my conclusion from them was not.

Proof:

$ pnpm --filter @origintrail-official/dkg-core build
$ pnpm exec vitest run --config vitest.unit.config.ts test/finalization-recovery.test.ts
 Test Files  1 passed (1)
      Tests  25 passed (25)

What this retracts:

  • Round 4's HIGH ("the suite is currently red") — withdrawn.
  • Round 4's "This is not Windows-only… CI should be red too" — wrong. CI builds the dependency closure first, which is precisely why it was green.
  • Round 5's framing that the failure was "well-localised" in the metric plumbing — there was nothing to localise.

What still stands, and was worth doing: the teardown fix in 6d20e82d1 is genuinely correct — store.close() belonged in the finally, and without it a real assertion failure would still be masked by EBUSY on Windows for whoever hits one next. The metrics.disable() + registration assertion, the mockResolvedValue change, and iterating all dataPoints are all real hardening. And the round-4 review of 697c7308a (per-entry batch isolation that records a deferred attempt with backoff) is unaffected — that remains a good change.

Also unaffected and still open as a follow-up: oldestDueAgeMs is MIN(created_at) (finalization-recovery-sqlite-store.ts:594), so a row being retried correctly ages identically to a stuck one.

My mistake, concretely: a reviewer running suites out of a worktree must build the dependency closure before trusting a failure. I had already seen this exact class today — a stale packages/storage/dist produced an identical false failure on #2003 — and I failed to apply it to my own run before escalating to HIGH. I should have rebuilt and re-run before posting, and I'll be doing that from now on.

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.

3 participants