Skip to content

feat(config): make startup bounds configurable - #412

Merged
farhan-syah merged 5 commits into
NodeDB-Lab:mainfrom
EnRaiha:drill/issue354
Oct 14, 2026
Merged

farhan-syah merged 5 commits into
NodeDB-Lab:mainfrom
EnRaiha:drill/issue354

Conversation

@EnRaiha

@EnRaiha EnRaiha commented Oct 6, 2026 •

Copy link
Copy Markdown
Contributor

Closes #354

Rebased onto main at 410ecae55.

What changed

Commit What it does
feat(config) Reads raft_ready_timeout_ms and data_group_recovery_timeout_ms from [tuning.startup]. Each boot wait fails only when it makes no progress for its bound. Range-checks both values, refuses an unknown key, and refuses either key under [server] with the path that is read.
fix(wal) Names the WAL format version when startup refuses a store, with the segment path. Judges the format at a segment's first record. A damaged version field in this build's record is corruption, not a format gap.
fix(wal) Accepts a newest segment that holds only what a crash leaves behind: zeros, or a torn first record. Any other content is refused as corruption.

Startup waits

  • bootstrap/stall_clock.rs tracks progress for every boot wait. A measure that rises, or a group that finishes, resets the clock.
  • The metadata wait resets on every applied entry.
  • The data-group wait resets when a group elects a leader, commits or applies more of its log, or finishes.
  • The Calvin scheduler catch-up wait uses the data-group bound. It resets when a scheduler advances its fully-applied watermark or catches up. CaughtUpRegistry carries each scheduler's watermark for this.
  • A long replay that keeps applying entries never fails boot.

WAL format version

  • nodedb-wal/src/record/foreign_version.rs judges a header whose version this build does not read. It recomputes the checksum with this build's version. A match means a flipped bit in this build's record, so the reader stops there as corruption. No match means another build's format.
  • The three segment readers and the restore walkers apply the same rule.
  • torn_tail::holds_only_crash_residue decides the newest-segment exemption in validate_for_startup.

Behaviour changes

Change Before After
Metadata replay bound fixed 30 s, reset on progress tuning.startup.raft_ready_timeout_ms, default 30 s, reset on progress
Data-group replay bound fixed 60 s hard deadline tuning.startup.data_group_recovery_timeout_ms, default 60 s, reset on progress
Calvin catch-up bound fixed 60 s hard deadline same key and default as the data-group bound, reset on progress
Store in another WAL format "the segment appears to be corrupted" names the segment, both versions, and the actions that exist
Newest segment of zeros or a torn first record refused at startup accepted, and the writer resumes it
Restore archive in another WAL format read as empty, records dropped silently refused, naming the segment and both versions

Both keys accept 1 to 86400000 ms.

How to check

  • cargo nextest run --workspace --exclude nodedb-cluster-tests --all-features --retries 0: 21833 of 21834 passed. The failure was startup_failure::nodedb_exits_nonzero_on_corrupted_wal: the newest-segment exemption accepted junk. The crash-residue rule fixes it, and the WAL and startup tests pass, 432 of 432.
  • cargo nextest run -p nodedb-cluster-tests --all-features --retries 0: 504 of 504 passed.
  • cargo clippy --all-targets --all-features -- -D warnings on nodedb, nodedb-wal, nodedb-types, nodedb-test-support: clean.
  • Every scripts/ci/check_* gate passes, including --self-test.

Copilot AI balanced review requested due to automatic review settings October 6, 2026 13:11

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@EnRaiha
EnRaiha marked this pull request as draft October 6, 2026 14:19
@EnRaiha EnRaiha changed the title feat(config): make startup bounds configurable feat(config): make startup bounds configurable Oct 7, 2026
@EnRaiha
EnRaiha marked this pull request as ready for review October 7, 2026 09:29

@farhan-syah farhan-syah left a comment •

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

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:

  1. Fix every reader. nodedb-wal/src/lazy_reader.rs:153 and nodedb-wal/src/mmap_reader/reader.rs:299 still turn UnsupportedVersion into a Corruption stop. 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.
  2. Decide the version per segment, not per record. header.validate checks 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, and torn_tail must classify it.
  3. Attach the segment path where the open fails. The pre-open validate_segments_for_startup call 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) and SegmentedWal::open already know the path. Attach it there, and keep one validation pass.
  4. 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.rs and calvin_reply/stage.rs keep 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.

Comment thread nodedb-wal/src/reader.rs
// 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 { .. })),

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

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.

Comment thread nodedb-wal/src/reader.rs Outdated
offset: header_offset,
});
}
Err(error @ WalError::UnsupportedVersion { .. }) => return Err(error),

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

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;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

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,

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

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,

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

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.

Comment thread nodedb/src/wal/manager/replay.rs Outdated
"the message must name the version it found, and it named none: {text}"
);
assert!(
text.contains("requires 3"),

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

"requires 3" breaks on the next format bump. Build the expected text from nodedb_wal::record::WAL_FORMAT_VERSION.

Comment thread nodedb/src/wal/manager/replay.rs Outdated
// 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

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

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.

Comment thread nodedb-wal/src/segmented.rs Outdated
/// 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:

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Remove the review history from the comment. State what the test pins: a newest segment with the record magic and a zeroed version opens.

Comment thread nodedb-wal/src/segmented.rs Outdated
/// 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() {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

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.

Comment thread nodedb/src/config/server/config.rs Outdated
/// 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

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

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.

@EnRaiha
EnRaiha force-pushed the drill/issue354 branch 3 times, most recently from ddbc5be to b112e60 Compare October 10, 2026 13:17
@EnRaiha
EnRaiha marked this pull request as draft October 10, 2026 16:49
Startup waits for the metadata group, the data groups, and Calvin
catch-up used fixed wall-clock deadlines, so a node that was making
steady progress could still be refused. Each wait now ends only when
it makes no progress for a configured stall bound, and every applied
entry resets the clock.

The bounds live in the new StartupTuning config and are range
checked on load. Setting them under [server] is rejected with a
message that names the [tuning.startup] path. The Calvin scheduler's
caught-up registry exposes a watermark so its wait can see progress.
Test harness call sites and the getting-started guide are updated.
A segment written in a WAL format this build cannot read was reported
as corruption, which sends an operator toward repair instead of
toward the build that wrote the data. Readers now judge the format at
the segment's first record and return SegmentFormatVersion with the
segment path and both versions. Startup turns it into a version-gap
message that lists the actions that exist.

A zeroed or damaged version field is still corruption, not a format
gap, and only a record whose checksum matches its declared version
counts as another format. The restore walkers report the same error.
A crash right after a segment rollover leaves the newest segment with
zeros or a torn first record. The next boot resumes that file, but
startup validation refused it as corrupt, so every later boot failed
too. holds_only_crash_residue recognises that state, and
validate_for_startup exempts the newest segment when it matches. Any
other content, and any older segment without a record, is still
corruption.
@farhan-syah farhan-syah added the run-ci Opt this PR into the full test suite; re-add to force a re-run label Oct 14, 2026
@farhan-syah
farhan-syah marked this pull request as ready for review October 14, 2026 18:13
A cold ASan build uses most of the job time limit. Pin the nightly so
the cache key stays stable across days, and cache the fuzz target
directory per matrix target, keyed on the toolchain and
fuzz/Cargo.lock.
@farhan-syah
farhan-syah merged commit 0f53044 into NodeDB-Lab:main Oct 14, 2026
19 of 20 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

run-ci Opt this PR into the full test suite; re-add to force a re-run

Projects

None yet

Development

Successfully merging this pull request may close these issues.

feat(config): make the startup stall and data-group recovery bounds configurable

3 participants