Repository navigation
Conversation
7ed16c1 to
b0f1c8f
Compare
b0f1c8f to
c620167
Compare
There was a problem hiding this comment.
Request changes. The head commit does not compile its own test target. The WAL fix is at the right layer, but it covers one of four places that read record headers.
Rebase first
The branch is 25 commits behind main. It merges cleanly today, but rebase onto current main before the next push. Re-run the checks on the rebased head and post the output from that head.
Claims checked
| Claim | Result |
|---|---|
cargo fmt --all -- --check passes |
Holds. |
| Full library suite passes on this head | Does not hold. cargo nextest run -p nodedb-wal fails to compile at c620167 with E0382 at nodedb-wal/src/reader.rs:595. |
nodedb-types startup tests |
Hold: 3/3 pass. |
Every pass claim in the description needs re-running on the rebased head.
Layer
The startup bounds are at the right layer and match what #354 asks for.
The WAL change is at the right layer: the reader that both recovery and the startup gate share. It needs four changes before it fixes the bug class instead of one instance:
- Fix every reader.
nodedb-wal/src/lazy_reader.rs:153andnodedb-wal/src/mmap_reader/reader.rs:299still turnUnsupportedVersioninto aCorruptionstop.nodedb-wal/src/torn_tail.rs:156(intact_record_lsn) still treats a non-current version as "not a record". After this PR, the sentence the description writes about the old reader is still true of three of them. - Decide the version per segment, not per record.
header.validatechecks the version before the CRC, so the reader declares a format gap from two unverified bytes. The version-0 carve-out patches one case of that. A writer never mixes format versions inside one segment. A mismatch on the segment's first record (after any preamble) is a format gap. A mismatch after an intact current-version record is damage, andtorn_tailmust classify it. - Attach the segment path where the open fails. The pre-open
validate_segments_for_startupcall runs a second full scan of every segment. It exists only because the writer's open returns the version error without a path.recovery::recover(path)andSegmentedWal::openalready know the path. Attach it there, and keep one validation pass. - Name an action that exists. No WAL migration path exists, so "needs a migration" sends the operator looking for a tool that is not there.
Scope
- Split the clippy changes out.
vector_primary.rsandcalvin_reply/stage.rskeep the logic the same but are unrelated to #354. They belong in their own PR. - Split the newest-segment relaxation out. Accepting a record-less newest segment changes startup behavior, separate from version diagnosis. It needs its own commit and an accurate justification (see the inline note).
What landed correctly
The [tuning.startup] section follows the existing tuning pattern, and the bounds are wired from main.rs to both gates. The [server] stale-key error does what #354 asks for.
| // An empty stream is also what a silent skip or a clean end would | ||
| // return, so the reason has to be asserted for this to prove a stop. | ||
| assert!( | ||
| matches!(reader.stop_reason(), Some(StopReason::Corruption { .. })), |
There was a problem hiding this comment.
Blocker: this does not compile. records(self) moves reader at line 585, and this line borrows it after the move (E0382). Keep the reader and iterate with next_record() in a loop, then read stop_reason(). Re-run the full suite on the rebased head after the fix.
| offset: header_offset, | ||
| }); | ||
| } | ||
| Err(error @ WalError::UnsupportedVersion { .. }) => return Err(error), |
There was a problem hiding this comment.
Blocker: this reader now returns the version error, but lazy_reader.rs:153, mmap_reader/reader.rs:299, and torn_tail.rs:156 still turn the same UnsupportedVersion into corruption or "not a record". Fix all four in this PR.
This arm also fires before the CRC check and at any offset. A mismatch after an intact current-version record in the same segment cannot be a format gap, because the writer never mixes versions in one segment. Return the version error only for the segment's first record (after any preamble). Route a later mismatch through the corruption stop, so torn_tail classifies it. The version-0 arm above then needs no special case.
| }; | ||
| let status_fn = Arc::clone(status_fn); | ||
| let deadline = Instant::now() + DATA_GROUP_RECOVERY_TIMEOUT; | ||
| let deadline = Instant::now() + timeout; |
There was a problem hiding this comment.
Blocker: a configured value overflows Instant and panics the boot. For example, data_group_recovery_timeout_ms = 18446744073709551615 panics here, and admitted_within in auth_lease/status.rs does the same. 0 fails every boot at once. Check both bounds at config load against a stated range, and return a config error that names the key and its range.
| /// resets the clock, so a large replay finishes; only a stuck group fails. | ||
| /// Default: 300_000 (5 minutes). | ||
| #[serde(default = "default_raft_ready_timeout_ms")] | ||
| pub raft_ready_timeout_ms: u64, |
There was a problem hiding this comment.
Nothing checks either bound. 0 and u64::MAX both deserialize, and both break the boot (see the note on data_group_recovery.rs:185). Add a range check at config load that names the key, the value, and the valid range.
| pub async fn await_planning_admitted(state: &SharedState, timeout: Duration) -> crate::Result<()> { | ||
| pub async fn await_planning_admitted( | ||
| state: &SharedState, | ||
| timeout: RaftReadyTimeout, |
There was a problem hiding this comment.
Planning admission is a different wait from the metadata apply stall. It is a hard deadline, and the stall bound resets on progress. Typing it RaftReadyTimeout couples them, and it raises this wait's default from 30 s to 300 s without saying so. startup.rs says different waits get different types. Give this wait its own type and bound, or keep its own constant, and state the default in the description.
| "the message must name the version it found, and it named none: {text}" | ||
| ); | ||
| assert!( | ||
| text.contains("requires 3"), |
There was a problem hiding this comment.
"requires 3" breaks on the next format bump. Build the expected text from nodedb_wal::record::WAL_FORMAT_VERSION.
| // torn tail must not stop a store from opening. | ||
| let info = match recovered { | ||
| Ok(info) => info, | ||
| // A store written by another build used to be reported as |
There was a problem hiding this comment.
Comments describe what the code does now, not what an earlier version did. "used to be reported as corruption" inverts once this merges. The same applies to line 510 ("The production store failed every boot"), reader.rs:548 ("The reader used to file..."), and the comment at reader.rs:233. Rewrite each to state the current rule.
| /// write rather than a format this build cannot read. Opening over it must | ||
| /// succeed, because the writer's open is the first thing a boot does. | ||
| /// | ||
| /// A review of an earlier version of the reader change found this missing: |
There was a problem hiding this comment.
Remove the review history from the comment. State what the test pins: a newest segment with the record magic and a zeroed version opens.
| /// rolled would refuse to open. This uses the real writer to produce the | ||
| /// state, rather than a file crafted by hand. | ||
| #[test] | ||
| fn open_accepts_a_segment_the_writer_rolled_and_never_wrote() { |
There was a problem hiding this comment.
This passes on main too. The writer creates the rolled segment with 0 bytes, and every path already skips a 0-byte segment. It pins no behavior this PR adds. Remove it, or make it cover the state the relaxation exists for.
| /// names the path that is read instead. `deny_unknown_fields` catches the key | ||
| /// anyway, but its message lists every valid field instead of the replacement. | ||
| /// | ||
| /// Neither key was ever a `[server]` field on `main`. They appear at that path |
There was a problem hiding this comment.
Remove the branch history ("Neither key was ever a [server] field on main... never landed"). The guard's purpose is enough: it names the path that is read for a misplaced key.
The metadata stall bound and the data-group recovery bound were constants in the bootstrap code, so an operator with a long WAL tail could not raise them. Both are newtypes now, read from [tuning.startup], which also stops a call site from transposing two adjacent Duration arguments. The shipped defaults change with them: the metadata stall bound moves from 30 s to 300 s and the data-group recovery bound from 60 s to 600 s. Those are the values production runs, and the backlog they cover takes minutes to replay, not seconds. The server config path already rejected such a key through `deny_unknown_fields`, but that message lists every valid field instead of the path that is read. A guard names the replacement path, so a misplaced key says where to put it. Both bounds are range-checked where the config is loaded, and the message names the key, the value and the range. Zero fails every boot on its first poll, and a bound near u64::MAX stops being a bound at all. The wait for the first authorization lease keeps its own constant: it is a hard deadline on one grant, with no progress reset, so it is not the metadata stall bound under another name.
A store written by another build failed every boot with a message calling its segments corrupt. The reader knew the format version and flattened it, along with every other header validation failure, into StopReason::Corruption, so the operator was sent looking for damage that was not there. The version error now travels out of the three readers that decide a record header. It is judged once per segment, at its first record: one writer never mixes formats inside one segment, so a mismatch there is another build's format, and a mismatch anywhere later is damage that torn_tail classifies. A zeroed version is neither, because no build writes format zero, so it stays a torn-write stop. torn_tail is deliberately not one of the three. A version mismatch past a corruption stop is damage there, never a resync point, and its own test pins that. The callers that know the segment path attach it, so the refusal names the file and both versions. Startup validation stays a single pass after the open: the open recovers the newest segment and fails there with the path attached, which is the same refusal without reading every segment twice.
A crash can leave the newest segment non-empty and holding nothing that parses, such as zero-filled pages, and the next boot resumes that same file. Refusing it leaves the store permanently unbootable, because every later boot reads the same bytes and refuses them again. The newest segment is now the one exemption from the empty-segment check. A record-less segment anywhere else stays fatal, since nothing resumes it and replay would silently skip whatever it held. Only segments without a preamble reach that check at all: a segment that opens with one reports its end at the end of the preamble, never at zero. The version check runs first, so a segment written by another build is still refused by name rather than accepted as an empty tail.
c620167 to
4adf4a8
Compare
feat: startup readiness bounds, WAL format naming
Closes #354
Why
A store written by another build failed every boot with a message that called its WAL segments
corrupt. The reader knew the format version and threw it away, which is the root cause. This branch
surfaces that version in every reader that decides a record header, so startup names what it found
and what it reads. It also makes the two startup readiness bounds configurable, with a range check on
each one.
What changed
7034193eefeat(config)raft_ready_timeout_msanddata_group_recovery_timeout_msfrom[tuning.startup]as newtypes, range-checks both at load, and refuses a bound written under[server]while naming the path that is read41bbc0620fix(wal)4adf4a87efix(wal)torn_tailis deliberately not one of the three readers. A version mismatch past a corruption stopis damage there, never a resync point, and its own test pins that.
Behaviour changes, stated plainly
tuning.startup.raft_ready_timeout_msdefaulttuning.startup.data_group_recovery_timeout_msdefaultThe wait for the first authorization lease keeps its own constant. It is a hard deadline on one grant
and does not reset on progress, so it is not the metadata stall bound under another name.
Bounds near
u64::MAXdo not overflow on Linux:Instant::now() + Duration::from_millis(u64::MAX)computes (
tv_sec: 18446744073719120). They are still refused, because a day is the longest windowthat still bounds a boot, and a platform whose
Instantis au64nanosecond counter does overflow.How it was verified
20261009-green-final.log20261009-fullsuite-final.log20261009-mutA-final.log20261009-mutB-final.log20261009-e2e-final.log20261009-preflight-final.logEvery arm ran against
4adf4a87e0b97eddb765415948a99cdc800d31d5, whose tree ise4bff056a296ce0590edb17a9e6abd0e57be71bc.Both mutation arms run
-p nodedb --lib -E 'test(/wal::manager/)', so they falsify the startupvalidation tests and the three reader tests through their caller. They do not falsify the
nodedb-walunit tests added for the readers, which ran only in the focused suites above.Preflight: one violation, and it is
main'sPreflight stops on two
clippy::nonminimal_boolerrors under clippy 1.96, atnodedb/src/control/planner/sql_plan_convert/dml/vector_primary.rs:171andnodedb/src/data/executor/handlers/control/calvin_reply/stage.rs:95. Both files are byte-identicalto
mainon this branch:The earlier revision of this branch carried the two rewrites, and preflight passed there. The review
asked for those fixes to be split into their own pull request, so this branch no longer carries them.
That leaves the gate red on code this branch does not touch. The two ways forward are a one-shot
PREFLIGHT_SKIP=1with this paragraph as the reason, or a separate pull request that fixes the twosites on
main.Review
Review 2 verdict: PASS (0 blockers) on
4adf4a87e0b9. The reviewer re-read the three readers,the startup validation pass, both bound checks, the
[server]path guard, and the exemption commit,and reproduced the byte-identity of the two clippy sites against
main.Scope
Two units share this pull request, one commit each: the startup bounds under
[tuning.startup], andthe WAL format diagnosis with the record-less newest segment as its own commit. The clippy fixes the
review asked to split out are not here.