Skip to content

PR1: Ingest correctness — durable per-record cursor + retry-dedup + run-id tiebreaker - #85

Closed
Diego Colombo (colombod) wants to merge 2 commits into
storage-api-on-78from
feat/ingest-durable-cursor
Closed

PR1: Ingest correctness — durable per-record cursor + retry-dedup + run-id tiebreaker#85
Diego Colombo (colombod) wants to merge 2 commits into
storage-api-on-78from
feat/ingest-durable-cursor

Conversation

@colombod

Copy link
Copy Markdown
Collaborator

First of the progressive split of #79 into smaller, independently-reviewable PRs. Stacks on #81 (storage-protocol isolation) → #78 (durable-ingestion hardening + Neo4j driver-leak fix).

Delivers

  • Durable per-record cursor persisted atomically with the queue offset, so a rebuilt worker resumes its data-layer counters instead of manufacturing duplicate nodes.
  • Retry-dedup on both the in-place-retry and dead-letter/isolation redrive paths — a redrive restores the pre-batch cursor and never double-counts.
  • Run-id tiebreaker: two same-timestamp orchestrator runs get deterministic, distinct node ids that survive a worker rebuild.

Correctness / concurrency

  • Every .offset writer (commit, compaction, dead-record reconcile, reclaim, reset) is serialised behind one counted, mutex-fenced per-session lock. Measured offset-clobber under concurrency: 83% → 0%.
  • Corrupt/legacy .offset and dead-letter files are read without conversion and degrade (logged + quarantined) — never a silent loss or crash-loop.

Surface

  • QueueManager Protocol delta is exactly the commit() cursor argument + read_cursor (backend-neutral; no filesystem detail leaked).
  • Breaking Protocol change → version 6.7.2 → 7.0.0 + CHANGELOG.

Scope

  • No working_dir/session-node changes — that is the next split PR (PR2).

Verification

  • 2142 non-Neo4j tests pass (0 fail); 12 Neo4j durable-queue tests pass on a live container; boot-and-serve smoke green (/version → 7.0.0, sample ingest written).

…reaker

Persist a cross-handler cursor atomically with the committed offset so a
rebuilt drain worker resumes its data-layer counters exactly where it left
off, instead of resetting them and manufacturing duplicate nodes on replay.
Retry-dedup and the run-id tiebreaker on parallel handlers keep a re-delivered
batch idempotent.

The cursor is only durable if EVERY writer of the offset record preserves it,
not just commit(). The idle-compaction rebase/restore and the dead-letter
reconcile route through the same atomic offset-record writer, and the boot
RESET_OFFSET reclaim now preserves the committed cursor instead of wiping it
on a re-drainable log. The dead-letter/exhausted-batch redrive rolls the
counters back to their pre-batch snapshot before isolating records, and that
snapshot is deep-copied so a later attempt's in-place mutation cannot corrupt
the baseline the next rollback restores. commit() writes under the same
per-key file lock as compaction, closing a race where a concurrent commit
during compaction could be silently erased.

Cross-handler run-id resolution is shared by the orchestrator-run, iteration,
and content-block handlers, so a partial cursor after a rebuild re-derives the
run's real id (with its sequence) instead of dropping the HAS_PART edge or
orphaning ContentBlock nodes. A genuinely corrupt/unparseable offset
quarantines the one affected drain worker (a transient I/O error still
supervises and respawns as before). A crash-then-respawn mid-isolation does
not re-dead-letter an already-dead leading record: the filesystem backend
marks a session dead-unreconciled in-memory on each dead_letter, and the next
read_batch reconciles past the leading dead lines exactly once. This is an
internal backend detail, kept OFF the QueueManager Protocol -- a broker
backend has no leading-dead-line window to reconcile. Process restart is still
covered by the boot-time global recovery_reconcile_dead(). The exhausted-batch
buffer discard also invalidates the seen-session cache, so the isolated
re-dispatch re-issues the Session node instead of early-returning. Cursor
restore is all-or-nothing: both replacement dataclasses are built before
either live field is reassigned.

Breaking Protocol change: QueueManager.commit() takes a REQUIRED cursor
argument (offset and cursor are written in one atomic record), and read_cursor
joins the backend-neutral surface. These are the genuine general primitives;
log-structured-queue details (dead-line reconciliation, is_fully_drained) stay
off the Protocol until a real cross-backend caller needs them. Every in-repo
caller is updated; the tolerant reader stays forward- and backward-compatible,
so a legacy bare-integer .offset still reads correctly.

Version 6.8.0.

🤖 Generated with [Amplifier](https://github.com/microsoft/amplifier)

Co-Authored-By: Amplifier <240397093+microsoft-amplifier@users.noreply.github.com>
A session's committed offset was written from several paths, but the lock
that protects it was the CALLER's job. commit() and compact_committed_prefix()
took the per-key file_lock; the dead-letter reconcile reached through
read_batch() took none, and read_batch itself ran wholly unguarded. A commit
racing that reconcile was silently rolled back to the offset the reconcile had
read before it -- 248 of 300 concurrent runs, with 2 more leaving a
half-written record. The staging file compounded it: every writer staged
through the same <session>.offset.tmp, so one writer could rename a file
another was still filling.

Move the invariant into the writer. _write_offset_record now takes the key's
file_lock itself and stages through a per-write temp name, so no caller can be
the one that forgets, and no two writers collide on the staging path. The lock
is reentrant (_KeyLock) because compaction and the reconcile legitimately hold
it across a wider atomic sequence; it keeps locked() so the framing guarantees
stay assertable. Guard creation moves behind a mutex: guards are now minted
from worker threads as well as the event loop, and two guards for one key would
mean two locks over the same bytes. read_batch joins the guarded paths -- it
can move the offset, and even its pure read must not observe the .log
mid-swap. This also makes the backgrounded boot reconcile safe against live
drainers without blocking first request on it.

Corrupt-offset quarantine now covers every read in the drain loop, not the
main-loop read alone: the idle dry-exit recheck and the session:end tail drain
escaped to the supervisor and crash-looped the session. All three route
through one SessionRegistry._read_batch.

Finalizing a session retires its dead letters out of the session's own name.
A session id can be reused, and the previous session's payloads made the new
session's reconcile skip log lines whose bytes merely matched an old dead
payload, committing past events it never processed. Nothing is discarded --
the retired file still expires on its own schedule and read_dead_letters still
reports it under the session.

CHANGELOG corrected to match the code: is_fully_drained and reconcile_dead are
NOT on the Protocol (the commit that introduced them says so explicitly), and
delete_drained drops the cursor by design rather than preserving it.

Version 6.8.0 -> 7.0.0. commit() gained a required argument on a Protocol this
repo documents as a backend extension point, and the CHANGELOG asserts SemVer
adherence; a minor bump for a breaking contract change makes the version number
lie about compatibility.

New guard tests, each red on 17b1756 and green here: the commit/reconcile
race, staging-path uniqueness and its reclaim attribution, the two previously
unguarded quarantine sites, and dead-letter retirement under id reuse.

🤖 Generated with [Amplifier](https://github.com/microsoft/amplifier)

Co-Authored-By: Amplifier <240397093+microsoft-amplifier@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant