Skip to content

kernel-wal: fsyncs fail loudly; poison survives restart (round 2) - #730

Merged
MattJackson merged 17 commits into
predevfrom
lane-task-draft-679-kernel-wal-round2
Oct 10, 2026
Merged

MattJackson merged 17 commits into
predevfrom
lane-task-draft-679-kernel-wal-round2

Conversation

@MattJackson

Copy link
Copy Markdown
Collaborator

kernel-wal: fsyncs fail loudly; poison survives restart (round 2)

…es a restart; a failed roll keeps its batch; a crash mid-commit is a torn tail, never corruption (frame layout 3) (Q128)

Audit findings 2-5 and 7-10 on crates/busbar-kernel-wal (1 and 6 are on #588).

2, 8. durable::sync_holding_dir is the one home of directory durability and
returns its error; only EINVAL or an Unsupported answer (a filesystem that
cannot fsync a directory) is tolerated. durable::write, remove and
create_dir_all propagate it. backend.rs's copy is gone: DirectoryFactory::new
goes through durable::create_dir_all (every created ancestor is fsynced),
segment creation returns the fsync error and removes the file it created (so
the next open does not find it existing and skip the fsync), and a quarantine
whose directory fsync fails is an error, so recovery leaves the damage in
place rather than cutting it. The structure-lint row no longer ledgers
backend.rs for fs::create_dir_all.

3. Design: roll eagerly on poison, plus cut the failed batch's bytes. On a
failed write or sync the segment shortens itself back to the last good commit
and syncs that, so a batch reported lost cannot be read back from the page
cache by a restart. The log then opens the next segment at once, before
reporting the loss; that segment's directory entry (made durable on creation,
and with 2 an error if it is not) is the durable record that the poisoned
segment is finished. A restart appends to the HIGHEST segment always, even
when it is empty and the tail is read from the one below, so it never resumes
in the segment that lost a sync. Chosen over "a boot never appends to a
pre-existing segment": that variant puts every boot's records in a segment of
its own, and boot recovery (quarantine, identity marks) only covers the tail
segment, so damage in a previous boot's records would stop being quarantined.
Chosen over a poison marker frame: the next segment's entry already is the
record, with no format to add. What no design covers: a medium that refuses
the cut as well as the sync (the bytes may read back until the retried batch
is durable), or refuses to create the next segment too (a restart resumes in
the poisoned one); every call on such a disk reports the loss.

4. The failed-roll arm retains the records it was handed, as every other arm
does, so the journal's chain has no hole after a full volume recovers.

5. Frame layout 3: version 2 plus an END MARK (the frame's last byte, never
zero; the payload area is 415 bytes) and an OPENS-COMMIT flag on the first
frame of each group commit. Recovery classifies by what was acknowledged:
damage with a verifying commit-opening frame past it is corruption whatever
its shape (that commit was only written after the damaged one synced). In the
last commit, damage made only of frames a write left unfinished (zeros, or a
prefix of this layout's frame with a zero end mark) is a torn tail and is cut
silently, together with any frame of that commit that landed out of order; a
whole frame (end mark present) that fails a check, or a frame of another
layout, is corruption and is quarantined (THE DESIGN 7). Version 3, not a
redefined 2: the payload area and the trailer changed, so a reader must not
read 2's frames as 3's; neither was released (1.5.5 had no WAL), and versions
1 and 2 are both unknown layouts, quarantined, never cut.

7. Deleted the never-constructed BufferShipper, Wal::memory_buffered,
Journal::memory_buffered and Journal::resuming (THE DESIGN: a memory-store
node keeps no history across a restart). Tests use a test-local keeping
shipper and memory_buffered_to.

9. The Cargo.toml dependency note names ring (sha2 is dev-only); durable.rs
cites the real structure-lint row; write_with carries its own contract,
including what an error from the directory fsync means.

10. The durable fault, decoy and fsync recorders live in src/tests/hooks.rs;
durable.rs keeps only the hook points. record::reseal moved to the test
fixtures. durable_tests mounts from src/tests.

RED tests: a_publish_whose_directory_fsync_fails_is_an_error,
a_removal_whose_directory_fsync_fails_is_an_error,
a_created_directory_whose_parent_fsync_fails_is_an_error,
a_segment_whose_directory_fsync_fails_is_not_handed_out,
a_quarantine_whose_directory_fsync_fails_leaves_the_segment_uncut,
a_deep_data_directory_has_every_directory_it_creates_fsynced,
a_batch_reported_lost_does_not_come_back_at_a_restart,
a_restart_never_appends_to_the_segment_that_lost_a_sync,
a_roll_that_cannot_open_the_next_segment_keeps_the_batch_it_was_handed,
a_failed_roll_leaves_no_hole_in_the_chain,
a_zero_padded_tear_at_every_byte_offset_is_torn_never_corrupt,
a_tear_inside_a_frames_payload_is_a_torn_tail_cut_silently,
a_commit_whose_pages_landed_out_of_order_is_a_torn_tail.

Rewritten: a_tear_inside_a_whole_header_frame_is_quarantined_not_cut (asserted
the old wrong verdict; now a_tear_inside_a_frames_payload_is_a_torn_tail_cut_silently);
a_poisoned_segment_never_takes_another_write (lands a batch first, since the
failed batch is now cut); chain_verification_catches_a_gap_in_the_numbering
(seals the gap by hand instead of through Journal::resuming);
a_restart_without_a_data_dir_loses_nothing_that_was_shipped (the resume half
went with Journal::resuming; now
without_a_data_dir_what_the_store_took_is_the_chain_to_the_nodes_head);
the_default_log_is_the_memory_buffered_one, the_bound_is_pinned and the
journal/no-disk batteries (memory_buffered_to); the version-1 vector
self-check compares the payload up to the end mark; the frame-size pin adds
the trailer.
…ble.rs directory fsync goes to #549's new home

#549 moves durable.rs into busbar-plugin-loader and the Shipper seam into the contract, and takes
busbar-kernel-wal off every loader edge. Resolved per the ARCHITECT ruling (wal-2 lands after #549,
its durable.rs directory fsync goes to #549's new home):

- crates/plugin-loader/src/durable.rs carries wal-2's fail-loud sync_holding_dir (only EINVAL or an
  Unsupported answer is tolerated) and write/remove/create_dir_all propagating it. Its test hooks
  (src/tests/durable_hooks.rs) and tests (src/tests/durable_tests.rs, with the publish, removal,
  created-directory and unsupported-fsync cases from wal-2's dir_fsync.rs) are mounted from it.
- busbar-kernel-wal keeps the same rule for its own segment files and quarantine copies in
  backend.rs (sync_holding_dir, create_dir_durably), since the log takes no loader edge; its
  hooks are trimmed to that one directory fsync and dir_fsync.rs keeps the three backend cases.
- ship.rs and wal.rs: BufferShipper and Wal::memory_buffered stay deleted (wal-2 finding 7); the
  shippers are Shipper<Record> (#549).
… published while unconfirmed

With kernel-wal finding 4 a failed roll keeps the batch it was handed, so the card is owed (held for
the log's retry), not dropped. ARCHITECT 2026-10-07 ruling: keep the batch; the root treats the card
as owed and unconfirmed and never publishes it until it is confirmed. refusal_of already answers a
retained card as Lost (in doubt): the caller publishes only on Ok, and the in-force card is put back
behind the in-doubt one so it never prices.

a_rate_card_the_log_drops_outright_is_never_published asserted the old Dropped answer. It is
rewritten as a_rate_card_the_log_owes_after_a_failed_roll_is_not_published_while_unconfirmed: the
apply is answered Lost, the card in force prices the applied instant and every instant after it,
and the log owes the card and its put-back. The CardRefused::Dropped and refusal_of prose now
states that no arm of the log refuses before retaining.
…28-kernel-wal-2

# Conflicts:
#	crates/busbar-kernel-wal/src/wal.rs
…ow; this branch dropped durable_tests' join("c") path literal (read as busbar-secret-c's id), so the cell measures 0 and the ceiling of 1 was slack (law0 18 -> 19)
…his PR's retained batch

Journal::resuming stays deleted; Journal::memory_resumed positions the chain itself.
Journal::retaining_at_bound and Wal::memory_seeded are kept: a retaining journal never
forgets from the batch a failed roll keeps. H3's tests no longer reach for the deleted
BufferShipper and Journal::memory_buffered; a kernel test pins a failed roll's batch kept
past a retaining journal's bound.
…eding log

The predev merge brought H3's Wal::memory_seeded, which names NullShipper; this
branch's wal.rs no longer imported it.

Supersedes #679.
@MattJackson
MattJackson enabled auto-merge October 10, 2026 06:55
@github-actions

github-actions Bot commented Oct 10, 2026 •

Copy link
Copy Markdown

promote into predev: BOARD @d8ec44638: 0 failing test row(s), 11 DENY row(s)

DENY rows (11)

gate row detail
construction one-pick-site 3 production call site(s) of 'pick_among(' (ceiling 2): crates/busbar-kernel-egress/src/walk.rs:298; crates/busbar-llm/src/engine/exhaustion/fallback.rs:120; crates/busbar-llm/src/engine/pipeline.rs:8
kind-isolation kind-isolation:deps 3 finding(s), 93 shipped edge instance(s) over 30 class(es), 93 declaration(s); 56 '[[dep]]' row(s), 0 question(s): unlisted-dep-edge legacy -> plane busbar-llm -> busbar-plane-llm is a shipped edge w
kind-isolation kind-isolation:test-deps 3 finding(s), 35 test edge instance(s) over 21 class(es), 35 declaration(s); 20 '[[dep]]' row(s), 0 question(s): unlisted-dep-edge cleanliness -> legacy busbar-core-admin -> busbar-a2a is a test edge
kind-isolation kind-isolation:law0 11 hit(s) off the [[law0]] ceilings, 5 finding(s) over 14 neutral crate(s): law0-rise busbar × instance:secret 37 hit(s) against a ceiling of 35: this landing grew a neutral crate's instance vocabular
kind-isolation-ship kind-isolation:deps 10 finding(s) over 93 shipped edge(s): ship-edge kernel -> hooks busbar-kernel -> busbar-hook-ranking is 'not-allowed': the architecture grants no kernel -> hooks edge, and the ship criterion is the a
kind-isolation-ship kind-isolation:test-deps 10 finding(s) over 35 test edge(s): ship-edge cleanliness -> export busbar-core-admin -> busbar-export-prometheus is 'not-allowed': the architecture grants no cleanliness -> export edge, and the ship
kind-isolation-ship kind-isolation:law0 11 hit(s) off the [[law0]] ceilings, 5 finding(s) over 14 neutral crate(s): law0-rise busbar × instance:secret 37 hit(s) against a ceiling of 35: this landing grew a neutral crate's instance vocabular
kind-isolation-ship kind-isolation:faces 2 finding(s) over 40 crate(s): foreign-entry crates/busbar busbar is kind 'root' and implements 'Transport' 1 time(s) in shipped source — the entry face of kind 'transport'. A trait implementation is
kind-isolation-ship kind-isolation:legacy-drain 3 finding(s): transitional-live qa/kind-isolation.toml 'busbar-a2a -> busbar-core-admin' (legacy drain: the retiring A2A engine's tests drive the admin surface that drained into the cleanliness tier)
ship-ready ship-ready:ship-twin 'kind-isolation-ship' is not green: kind-isolation:deps (a dependency the architecture does not grant is still in the graph); kind-isolation:test-deps (a dependency the architecture does not grant is
structure-lint structure-lint:plane-dup:unledgered 22 finding(s): PLANE-DUPLICATE (module): 'config.rs' — a2a:crates/busbar-a2a/src/a2a/config.rs decisions:crates/busbar-plane-decisions/src/config.rs voice:crates/busbar-voice/src/config.rs (the ledger

Judged against base 7290204b1: 0 new red, 0 worse, 5 standing (excused).

tests passed: 22992, failed: 0. Run: https://github.com/GetBusbar/busbar/actions/runs/38072813866 . Artifact verdict-d8ec44638ed76efc2974e3ce08355f309e05442c (failures.json, junit.xml, raw.log; 90 days).

@MattJackson
MattJackson added this pull request to the merge queue Oct 10, 2026
@MattJackson
MattJackson removed this pull request from the merge queue due to a manual request Oct 10, 2026
@MattJackson
MattJackson added this pull request to the merge queue Oct 10, 2026
@MattJackson
MattJackson removed this pull request from the merge queue due to a manual request Oct 10, 2026
@MattJackson
MattJackson added this pull request to the merge queue Oct 10, 2026
@MattJackson
MattJackson removed this pull request from the merge queue due to a manual request Oct 10, 2026
@MattJackson
MattJackson added this pull request to the merge queue Oct 10, 2026
@MattJackson
MattJackson removed this pull request from the merge queue due to a manual request Oct 10, 2026
@MattJackson
MattJackson added this pull request to the merge queue Oct 10, 2026
Merged via the queue into predev with commit 027d9a5 Oct 10, 2026
8 checks passed
@MattJackson
MattJackson deleted the lane-task-draft-679-kernel-wal-round2 branch October 10, 2026 20:33
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