Skip to content

Find and fix three persistence event-gap leak sites - #99

Closed
pathscale wants to merge 4 commits into
deps/arctic-0.1.11from
feat/persistence-event-ledger
Closed

Find and fix three persistence event-gap leak sites#99
pathscale wants to merge 4 commits into
deps/arctic-0.1.11from
feat/persistence-event-ledger

Conversation

@pathscale

@pathscale pathscale commented Sep 7, 2026

Copy link
Copy Markdown
Owner

Based on deps/arctic-0.1.11, not on master. The seven files here do not overlap the three that branch changes, but this was written and tested against its dependency bump, so it is stacked rather than rebased. Merge that one first.

The event gap

BatchOperation::validate refuses an event stream with a hole in it. A hole is normally transient: the operation carrying the missing id has been produced but not yet batched. A hole that survives the whole deferral budget is not transient, and the stall message could not tell the two cases apart. It reported the range and nothing else:

persistence stalled on primary index event gap: last applied Id(1439),
next available Id(1455) (attempt 9)

Two very different bugs produce that line. Either an id was assigned by the index and its event was dropped instead of queued, so nothing will ever deliver it and the stream is permanently gapped, or the operation was queued and batch collection keeps assembling batches that exclude it.

Distinguishing them needs a record of what was queued. Assignment happens inside the index, which this crate cannot hook, but every event reaching persistence passes through Queue, so an id present in the stream and absent from the ledger was assigned and never queued. That is the leak signature and it is what gap_report reports.

Producers are named by std::panic::Location. That is why push_at takes the caller location, and why the old push and push_many wrappers are deleted rather than left in place: push_at's own documentation warns that calling push loses exactly the location the ledger exists to record, so keeping the wrapper is keeping a trap.

The ledger found three leak sites, all in generated persisted query code, and the second commit fixes all three: the NotFound arm of the update insert path, and the two bare res? sites in gen_process_diffs_remove_on_index and after delete_row_cdc. Each now builds an Acknowledge carrying the orphaned events and applies it before propagating, which is what the rollback arms already did.

The events are moved into the acknowledge rather than cloned, and that is load-bearing. Cloning does not compile: the events type is still an inference variable there, pinned only by the op.extend_secondary_key_events call further down, so method resolution for .clone() has nothing to resolve against. It fails as an E0282 against the worktable! invocation with no inner span.

The write failures are not forcible through the public API, so both regression assertions pin the emitted tokens and check the acknowledge comes before the return or the extend, not merely that one appears. The in-memory generator has the same two NotFound arms and is deliberately untouched: no persistence stream sits behind them.

The formatting job

scripts/ci-local.sh runs cargo fmt --all --check as its first step and CI did not, so the local script was stricter than CI rather than equal to it. Measured before adding the job: the tree already passes, exit 0, no diffs. The job is green as written.

The change log

The log carried beta.18 and beta.19 and nothing before them. Backfilled to 0.3.10 from this repository's own history.

Correction carried in docs/TODO.md

The congee crossbeam-epoch port was already done, in congee-wt 0.4.4, and the TODO was wrong about both the state and the nature of it. It was a design change rather than a rename: per-tree Domain, batched retires, guard provenance checked. The new Guard is !Send and tree-scoped, which constrains future callers.

Verification

cargo fmt --all --check                                 exit 0
cargo test --workspace --all-targets                    1086 passed, 0 failed
cargo clippy --workspace --all-targets -- -D warnings    exit 0

meh added 4 commits September 8, 2026 02:22
`scripts/ci-local.sh` runs `cargo fmt --all --check` as its first step and CI
did not, which made the local script stricter than CI rather than equal to it,
so formatting drift could reach master unnoticed. Same command, same arguments.

Measured before adding it: the tree already passes, exit 0 with no diffs, so
this job is green as written rather than a red first run to clean up.
The log carried beta.18 and beta.19 and nothing before them, so the history a
consumer needs in order to judge an upgrade was only in the commits. Entries
are derived from this repository's own history.
`BatchOperation::validate` refuses an event stream with a hole in it. A hole is
normally transient, and one that survives the whole deferral budget is not, but
the stall message could not tell the two apart: it reported the range and
nothing else. Two very different bugs produce that line. Either an id was
assigned by the index and its event was dropped instead of queued, in which
case nothing will ever deliver it, or the operation was queued and batch
collection keeps assembling batches that exclude it.

Distinguishing them needs a record of what was queued. Assignment happens
inside the index and cannot be hooked from here, but every event reaching
persistence passes through `Queue`, so an id present in the stream and absent
from the ledger was assigned and never queued. That is the leak signature.
Producers are named by `std::panic::Location`, which is why `push_at` takes the
caller location and why the old `push` and `push_many` wrappers are deleted
rather than kept: calling them loses exactly the location the ledger exists to
record.

This observes and does not fix. `docs/TODO.md` records the three leak sites the
instrument points at, all in generated code: the `NotFound` arm of the update
query returns without building an acknowledge while its `AlreadyExists` sibling
builds one, and two `res?;` sites in update and delete propagate before
acknowledging.

The TODO's congee section is corrected while here. The crossbeam-epoch port was
already done in congee-wt 0.4.4, and it was a design change rather than a
rename: per-tree `Domain`, batched retires, guard provenance checked, and a new
`Guard` that is `!Send` and tree-scoped, which constrains future callers.
The ledger from the previous commit points at three sites in generated
persisted query code where an event the index had already assigned an id to
was dropped instead of queued. A dropped event is a hole
`BatchOperation::validate` refuses forever, so the table stalls and the
message names a range rather than a cause.

All three now do what the rollback arms already did: build an `Acknowledge`
carrying the orphaned events and apply it before propagating. The
`IndexError::NotFound` arm of the update insert path acknowledges the events
its sibling `AlreadyExists` arm was already acknowledging, and the two bare
`res?` sites, in `gen_process_diffs_remove_on_index` and after
`delete_row_cdc`, became `if let Err(e) = res` blocks that acknowledge and
then return.

The events are moved into the acknowledge rather than cloned, and that is
load-bearing rather than a preference. Cloning does not compile: the events
type is still an inference variable at that point, pinned only by the
`op.extend_secondary_key_events` call further down, and method resolution for
`.clone()` needs the type known where the call is written. It fails as an
`E0282` reported against the `worktable!` invocation with no inner span, which
is expensive to diagnose. The comment in `docs/TODO.md` records that so nobody
pays for it twice.

The write failures are not forcible through the public API, so the wiring is
pinned on the emitted tokens the way the surrounding tests already do it. Both
assertions check the acknowledge is emitted *before* the return or the extend,
not merely that one appears somewhere in the output.

The in-memory generator has the same two `NotFound` arms and is deliberately
untouched: there is no persistence stream behind them to gap.
@pathscale pathscale changed the title Name the cause of a persistence event gap, not only its symptom Find and fix three persistence event-gap leak sites Sep 7, 2026
@pathscale

Copy link
Copy Markdown
Owner Author

CI does not cover this PR. .github/workflows/rust.yml triggers only on pull_request: branches: [master], and this one is based on deps/arctic-0.1.11, so no checks run against it. That is a gap in its own right: every stacked PR in this repository is unverified by CI until its base merges. I have not changed the trigger, because widening it means every stacked branch runs the full matrix and that is a policy call rather than a fix.

Verified locally instead, on the merge state of this branch:

cargo fmt --all --check                                  exit 0
cargo test --workspace --all-targets                     1086 passed, 0 failed
cargo clippy --workspace --all-targets -- -D warnings     exit 0

The new formatting job in the first commit is included in that and is green.

Note also that the formatting job this PR adds will not run on this PR, for the same reason.

pathscale pushed a commit that referenced this pull request Sep 9, 2026
Collapses what was PR #102 onto the arctic, event-ledger and page-list
work already on this branch, so WorkTable carries one pull request. #97,
#99 and #100 are folded in; #58 is not, being 224 commits behind master
and already conflicted, which is its own job.

`fsx` and every persistence signature go through `nagoya::io`, so the
production path names no runtime. A persisted table can set `page_size`,
which was refused before because the seeks computed offsets from a crate
constant while the generated table threaded the configured one.

Four places where the folded branches disagreed, each resolved by keeping
both rather than either:

* `arctic` and `ps-reclaim` keep #97's raised version floors and gain the
  `default-features = false` the no_std work needs.
* `allocated_bytes` keeps the page list as #100 left it and takes
  `core::mem` from the no_std work; the `.load()` in the older branch
  belonged to a shape #100 replaced.
* `batch.rs` and `task.rs` take the `core`/`alloc` imports, plus the `Arc`
  and `Location` the no_std lists had dropped and the code still uses.
* The s3 test goes back to tokio's extension traits. It talks to a tokio
  `TcpStream` it starts itself, so the sweep that took the storage path
  off tokio should never have touched it.

`event_ledger` is new here and was written against `std`. Its bookkeeping
moves to `core` and `alloc`; only the two parts that genuinely need an
operating system are gated, the backtrace capture and reading
`WT_EVENT_LEDGER`, so without `std` the ledger is simply never enabled.

`futures/std` goes in this crate's own `std` feature rather than on the
dependency line, where a `--no-default-features` build would still have
turned it on.

    cargo test --workspace --all-targets                  933 passed
      ... --all-features                                  935 passed
      ... --features versioned-row-publication            1029 passed
    cargo clippy --workspace --all-targets [--all-features] clean
    cargo check --no-default-features                     clean
@pathscale

Copy link
Copy Markdown
Owner Author

Folded into #102, which now carries this work plus the tokio::fs removal and tunable page stride, rebased linear with no merge commit. WorkTable carries one PR for this chain.

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