Skip to content

fix: let a no-op republish complete its raft queue entry - #1716

Merged
bplatz merged 7 commits into
mainfrom
fix/raft-noop-republish
Aug 28, 2026
Merged

fix: let a no-op republish complete its raft queue entry#1716
bplatz merged 7 commits into
mainfrom
fix/raft-noop-republish

Conversation

@bplatz

@bplatz bplatz commented Aug 26, 2026

Copy link
Copy Markdown
Contributor

Problem

Every zero-change write routed through the raft committer hung until the waiter timed out with submission stranded by leader transition.

The commit worker's no-change arm completes a zero-change transaction by republishing the branch's current head unchanged — same t, same commit id. apply_head's monotonicity guard (which exists to refuse stale writers) rejected exactly that shape (commit_t <= current_t) and pushed the queue entry back for retry. Re-staging a no-op can only reproduce the same unchanged head, so the submission looped re-stage → reject forever.

Fix

Equal t and equal commit id is now recognized as the designed no-op completion:

  • the queue entry is consumed and the idempotency record cached
  • the ref is deliberately left untouched, so last_advanced_* stamps keep pointing at the last real advance

A same-t publish with a different commit id is still a stale writer and is still refused, so the guard's safety property is unchanged.

Testing

  • New regression test apply_head_completes_a_no_op_republish_of_the_current_head pins the completion behavior (entry consumed, ref untouched, stale same-t/different-id still rejected)
  • cargo test -p fluree-db-consensus

The commit worker's no-change arm completes a zero-change transaction
by republishing the branch's current head unchanged — and apply_head's
monotonicity guard rejected exactly that (commit_t <= current_t),
pushing the entry back. Re-staging a no-op can only reproduce the same
unchanged head, so the submission looped re-stage -> reject until the
waiter timed out: every zero-change write through the raft committer
stranded with 'submission stranded by leader transition'.

Equal t AND equal commit id is now recognized as the designed no-op
completion: the entry is consumed, the idempotency record is cached,
and the ref is left untouched (the advance stamp keeps pointing at the
real advance). A same-t publish with a DIFFERENT commit id is still a
stale writer and still refused.
bplatz added 5 commits August 27, 2026 06:30
…e relay

A follower's client_write refuses with ForwardToLeader, which pins every
proposer to whichever node leads — a load balancer spreading requests
across a group's nodes has no way to land writes from the others. The
network router (peer-trusted, beside the raft RPCs) gains /propose: the
leader decodes a JSON command, client_writes it locally, and answers the
JSON application response. forward::propose_via_leader is the caller
half — local client_write when leading, relay to the membership-recorded
leader raft_addr otherwise, one retry with a fresh lookup when
leadership moves mid-relay. Same SSRF posture as the request-forwarding
middleware: no-redirect client, is_valid_leader_url on the
membership-supplied address.

The relay wire is JSON in both directions, deliberately unlike the raft
RPCs' postcard: commands are constrained to postcard-safe shapes by the
log, but application RESPONSES never ride the log and may carry
serde_json::Value state postcard cannot decode.

Proven on the three-node harness: a follower's direct client_write
refuses, its relayed propose applies, and all three replicas converge.
BackgroundIndexerWorker::new returns (worker, IndexerHandle) and the
handle owns the worker's ShutdownTrigger. The raft leader watcher's
task closure bound it to _handle, dropping it at the end of the
closure — which fires the shutdown oneshot, so worker.run() exits on
its first select, before its first log line. A raft cluster therefore
runs with NO indexer at all: nothing ever indexes, every read walks
the commit chain from genesis, and read latency degrades unboundedly
as commits accumulate, with no error anywhere.

Move the handle into the worker's task so they live and die together;
the leader watcher's abort on leadership loss still releases both.
Found (and fixed identically) in fluree-solo's embedded raft host,
where the symptom was 3.4s-and-growing unindexed upsert scans that
took debug-log gap analysis to trace back to a '_' binding.
A deployment that lived in the single-node file posture had no way
into raft mode: the replicated nameservice never reads ns@v2, so the
first raft boot came up believing no ledgers exist while all their
data sat on disk — the posture was reachable only with a fresh store.

RaftBootstrapConfig grows adopt_file_registry (a store root; default
None). The node that initializes the cluster calls
RaftIntegration::adopt_file_registry() once a leader is known, and
every non-retracted ns@v2 record replays into the machine as ordinary
proposals: ledger init, commit head, index head, and the config and
status values verbatim. Tombstones are skipped — they carry no live
state.

Once-only via a marker under the raft storage root, written after a
successful replay. Crash-safe by idempotence rather than atomicity:
a crash before the marker re-runs the replay, and every step
tolerates its own prior success (AlreadyExists on init, a head the
machine already holds at least as new, read-then-CAS for the index
ref). Genuine divergence — the machine holding DIFFERENT state, which
the marker should have made impossible — refuses loudly rather than
guessing which side is authoritative.

Joining nodes never adopt; they receive the registry by replication.
Embedders should guard their non-raft boot path after adoption — the
file registry beside the store goes stale on every raft write (the
fluree-solo host stamps the store and refuses a later file-posture
boot; this server can grow the same guard when its config wires the
option).

Integration tests: full replay fidelity (heads, tombstone skipped),
marker once-only, and convergence when re-run over a partially
adopted machine.
The raft nameservice deliberately keeps serving retracted records so
admin tooling can read the flag — but LedgerState::load and
Fluree::ledger_exists consumed lookup() without checking it, so on a
tombstoning backend a DROPPED ledger kept loading and serving queries:
the cache eviction on LedgerRetracted landed, and the very next query
re-loaded the ledger from its still-referenced heads. Backends that
hide the record outright (file, DynamoDB, memory) never showed this,
which is how it survived — the divergence upstream #1670 describes.

Filter retracted at both consumers: the loader treats it as not-found
(matching the documented peer semantics — 'retracted reads identically
to not-found for the query path'), and ledger_exists answers false.
Demonstrated live on fluree-solo's raft-mode standalone: drop reported
'dropped', eviction logged, and the ledger answered queries
indefinitely; with this, the drop-then-query probe refuses.

@aaj3f aaj3f left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Approving @bplatz but recommending some changes, even if narrowly, and only against the file-registry adoption commit. The titular fix is correct, and I'd approve fa0a541b4 on its own without hesitation.

The notes I'd want to focus on are largely from ea1d6972e. The config and status replay arms at integration.rs:490 and :499 discard read errors from get_config/get_status, and the function then writes its own once-only marker — so an unreadable record silently loses that ledger's config with no error, no warning, an info log claiming success, and no second chance for the operator. It's the only place in that function that doesn't follow the "refuse loudly rather than guess" rule you set for it, and the two arms have no test coverage at all despite the test module's doc claiming they do. Both are small fixes and the design around them is good — the idempotent-not-atomic replay, tolerating prior success on every step, and the partial-retry test are all well judged.

The optional items below the blocking ones are genuinely optional; the propose-relay retry note is about an API with no production caller yet, and I'd not hold anything for it.

db adherence checklist

Axis Assessment
Performance (speed-first) Positive. One ContentId equality added per ApplyHead — once per committed write, inside a function already doing a map lookup and a VecDeque::pop_front. Removes an unbounded re-stage → reject → refresh loop that burned a staging pass plus a raft round-trip per iteration until waiter timeout on every zero-change write. Separately, b8bb981d3 recovers a raft cluster from running entirely unindexed, which is a large latent read-latency win. No hot-path query code touched.
SPARQL ↔ JSON-LD IR parity Not applicable — no query, parse, or lowering code in the diff.
Shared-abstraction adherence Good. The fix stays inside the existing guard rather than adding a parallel path; drop_queue_if_empty, the idempotency insert, and the HeadApplied response all run unchanged. The relay reuses is_valid_leader_url and the no-redirect client posture from the existing forwarding middleware instead of inventing a second SSRF story. adopt_file_registry replays through the ordinary NameServiceLookup/RefPublisher/ConfigPublisher traits rather than reaching into the state machine.
Tests / CI CI ran and is green on all six real jobs (fmt, clippy, test 14m22s, testsuite-sparql, bench-paths, plan); the eight "skipping" checks are release-publishing jobs. The test job is --workspace --all-features, so the non-default raft-gated tests genuinely execute. I re-ran the targeted suites and mutation-checked the new test: reverting the guard exception turns apply_head_completes_a_no_op_republish_of_the_current_head red in 0.026s (fails fast, doesn't hang). Gap: the config/status adoption arms are untested — see the second blocking comment.

Packaging note. The title and body describe one of four changes. The other three are a new peer-trusted HTTP write endpoint with its own SSRF surface, an indexer lifetime fix, and a 260-line one-shot migration feature with a marker file. The indexer fix in particular deserves better billing than it's getting — a raft cluster running with no background indexer at all, every read walking the commit chain from genesis, is a bigger operational finding than the no-op strand this PR is named for. Splitting ea1d6972e out would also let the adoption feature get reviewed as the migration it is.

}
}
}
if let Ok(Some(config)) = file_ns.get_config(ledger_id).await {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

The config and status replay arms discard a read error, and then the marker gets written anyway — so a one-shot migration can drop a ledger's config permanently and tell nobody.

if let Ok(Some(config)) = file_ns.get_config(ledger_id).await has no Err arm. FileNameService::get_config returns Err on a split_ledger_id failure and on any read_json_from_address I/O or JSON-parse failure (fluree-db-nameservice/src/file.rs:1598-1605), so a truncated or unreadable ns@v2 record surfaces as Err and lands in the discarded arm. The loop then continues, the per-record tracing::info!("file registry record adopted into the replicated nameservice") still fires, adopted still increments, and finish(adopted) writes <raft storage root>/file-registry-adopted. From then on the adoption.marker.is_file() early return at the top of the function makes every later call Ok(0).

That is unrecoverable from the operator's side by design: adoption is deliberately once-only, and the only way back is deleting the marker by hand — which they have no signal to do, because nothing was returned, nothing was warned, and the info log says the record adopted fine.

This is also the one place in the function that departs from the principle the commit message sets out for itself: "Genuine divergence … refuses loudly rather than guessing which side is authoritative." The two head arms honour that with FileRegistryAdoptionError::Diverged. These two swallow.

Failing scenario: transition boot on a store where one ledger's ns@v2 JSON was truncated by an earlier unclean shutdown. all_records() still yields the record, so existence and both heads adopt correctly; get_config errors; the ledger arrives in raft with its config silently absent, the marker is written, and the operator's first symptom is a ledger behaving with default config in production.

The fix is small: give both arms explicit Err handling and route it through the error type the publisher-side arms immediately below already use — FileRegistryAdoptionError::replay(ledger_id, "config", e). If you'd rather not fail the whole adoption over one unreadable optional record, then at minimum warn! it and count it, and have finish() refuse to write the marker when the count is non-zero, so a re-run is still possible.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Addressed in 403812c.

Your finding was the visible half. Writing the test you asked for in the next comment turned up a second fault in the same two arms: they pushed with expected: None, and apply_versioned_push (state_machine.rs:2125) reads an absent value as VersionedValue::absent()ConfigValue::unborn / StatusValue::initial — then requires expected.as_ref() == Some(&current). None can never match, so every push returned Conflict and the Ok(_) => {} swallowed that too. Neither config nor status was ever carried, on any path. The // A conflict is a retried replay's own prior write comment was wrong: it was the first attempt failing, every time.

Both arms are now read-then-CAS in the shape of the index-head arm above them — read failure aborts through Replay, a real conflict refuses through Diverged, and a watermark at or above the registry's absorbs a retried replay's own write.

@@ -0,0 +1,198 @@
//! The transition boot: a store that lived in the single-node FILE
//! posture moves into raft, and `adopt_file_registry` carries every
//! registry record — existence, both heads, config, status — into the

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

The test module's own doc claims config and status coverage that isn't there — and those are exactly the two arms with the swallowed error above.

Line 3 reads "carries every registry record — existence, both heads, config, status — into the replicated nameservice". seed_file_registry (lines 37-70) calls init, fast_forward_commit, and compare_and_set_ref only; it never calls push_config or push_status. Neither test reads config or status back. I checked: grep -n "config\|status" over the file returns only this doc line and the name no_registry_and_no_config_both_answer_zero, where "config" means the bootstrap config, not a ledger config.

So both replay arms are entirely unexercised. The heads path is well covered — replay fidelity, tombstone skipping, marker once-only, and convergence over a partially adopted machine are all pinned, and the partial-retry case in particular is a nice piece of test design. Config and status just aren't in it, while the doc says they are.

Seeding a config and a status in seed_file_registry and asserting both come back through integration.nameservice() would cover the arms and make the doc true. It would also give the fix above something to fail against.


Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Addressed in 403812c. Mutation-checked both ways: restoring expected: None fails with Diverged { machine holds v: 0, registry carries v=4 }, and restoring the original arm including the swallow fails the readback at left: 0, right: 4.

// `commit_t == current_t` with `commit_id` equal to the stored
// head. That is not a stale writer — it is the designed way an
// entry that advances nothing leaves the queue. Rejecting it
// (as this guard once did) pushed the entry back and stranded

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

fluree-db-consensus/src/raft/commit_worker.rs:752 — The process_revert docstring still asserts the mechanism this PR just disproved — it says ApplyHead against the same head "surfaces via QueueDesync::WrongFront only if another transactor jumped ahead, which is exactly the race the queue already serializes against." Both halves turned out to be wrong: it surfaced as HeadNotMonotonic, not WrongFront, and it fired unconditionally rather than only under a jumped-ahead transactor. That belief is arguably why this sat latent since June. The code is fixed now, but the comment sits directly above one of the four arms that depends on the new behaviour, so the next reader gets the old model. Minor and non-blocking — but if you agree it's right, I'd rather see it folded in now than lost in the backlog.

Commenting here because fluree-db-consensus/src/raft/commit_worker.rs is not in this diff.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Addressed in 403812c.

};
match relay_propose::<C>(&node.raft_addr, &cmd).await {
Ok(data) => return Ok(data),
Err(e @ ProposeError::Relay(_)) => {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

The relay's retry arm can double-apply. relay_propose maps a resp.bytes() failure to ProposeError::Relay(...) at line 1024, and this arm retries the whole client_write on any Relay error. By the time bytes() fails the leader has already answered a status line, so it has already applied the command — the retry lands it twice. The 503 arm is genuinely safe (the leader refused with ForwardToLeader, nothing applied), and the send() failure is honestly ambiguous, but the response-read failure is unambiguously post-apply. Since there's no production caller yet — only multi_node_group.rs:289 — this is a design note rather than a live defect, and I'd normally leave it. I'm raising it because the commit message frames this as the answer for a load balancer spreading writes across a group, so it will get wired to something, and at that point "at least once" becomes a property callers need told about. Either narrow the retry to the 503 and send() cases, or say plainly in the propose_via_leader doc that delivery is at-least-once and commands should be idempotent.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Not addressed — taking your read that this shouldn't hold the branch. The analysis is right: a bytes() failure is unambiguously post-apply and the retry double-applies. Since the choice between narrowing the retry and documenting at-least-once depends on what ends up calling it, I'd rather make it with the caller in hand than guess now.

.map_err(FileRegistryAdoptionError::Marker)?;
Ok(adopted)
};
if !registry.is_dir() {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

if !registry.is_dir() { return finish(0); } writes the marker when the configured root has no ns@v2 subtree. A typo in the operator's adopt_file_registry path therefore burns the one-shot adoption for that raft storage root, silently. Answering Ok(0) without writing the marker in this specific case would leave the door open for the corrected config, and you'd lose nothing — the adoption.is_none() case above already returns Ok(0) without a marker.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Addressed in 403812c.

/// a value at least as new; a config/status conflict).
///
/// Returns how many ledger records were carried.
pub async fn adopt_file_registry(&self) -> Result<usize, FileRegistryAdoptionError> {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Nothing in adopt_file_registry checks that this node is the one that initialized the cluster; the "joining nodes must NOT call this" contract lives only in the doc comment. Given RaftBootstrapConfig is the kind of thing that gets templated across every node in a deployment, a cheap self.raft.current_leader() check (or an explicit is_initializer flag threaded from bootstrap) would make the contract enforceable rather than advisory.


Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Not addressed — leaving as-is for now. Agreed the contract should be enforceable rather than advisory, but current_leader() doesn't actually separate the initializer from a joining node that later won an election, so the honest version is the is_initializer flag threaded from bootstrap. That's a bootstrap-surface change I'd rather not fold into this branch.

The adoption's config and status arms never carried anything. Two
faults compounded:

- The read discarded its error (`if let Ok(Some(..))`), so a record
  whose `ns@v2` JSON was truncated by an unclean shutdown lost its
  config with no error, no warning, and an info log claiming the
  record adopted fine — permanently, since the once-only marker was
  still written afterwards.
- The push passed `expected: None`, which `apply_versioned_push` can
  never match: an absent value reads as the `unborn` / `initial`
  watermark, not as missing. Every push conflicted, and the conflict
  was swallowed by `Ok(_) => {}`.

Both arms now read-then-CAS like the index-head arm above them: a read
failure aborts through `Replay`, a genuine conflict refuses through
`Diverged`, and a watermark already at or above the registry's absorbs
a retried replay's own prior write.

Also: a configured registry root with no `ns@v2` subtree answers
`Ok(0)` without writing the marker, so a mistyped path no longer burns
the one-shot adoption silently, matching the unconfigured case.

`process_revert`'s docstring described the pre-fix `ApplyHead`
behaviour (`WrongFront` only under a jumped-ahead transactor); it now
describes the no-op completion the four arms below it depend on.

Tests seed config and status in the file registry and assert both
watermarks and payloads arrive through the replicated nameservice,
covering arms the module doc already claimed. Mutation-checked: the
old `expected: None` push fails the readback at v=0 vs v=4.
@bplatz
bplatz merged commit fe3c198 into main Aug 28, 2026
13 of 14 checks passed
@bplatz
bplatz deleted the fix/raft-noop-republish branch August 28, 2026 01:35
@aaj3f

aaj3f commented Aug 28, 2026

Copy link
Copy Markdown
Contributor

Heads up @bplatz — this one turned main red, and I think it's just a test that encodes the old semantics rather than anything wrong with the change.

43d758610 makes ledger_exists treat a retracted record as absent. it_ledger_lifecycle::ledger_exists_on_file_storage asserts the opposite:

// A soft-dropped ledger still has a (retracted) record: `exists`
// is "is there a record", not "is it live".
assert!(
    fluree.ledger_exists("x:main").await.unwrap(),
    "exists() reports the record, which a soft drop keeps",
);

fluree-db-api/tests/it_ledger_lifecycle.rs:219. Failing on main since the merge at 01:35 (run 33133302931), and it's inherited by every branch that has merged main since — I hit it on #1701, which touches nothing but the planner.

Your commit message reads like the test is simply now wrong ("exists is a query-path question"), in which case updating the assertion and its comment is the whole fix. I didn't want to push that myself since it's your semantics call and the old assertion was deliberate enough to carry an explanatory comment. Happy to push it if you'd rather — just say which way you want exists documented.

One thing worth flagging while you're in here: this settles a sub-question of #1670, which is still open and carries the wider delete-semantics decision (init-reclaims vs reject, whether an un-retract path exists, branch-scoped hard purge). Your commit body already cites the same divergence. Might be worth a line on #1670 recording that the exists half is now decided, so whoever picks up the rest isn't re-deriving it — and so the two don't drift apart, which is the failure mode that issue is about in the first place.

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.

2 participants