Repository navigation
acpmux: the open after a kill syncs nothing per replayed chat; retention never waits for a checkpoint in flight (R-DURABILITY, follow-up 4 of review 3 of #34901) - #35866
Conversation
…nd ack after durable (R-DURABILITY S1) A new crate, no consumer yet. It is stage 1 of the R-DURABILITY rewrite (handoff/rewrites/R-DURABILITY/DESIGN.md): every acknowledged change of the cmux-tui daemon and of acpmux goes into one journal of this format, and a reply goes out only once its record is synced. - Journal::append copies one record into memory (no I/O, no wait) and returns its LSN. One writer thread takes every pending record as one batch, writes it with one write, and syncs it once when it holds an acked record (group commit). Durable listeners then release the replies that wait for those LSNs. Stream records (terminal output, streamed tokens) are written in the next batch and synced with the next acked batch. - Sync levels: F_BARRIERFSYNC per acked batch on macOS (fdatasync on Linux), and F_FULLFSYNC at most 1 s after the first barrier sync, so a power loss takes back at most 1 s of acked batches, always a suffix. - Recovery is the one start path (clean exit, crash, kill -9, OS crash): it syncs the last segment, checks every record's CRC and LSN, and hands the records after the caller's compaction point to its replay. A segment is only ever appended to: a torn tail stays in its file and the journal goes on in a new segment whose header names the last good LSN. Damage that is not a torn tail moves the later segments to quarantine/ (never deleted), and no LSN is used twice. - A failed write is cut back and written again in the same segment; a failed sync is never retried on that segment (its pages may be clean and lost): the records after the durable LSN go into a new segment. - Storage is the file system seam. The `sim` feature's SimStorage keeps synced and unsynced bytes apart and crashes at any storage operation, then keeps what a process crash, an OS crash or a power loss keeps. Tests: unit tests of the format, group commit, ack after durable, listeners, sync levels, idle (no storage operation), backlog, seal, lock, rotation, failed writes and syncs at every operation, torn tails, quarantine and LSN reuse; the crash simulation (200 seeds per crash kind by default, CMUX_JOURNAL_CRASH_SEEDS for the acceptance run); and a round trip with a torn tail on the real file system. OWNERS.md: a row for the crate (R-DURABILITY rewrite owner).
…clippy) Fleet job 1a494d9372ec2bb7d71e8e1e at 26e4e71 refused two lines: E0689 (is_multiple_of on an untyped range in a test) and dead_code (Active::seq in the writer: the next segment's number is next_seq). The acpmux notify::Watcher import error in the same run is a base red.
… segment while nothing is owed - macOS sync_file fell back to fsync on ANY fcntl error. An EIO from F_FULLFSYNC or F_BARRIERFSYNC then became a success of a weaker sync, and the writer counted the records durable. Now only "not supported" (ENOTSUP, EOPNOTSUPP, EINVAL, ENOTTY: a network or FAT volume) falls back; any other error reaches the writer, which starts a new segment. - After a rotation the writer made the next segment at its next pass even with nothing to write (the shutdown pass did it too). A segment is now made only when records or a full sync are owed. - Test-only and lint-safety rewrites: a slice parameter in place of &Vec, a type alias for the crash model, a range check, one split match scrutinee.
Fleet job 7f6bfd5afb225cd1cee9d0f1 at 36f09c8: 24 of 25 crate tests pass (the three 200-seed crash simulations included). The one red, a_compaction_point_past_the_end_never_reuses_an_lsn (left [1], right [1, 10]), was a test fault: it appended LSN 10 in the restarted storage and then read back the first one. The clippy type_complexity hit of the same run is the crash model type, fixed in f55f8c5.
fcntl(F_BARRIERFSYNC / F_FULLFSYNC) and fsync can return EINTR. The writer counted that as a failed sync and moved to a new segment. Now the call is made again, as std's sync_data and sync_all do (cvt_r).
…1 review regressions (red) The S1 review (FIX at 89293fb) found that the crash simulation could not find its F1 and F4: it never crashed inside recovery, a failed operation had no partial effect, and a failed sync never lost a page. This commit changes only the simulation and the tests; the next commit fixes the journal. The simulated machine (sim.rs) now has: - a page cache over a disk, per 4 KiB page; a sync writes the dirty pages; a sync that fails writes some of them and drops the rest as clean, so a later sync reports success without them (Linux write-back error behavior); - a write that fails writes a random prefix first; - one ordered log of names (journaling file system): a folder sync makes durable every change up to the last one in that folder; a rename between folders is one change; an unsynced folder can be lost; - a process crash keeps the machine whole, so a later OS crash still loses what it would have lost; - an OS crash writes each dirty page whole, not at all, or a prefix of its sectors; a power loss also cuts back to a common point after the last full sync; - counters of open files and of injected failures. The crash runs (crash_sim_tests.rs) now run several epochs per seed: appends, a crash at a random operation (sometimes after a failed write or sync), and an open that sometimes crashes inside recovery and is tried again. After each open the oracle checks every record byte for byte, that every record an earlier open replayed is still there, that the epoch's records are a prefix of its appends, and that every acked record (fully durable after a power loss; every written one when only processes crashed) survives. Four runs: process, OS, power loss, and mixed. Regression tests for the review findings, red on the code before the fix: - F1 a_crash_inside_the_quarantine_step_never_reuses_a_quarantined_lsn - F2 a_damaged_header_in_the_last_segment_never_reuses_its_lsns - F3 a_panic_on_the_writer_thread_closes_the_journal - F4 a_restart_after_a_failed_sync_keeps_every_later_ack - F6 recovery_holds_at_most_two_files_open
…eral segments (red) The reviewer's F2 scenario has two segments: S1 holds 1..10, S2 (the last) holds 11..20, and S2's header is damaged. The test now rotates segments (300-byte segments, 20 acked records) and damages the last one's header, so the report must say lost_through 20 and the next append must take 21. On the code before the fix the moved segment's LSNs read as 0, so the next append reuses one.
…S crash) (red) On the code before the S1 fix, the crash-sim step was green (fleet 184129d8 at 263da86): 6 of 7 runs passed, and the mixed run failed only seed 140 in the full run and passed when it ran alone. The random faults reached the order the review found (F4) in 1 of 800 runs. So these tests were no red/green proof. The order: the sync of a write that spans pages fails and the disk gets none of them (Linux keeps them clean in the cache, so a later sync reports success); the process is killed in the writer's backoff; the next open reads the failed segment from the cache, replays its records and goes on; an OS crash or a power loss then takes those pages, and records an open already replayed are gone. - sim.rs: `fail_next_sync(pages, kind)` fails the next sync that has at least that many dirty pages, and the disk gets none of them. `crash_after_failure(n)` makes the n-th operation after the next injected failure the crash point. - crash_sim_tests.rs: a new run forces the order on every seed (a_kill_after_a_failed_sync_then_os_crashes_keep_every_acked_record): epoch 0 aims the fault, the kill is a process crash at the writer's retry, the open after it does not crash, epoch 1 ends in an OS crash, and a third open checks that the records the second open replayed are all there. In the random runs, one epoch in four aims the fault, with the crash 1 to 3 operations after the failed sync and a crash of any kind after it. - A sim test checks the aimed fault itself (the_sim_fails_the_aimed_sync_and_crashes_in_the_backoff). Expected on this commit (old journal code): the forced run fails every seed; the mixed run fails most runs. The fix commit follows.
…y, LSN floor, writer end) Review FIX at 89293fb (PR #33919 comment 6610468202). The regression tests are the two commits before this one. - Sync markers (format.rs): an entry with flags 1 and no payload, whose LSN field says "every record of this segment before me is synced". The writer appends one after each successful sync that covered records (after the durable listeners, so no reply waits for it), and syncs it fully at a seal and at a rotation. - F4, recovery (step 4 and 6): the last segment is trusted only up to its last marker. Its records after the marker may sit in pages a failed sync dropped (a later sync on another descriptor reports success without them), so recovery writes them again into a new segment: header, records and a marker, fully synced under `<name>.seg.new`, renamed into place, folder synced. The journal always goes on in a segment this open made, never in one an earlier process wrote. A restart in the writer's backoff after a failed sync is then safe. - F1, quarantine: every LSN a quarantined segment holds counts in the LSN floor at every open, and a quarantine that no new segment followed (a crash in the middle of an earlier recovery) is reported as damage again. The highest LSN of every moved segment is read before the first rename. - F2, highest LSN: a segment whose header is damaged is read from its first record (each record's checksum covers its LSN), then the bytes after the first bad entry are scanned for records and markers that still check out. The same scan runs after a cut in the last segment: a marker there is media damage (reported), records there are a torn write (`torn_lsns`); their LSNs are never used again. - F3, writer end: a guard closes the journal when the writer thread ends, also by a panic: appends fail with Closed, waits end with WriterEnded, the listeners are dropped (so a channel a held reply waits on closes), and stats.failing says why. The crate does not abort the process; the daemon owns that policy. - F6: recovery closes each segment after its header is read and opens one again only to read it. (Retirement of compacted segments is an S2 API, before the first consumer.) - F5: recovery syncs the parent folder after it makes the journal folder. - F7: format.rs and lib.rs now say what the code does: a failed write is cut back and retried in the same segment; the cut removes only bytes never synced and never acknowledged; nothing shortens a segment past a synced record. Docs: journal.rs says when open's replayed records are durable (on Ok) and what a stopped writer does.
… (review G5) sim.rs had 924 lines and 63 functions, over the 60-function limit for a new Rust file (the god-file check warned "not in baseline: split it"). The machine model (each file's page cache and disk, the ordered log of names, and what an OS crash or a power loss leaves) moves to sim/machine.rs (297 lines, 19 functions); the process view and the Storage implementation stay in sim.rs (639 lines, 30 functions). No behavior change: the items move as they are, made visible to the sim module.
…ash sim (red) Re-review FIX at 3498975 (PR #33919 comment 6620113993). Tests and sim only; the fix follows. - sim: the pages a failed sync dropped stay in the cache until an eviction; `evict_dropped_pages_after_read(true)` makes each read evict the dropped pages it read, so a second read of them sees the disk's bytes. - G1 recovery_copies_the_records_it_checked_when_the_cache_drops_them: a sync fails and drops record 2's pages, a kill in the backoff, the next open reads record 2 from the cache (replayed) and the pages are evicted after that read; then an OS crash. Record 2 and the record 3 acked after the restart must survive. On this code recovery's second, unchecked read stages zeros under a vouching marker, and the last open keeps only record 1 and reports damage. - G2: the F1 regression asserts that every open after the damage reports the loss (lost_through 40). On this code a crash at the open's last step (the folder sync after the new segment's rename) loses the report for good. - G3 a_journal_in_a_symlinked_folder_opens_and_round_trips (real file system): a journal in a symlinked state folder, and a journal folder that is a symlink. On this code the parent folder's sync opens with O_NOFOLLOW (ELOOP). - G4 an_open_after_a_finished_quarantine_reads_no_quarantined_segment: on this code every open reads every quarantined segment in full.
Re-review FIX at 3498975 (PR #33919 comment 6620113993); the regression tests are the commit before this one (red: fleet de35f6d0). - G1: recovery writes again exactly the bytes its checked read returned. The last segment's entries after its last marker are kept from the checked read (header and payload) and staged from memory; the second, unchecked read of the file is gone. A page that a failed sync dropped can be evicted between two reads, and the second read then returned the disk's zeros, which the copy kept under a vouching marker. - G2: the damage an open finds is written to `DAMAGE` (whole, fully synced, renamed into place, folder synced) before the new segment that hides it from later opens. Every open reports the recorded damage first, until the daemon calls the new `Journal::clear_damage`, which moves the record into `quarantine/` (kept, never deleted). - G3: a folder sync follows a symlink in its path (no O_NOFOLLOW): a state folder, or the journal folder itself, may be a symlink. The sync is for the folder that holds the entries. - G4: the quarantined segments are read for their LSNs only while no live segment is numbered above them (an unfinished quarantine); once one is, its first LSN already carries their floor. - G6: `checked_div` in the scan of the bytes after a cut (the crate's one tracked arithmetic hit). - G5 (the sim split) is the commit before the regressions.
Third review FIX at cb893c6 (PR #33919 comment 6621681018). Tests only. - G7 A a_damaged_header_after_a_quarantine_never_reuses_a_quarantined_lsn: after a finished quarantine, one byte of the empty new segment's header changes. On this code the next open skips the quarantine scan (the listing counts the damaged segment) and the next append reuses a quarantined LSN. - G7 B a_damaged_newest_segment_after_a_quarantine_reports_its_records: the newest segment holds acked 41..43, and its header and its first record are damaged. On this code the moved segment is scanned with a base far below 41, so the loss is not reported through 43 and the next append is not 44. - N1: the F1 regression also asserts that an open reports each loss once (no reported LSN range lies inside another). On this code a crash after the quarantine's renames makes the next open report the same loss twice.
…y.rs Third review FIX at cb893c6 (PR #33919 comment 6621681018); the regression tests are the commit before this one. - G7: the quarantine scan is skipped only when a segment whose header checked out is numbered above every quarantined segment (the folder listing also counted a newest segment with a damaged header, which carries no floor). The scans start from the running floor: `quarantined_floor` and `quarantine` pass the highest LSN counted so far as the base of the next file's scan, so a quarantined file with no readable header and no readable first record is scanned for the LSNs right above what is counted. - The "unfinished quarantine" report is made only when the quarantine holds LSNs nothing else counted (an old quarantine behind a segment that a crash tore at its creation holds none, and a crash is not damage). Its reason now also names a damaged newest header. - N1: a loss whose LSN range lies inside a recorded one is the same loss found again after a crash: it is neither recorded nor reported twice. - N2: recovery.rs's docs name `DAMAGE`; two doc lines over 100 columns are wrapped (lib.rs, sim.rs). - recovery.rs was at 987 of its 1000 lines: the damage record moves to recovery/damage.rs, the entry reader and the LSN scans to recovery/scan.rs (moved as they are).
…e (N1) The red run of the N1 regression (fleet 27d011ca, op 64 of the F1 test) shows the case the previous commit did not cover: a crash after the quarantine's renames and before DAMAGE is written; the next open finds both the damaged synced record of the last segment (7..14) and the unfinished quarantine (7..40), and reported both. Now every loss inside another (recorded or found by this open) is dropped, the larger range kept; DAMAGE is rewritten only when its set of losses changed.
Fourth review FIX at 215c8f0 (PR #33919 comment 6622740919). Tests only. - G8 a_recovery_copy_above_a_quarantine_never_reuses_a_quarantined_lsn (the reviewer's three steps): records 2..5 unsynced and a kill -9; open #1 copies them into C1 and goes on there with 6..10; C1's header is damaged, open #2 quarantines C1, copies 2..5 again into C2 and makes the empty E (first LSN 11); E's header is damaged. On this code open #3 sees C2 numbered above the quarantine, skips its scan and gives 6, an LSN the quarantine holds. - N3: G7 B and the F1 test assert that every file in quarantine/ is named by a reported loss. On this code the absorbed loss's names are dropped (G7 B). - N4: the F1 test asserts that the loss through 40 names the segment where the journal ends (00000000000000000002.seg). On this code a crash after the moves into quarantine/ and before DAMAGE turns it into the vaguer "unfinished quarantine" report.
… the moves (G8, N3, N4) Fourth review FIX at 215c8f0 (PR #33919 comment 6622740919); the regression tests are the commit before this one. - G8 (the G7 class): the skip of the quarantine scan trusted a live segment numbered above the quarantine to carry its floor, which a recovery copy does not (its records start at vouched + 1). Now each moved segment is named `<segment>.<n>.<floor>`, with the highest LSN counted when it moved (that file's and every one before it, scanned from the open's floor), and every open takes the quarantine's floor from the names alone: no quarantined file is read (G4 stays), and no header can hide the floor. `quarantined_floor` and the `checked_max_seq` skip are gone. The "unfinished quarantine" report is made when the names' floor is above everything else counted. - N4: `quarantine()` is split into `plan_quarantine` (measure and name every file, no change on disk) and `move_quarantined`; the loss, with the planned names, goes to DAMAGE before the moves. A crash after the moves keeps the precise report (the damaged segment and reason). - N3: when one loss absorbs another, the absorbed loss's files and reason go to the kept one, so DAMAGE names every quarantined file. - Docs: recovery step 5 and lib.rs name the quarantine file format.
…essary_sort_by) Fleet d9f22bed at b589044: the 33 journal tests pass, and clippy -D warnings fails at recovery.rs:478 (`consider using sort_by_key`).
…S2's projector) R-DURABILITY S2 (acpmux event logs through the journal) projects each chat's log line into its ndjson file once the journal has WRITTEN the batch that holds it, so readers see stream lines at once. The listener runs on the writer thread after the write and before the sync, once per batch that moved the written LSN: a batch written again into a new segment after a failed sync is not heard twice. When the writer stops, these listeners are dropped too. Test: heard while the batch's sync still runs, once. Port of 145faa6 (branch rewrite/r-durability-s2-acpmux-logs) onto the S1 review fixes.
…paction point covers) R-DURABILITY S2: a daemon's projector compacts the journal into its own files (acpmux: the chat ndjson logs, then a synced checkpoint). Without a delete the segment count only grows (review finding F6). `retire_through(compacted)` deletes the segments that hold no record after the caller's compaction point: a segment goes when the next segment's "previous last LSN" is at or before it, so the newest segment never goes. The point is capped at the durable LSN. The deletes and a folder sync run on the caller's thread (the projector, never a request path). A delete that fails leaves its segment live for a later call. Why a crash during a retirement is safe: the caller reopens from a point at or after `compacted`, and recovery reads nothing from a segment the point covers (it skips it on its next segment's header), so any subset of the deletes may reach the disk. A point older than the retired segments is reported as Damage::Missing. Quarantined segments are never deleted. - Storage::remove_file (FsStorage: std::fs::remove_file; SimStorage: one more change in the ordered log of names, durable at the folder sync). - The live segment list: recovery's, then each segment the writer makes. - JournalStats::segments_retired. - Tests: only covered segments go, and a second call deletes none; an OS crash after it keeps every record after the point and an older point is reported; a crash at every operation of a retirement (process, OS crash, power loss) keeps every record after the point and never reuses an LSN; the newest segment stays; a point in the middle of what this writer wrote. This adds a delete of journal data, so its PR is risk:high.
store.rs (2139 lines, 120 fns) and config.rs (1909 lines, 97 fns) are over
the god-file limit, and the journal store (R-DURABILITY S2) must not grow
them. MemoryStore moves to store/memory.rs and StoreMode, StoreConfig and its
defaults to config/store_config.rs; both stay exported where they were
(crate::store::MemoryStore, crate::config::{StoreConfig, StoreMode}). No
behavior change: the code moves as it is.
R-DURABILITY S2 (DESIGN.md 3.3b). Today no event-log append is synced: a line
a client was told about is on the disk only after the next flush (shutdown),
so an OS crash or a power loss takes back every line since (E12). This commit
adds only the switch, a seam and the tests; the journal store follows.
- `store.journal` (StoreConfig::journal, default false).
- `Store::wait_durable(timeout)`: tests and tools wait until every appended
record is durable. The default returns at once (the Local and Memory stores
never sync an append), which is today's behavior.
- `store::open_with_journal` (tests): the store with the journal on a given
`cmux_journal::Storage`. Here it ignores the journal and opens LocalStore.
- acpmux depends on cmux-journal (dev: with the `sim` feature).
- store/journal_store_tests.rs, red on this commit:
- a_line_the_store_says_is_durable_survives_an_os_crash (lines 2..20 are
lost: nothing synced them);
- a_deleted_chat_stays_deleted_after_a_crash and
a_line_the_log_refused_is_never_written_later pass here, and must keep
passing with the journal store (no replay may bring them back);
- a_kill_keeps_every_line_once passes here and must keep passing (a replay
after a kill must write no line twice).
…DURABILITY S2)
DESIGN.md 3.3b. With `store.journal` on, each event-log append is one record
in `<home>/journal/` (cmux-journal: group commit, one sync per batch; a power
loss takes back at most the last second, always a suffix), then the same line
goes to the chat's ndjson as before. Readers, segments and file formats do not
change. Default off.
- store/journal_store.rs (JournalStore over LocalStore):
- append: under one lock, the journal record (a memory copy, no I/O), then
the log line; a line the log refuses gets a `cancel` record. Agent stream
lines (`in` session/update, claude.*, codex.*) are Class::Stream, every
other line Class::Ack.
- checkpoint (`<home>/journal-projected.json`): every 30 s while the journal
grew, past 64 MiB, at flush (shutdown) and before a retention commit. It
syncs each log written since (LocalStore::sync_log, with no lock held
while the disk works) and the sessions folder, writes the LSN (temp file,
sync, rename, folder sync, every error checked), then retires the journal
before it. A log close that failed its sync freezes the checkpoint until a
restart (LocalStore::sync_failed).
- recovery: the records after the checkpoint; for each chat, the log is cut
right after its last readable line below the first journaled seq (later
segments removed, the boundary one shortened, their kind-index copies
dropped), and the journaled lines are written again in order. `forget`
deletes a chat again; `cancel` keeps a refused line out.
- delete: `forget` record, folder removal, then a bounded wait until the
record is durable.
- seal: the logs refuse writes and the journal syncs fully.
- LocalStore: `append` is serialize + `append_line`; `sync_log`; a failed sync
at a log's close is noted (`sync_failed`).
- store::open opens the JournalStore when `store.journal` is on.
Not yet (S2c): replies and events do not wait for durability; the daemon does
not stop when the journal closes; the writer thread keeps the process's
Utility I/O policy.
…its delete (red) - a_chat_made_again_after_its_delete_keeps_its_files_through_a_replay: a chat is deleted (its forget record), made again under the same id (a transfer back: session.json and new lines), then a kill -9. On this code the replay deletes the folder again and its session.json is gone. - a_line_after_the_seal_is_refused_and_never_written_later: coverage of the seal (it passes here; the fix also closes a race the test cannot force).
…ps a chat made again - seal takes the appends' lock: an append lands in both the journal and the logs, or in neither. Before, an append that raced the seal could leave a journal record whose line the sealed logs refused (its cancel then failed on the closed journal), and the next start would write that line. - append and delete check the logs' seal first, under the same lock, and do not touch the journal after it (no error per append at shutdown). - replay deletes a forgotten chat again only when no line follows its forget record: otherwise the chat was made again under the same id, and its folder holds that chat's own files.
Fifth review FIX at 9c47594 (PR #33919 comment 6623794646). Tests only. - a_skip_hidden_behind_a_recovery_copy_is_never_given_out_again (the reviewer's route): record 1 synced, 2..10 Stream and 11 Ack synced as one run with marker 11, media damage in record 5. Open #1 reports (4, 11], copies 2..4 into segment 2 and makes the empty segment 3 from 12. Segment 3's header is damaged. On this code open #2 reads segment 1 only through the copy's previous last LSN, never scans the damage again, and gives 5. - torn_lsns_hidden_behind_a_recovery_copy_are_never_given_out_again: the same for torn records 4..5 (a tear, then a whole record that no marker vouches for) behind the copy of 2..3: on this code the next append gets 4.
Fifth review FIX at 9c47594 (PR #33919 comment 6623794646); the regression tests are the commit before this one. When an open gives out LSNs above everything its kept and new segments say (damaged synced records or torn records in the last segment, which a copy then hides from every later open), the only record of that floor was the header of the empty segment it makes; a damaged header lost it, and DAMAGE is cleared by `clear_damage` (torn records are not damage at all). Now such an open makes the empty file `FLOOR.<lsn>` and syncs its folder before any new segment, and every open takes the highest floor from the folder listing (no file read), as it does for the quarantine names. The caller's compaction point, the quarantine's names and this open's moves say their floors already: no file is made for them.
Both sides added tests after the same test in cmux_journal_tests.rs; both are kept (the G9 tests, then the S2 retirement section).
Review FIX at ab0e953 (PR #34901 comment 6624439824). Each test fails on this commit and passes after the fix that follows it: - 1a lines_written_with_the_switch_off_survive_turning_it_on_again - 1b a_kill_while_the_journal_syncs_keeps_the_lines_the_log_got - 1c a_line_flush_session_made_durable_survives_losing_its_record - 2 a_folder_put_back_after_a_delete_is_not_deleted_by_the_replay - 3 a_kill_in_a_delete_leaves_the_chat_whole_or_gone - 4 a_delete_does_not_wait_for_the_journal_sync - 5 a_failed_log_sync_freezes_the_checkpoint, a_checkpoint_that_keeps_failing_asks_the_daemon_to_stop - 6 one_chat_that_cannot_be_repaired_does_not_stop_the_open - 7 the_journal_storage_refuses_a_lost_home - the repair keeps a log's lines after a hole, also through a crash in it: an_os_crash_hole_is_repaired_and_the_lines_after_it_kept, a_crash_inside_a_repair_loses_no_line - nits: an_unreadable_checkpoint_fails_the_open, the_open_waits_for_the_previous_daemon_to_let_go_of_the_journal, a_crashed_checkpoint_temporary_is_removed_at_open, a_crash_right_after_a_checkpoint_reports_no_damage - the catch-up with the switch off: switching_off_after_a_crash_keeps_every_acknowledged_line - cmux-journal: check_append_says_what_append_would_do So they compile here, this commit adds the seams they use, with no behavior: `Store::stop_reason` (default None), `Journal::check_append` (always Ok), `FencedStorage` (never asks its fence), the open report and a test stop point after a repair's cut.
|
Review (r-durability-rsync-reviewer): FIX at 7f3e036 Delta review 3: Repro tests (never merge): branch
D2 from my last review: fixed
FindingD3 (P2). The write-back finds its file by path, inode number and length. An inode number is not a file's identity, so the write-back can write a deleted chat's bytes into a new chat's log.
P3
Checked and found correct
Not run
|
…en verified, tests (red; delta review 3 of #35866, D3) D3: the checkpoint's write-back finds its segment by path, inode number and length (open_verified). An inode number is not a file's identity. A chat deleted while its write-back is pending and made again under the same id gets a new first segment, and a file system that hands the freed inode out again gives it the old number (ext4 did, in the reviewer's Linux repro); on Windows nothing is compared. The checkpoint then writes the deleted chat's bytes over the new chat's acknowledged lines, syncs them and retires their records. - a_chat_deleted_and_made_again_never_takes_the_old_chats_verified_bytes: the reviewer's test (rsync_review5_a_deleted_chats_write_back_never_lands_in_a_new_chats_log, red on macOS fleet 857c0030 and Linux fleet 878ccf7a), renamed. A second link stands in for the reused inode. - a_folder_replaced_outside_the_store_never_takes_the_old_chats_verified_bytes: the same with no delete, as a transfer-in that replaces a durable chat does: the folder is renamed aside and a new one is put in its place, whose first segment has the old file id. A cleanup at Store::delete would not see it. The reviewer's ext4 test is not taken: where the file system does not give the inode out again, it fails as "inconclusive". The second link gives the same file id on every file system. Both fail at this commit; the fix follows.
…nly syncs the open's handles (delta review 3 of #35866, D3) D3: the checkpoint's write-back found its segment by path, inode number and length, and an inode number is not a file's identity. A chat deleted while its write-back was pending and made again under the same id, or a folder that code outside the store replaced (a transfer-in that replaces a durable chat), could put a file with the old number at the path; ext4 hands a freed inode out again, and Windows compared nothing. The checkpoint then wrote the old chat's bytes over the new chat's acknowledged lines, synced them and retired their records. The fix takes the reviewer's direction: - The open's repair writes each verified range back over itself right after it read and checked it (Inner::write_back: one write per segment, no sync). It runs before any other thread uses the logs, so the file it writes is the file it read. - It keeps the handle it wrote through (Order::read_segments, now path to Arc<File>), and the checkpoint only syncs those handles (sync_read_segments). A sync reaches the very file the open wrote, wherever a delete, a folder replace or a retention rewrite moved it, and the checkpoint writes nothing, so a file put at the path since is never changed. A folder moved aside and put back (the reviewer's P3 1) is synced too. - The open still syncs nothing per chat. The write marks the range dirty again, so the sync puts the verified bytes on the disk even after a failed sync that left no note (D2 holds). - A write error at the open neither fails the open nor freezes the store: the chat takes the slow path (journal the lines only the log holds, cut at the repair start, write the merged lines), whose cut also removes a range written back in part. - The write opens the segment as every segment read does, plus the lost-home check: never made, never through a final link, only an owner-only file of this user (macOS: no extended ACL; Windows: owner_only::open_rw_existing). (The reviewer's P3 2.) - Store::delete drops its chat's handles: its files need no sync, and the handles would keep the removed files' space in use until a checkpoint. The cost: one descriptor per replayed segment, from the open until the first checkpoint that succeeds (the daemon raises its open-file limit before the store opens), and the open's write of each replayed tail (to be measured with the open-time bench). Green: a_chat_deleted_and_made_again_never_takes_the_old_chats_verified_bytes and a_folder_replaced_outside_the_store_never_takes_the_old_chats_verified_bytes (red at b53a750: macOS fleet 6b7b8fa2, Linux fleet 55b36bf4); new a_failed_write_back_at_the_open_writes_the_lines_again.
…e (clippy -D warnings, Linux fleet bd0548b2) The D3 tests imported std::io::Write, which store.rs already brings in through use super::*; clippy's unused_imports failed the Linux all store:: step at 4846ec2 (every test passed).
… and holds no handle (delta review 3 of #35866, pre-review points 1 and 2) 4846ec2 kept the handle the open wrote each verified range through until the first checkpoint that succeeds. The reviewer named two costs before its review: 1. Descriptors: one per replayed segment, and a failed checkpoint keeps them (the P1-1 rule). LocalStore caps its open logs at 64 because a daemon ran out at 256. The daemon raises its soft limit to min(hard, 65536) at start, but when setrlimit fails the limit stays (256 under launchd): 300 replayed chats then use up the descriptors during the open itself, whose repair file writes then fail and fail the open. 2. Windows: a folder whose files are open may refuse a rename or a removal, so a transfer-in's rename of the chat folder aside, or Store::delete's removal, could fail while a handle is held. The checkpoint now opens each listed segment by path only to sync it, and writes nothing. That is enough for D3: a file that a delete, a folder replace or a retention rewrite put at the path since is synced, never written, and its sync is harmless. D2 holds: the open's write marked the range dirty, and the sync of the same file puts the bytes on the disk. A retention rewrite of a replayed chat still checkpoints first (Order::log_lsn), so the sync comes before any rename over the segment. Order::read_segments is a set of paths again; Store::delete still drops its chat's. The sync opens the segment as the write does (lost-home, link, owner, mode and ACL checks); on Windows FlushFileBuffers needs a handle that may write. What this gives back against 4846ec2: - A folder moved aside during the window between the open and its checkpoint (the reviewer's P3 1: a transfer-in that fails and renames its backup back) is passed when not found, as before D2, and its records are retired: the class of #35915 item 4. - A sync through a new descriptor does not report a writeback error that another process's fsync of the same file already saw (Linux errseq). Inside this process every other sync of a log freezes the store when it fails, and the checkpoint reads frozen after the syncs under way. The D3 tests and a_failed_write_back_at_the_open_writes_the_lines_again are unchanged.
…or the checkpoint's sync (open-time A/B of delta review 3 of #35866) 3452068 closed each segment the open wrote its verified bytes back into right after the write, and the checkpoint synced it by path. The open-time A/B (the PR's bench, 10 runs per size) showed that close is costly on macOS, on three Macs and in both orders. Median with 300 replayed chats, the open: 39.1 ms with the handle held (4846ec2, aws-m4pro-8), 115.8 ms with the close (3452068, aws-m4pro-8); 42.0 against 121.3 ms on aws-m4pro-9, 52.8 against 130.8 ms on aws-m4pro-6 (run at the same time). The first acknowledged change after the open: 1.9 against 22.6 ms. With 10 chats nothing changed. Now, on Unix, the open keeps the handle it wrote through (repair::WrittenBack::Held) for up to repair::held_cap segments: a quarter of the open-file limit, which the daemon raised before the store opens, at most 4096 (64 when the limit cannot be read). The checkpoint syncs a held segment through its handle and then closes it on its own thread; a segment past the cap, and every segment on Windows (where an open file can block its folder's rename or removal), is closed at the open and synced by path, as at 3452068. The checkpoint still writes nothing, so D3 stays closed. A held handle also covers a folder moved aside and put back before the checkpoint (the reviewer's P3 1, #35915 item 6), within the cap on Unix. Store::delete takes its chat's entries out under the appends' lock and closes the handles after it lets the lock go. New test: the_written_back_segments_past_the_cap_are_synced_by_path (a cap of 1: one segment held, two closed, then every acknowledged line after the checkpoint and an OS crash). Measured at 18612dc (this change without the delete's close order): open with 300 chats 40.8 ms median (aws-m4pro-8, fleet b26f2929); macOS journal_ 384/384 (fleet 9e5aa05f); Linux all store:: with fmt and clippy, 186 acpmux tests pass (fleet 4cfd173c).
|
Review (r-durability-rsync-reviewer): SHIP at 29967f9 Delta review 4: D3 from my last review: fixed
The held handles (
|
- cef-embedder.sh (#36421): a comment line named both kill(0) and the name lookup, which the pkill lint counts as a name-lookup kill; reword it. - journal_store.rs (#35866): SEAL_WAIT and LOCK_WAIT get a ratchet entry that says what each bounds. - nightly.yml (#36287): drop the gh-app-install and ring-token anchors no alias uses any more (actionlint). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Agent-Session: https://cmux.com/deeplink/agent?id=aid-c21fa64be7cee139675a
Summary
Follow-up 4 of the third review of #34901 (S2): the open's syncs per replayed chat, measured against the daemon's 8 s start budget, then cut. Its base is now
feat-cmux-next: #35483, #35562 and #35895 have landed, and the S3b head (#35565) is merged in. Behind the same switch,store.journal(default off), which stays off until this PR and the data-loss follow-ups (#35905, #35915) land.An acpmux daemon that a kill or a crash ended replays the journal at its next start, before it binds its socket. Each replayed chat cost a few syncs, and on macOS a sync of a file is a drive cache flush (
F_FULLFSYNC), so the daemon served nothing for seconds.Open-time bench: distributions, base and head on the same Mac
open_time_after_a_kill_with_replayed_chats(scratch, never lands; branchesfix/r-durability-openbench-dist2-{base,head}-nomerge): N chats each with 5 lines and a metadata image past the checkpoint, a kill, then the open. 10 runs per size, sizes interleaved, real files. Both on owned Mac aws-m4pro-8, base first, then head:86441c46001(S3b head66e1df5ec5e+ the bench);237017ea1ae(this branch atb8159d903c8+ the bench).Measures, in ms (median of the 10 runs / max):
ready: the store's open (the daemon binds its socket after it) plus the person's first change after it (a line to a replayed chat) until the S2c gate passes it (its record durable): the time the person waits at a start.durable: the start until a checkpoint after that change and a new chat's ends (every replayed record durable and retired).The parts, median / max (base, then head):
The head is faster in every measure. The 10-chat maxima on the head (ready 82 ms, open 76 ms, new chat 32.5 ms, durable 390 ms) all come from
run=0 chats=10, the first open in the test process; every later 10-chat run opened in 5.8 to 11.3 ms, andrun=0of 100 and 300 chats (in the same process right after) is at their medians (19.2 and 36.1 ms). So it is a cost of the first open in a process (about 65 ms here), not host noise. A daemon start is a first open in its process, so the start to expect with 10 chats is that run's: 76 ms open, 82 ms ready (the base's first run: 113 ms open, 114 ms ready). Where those 65 ms go is not measured (not a split by step); follow-up R-DURABILITY.Does a visible action wait for "durable"?
No, after the fix below. On the base, the open itself made everything durable (
open=durable), and the daemon answered nothing until then. On the head, the open returns at once; the checkpoint that makes the replayed records durable runs on the checkpointer's thread:Store::flush(only the shutdown does, after the agents stop, within its 5 s budget).commit_compactionran a checkpoint under the chat's append lock (hub/retention.rs), so a person's first message to that chat waited for the open's checkpoint. Fixf9c6a16d1b8:Order::log_lsnkeeps each chat's newest log record in this process; a chat whose records are all checkpointed (retention takes chats untouched for days) runs no checkpoint; any other still checkpoints first. Redd860bef9d32:a_retention_rewrite_of_a_checkpointed_chat_does_not_wait_for_a_checkpoint; guardsa_retention_rewrite_of_a_chat_with_records_past_the_checkpoint_checkpoints_first,a_retention_rewrite_of_a_replayed_chat_checkpoints_first.Until the checkpoint ends, the journal keeps every replayed record, so a second crash replays them again; nothing acknowledged depends on the checkpoint.
What changes
log_sync::sync_segment_ordered,sync_directory_ordered: macOSF_BARRIERFSYNC, Linuxfdatasync/fsync) and flush the drive once, with the checkpoint file (sync_full), before they retire any record.cmux_journal::sync_fileis public for this.Order::read_segments). The next checkpoint syncs each listed segment before it retires their records, through the handle the open wrote with (on Unix, up to a quarter of the open-file limit) or else by path (past that cap, and on Windows): a killed daemon's writes may be in the page cache only, and a previous run's failed sync may have dropped pages that still read back. The write marks those pages dirty again, so the sync puts them on the disk either way, with no need for a durable note of the failure (delta review 2, D2). The write happens at the open, before any other thread uses the logs, and the checkpoint writes nothing, so a file that a delete, a folder replace or a retention rewrite put at the path since is never changed (delta review 3, D3). A write error at the open takes the slow path for that chat (journal, cut, write again); a delete drops its chat's listed segments.session.json.Order::log_lsn, a per-chat upper bound of its newest log record in this process): it no longer waits, under the chat's append lock, for a checkpoint in flight such as the open's.frozenjournal record (kind 6), a control record that a full queue never refuses (Journal::append_control); the journal closes only after the shutdown's last checkpoint. The open that replays it writes every replayed chat's lines again. A record the journal still refuses (its writer stopped) leaves a durable marker,<home>/journal-frozen, which the open reads the same way and the first checkpoint that succeeds after it removes (never while the store is frozen). A chat whose lines come from a repair file (acpmux: event journal follow-ups: a failed repair keeps its records, a hole's last zero splits, an idle checkpointer sleeps #35562) is written again too.Risk
risk:high, the label the risk classifier set at 02:35Z for the push that addedJournal::append_control(it had setrisk:normalbefore; agents do not change it). It lands on an independent SHIP at the exact head and greens on macOS and Linux at that head, both stated below. Behind the default-off switch. It changes when the store's files reach the disk after a crash: the open returns before the replayed state is synced, and the checkpoint on its own thread makes it durable before any record is retired. Every record stays in the journal until then, so a second crash replays the same records again.Testing
acpmuxstore::journal_meta_testsandstore::journal_store_tests:an_open_that_replays_many_chats_flushes_the_drive_a_bounded_number_of_times(40 chats; at most 8 full syncs)an_open_after_a_kill_syncs_nothing_per_chat_and_the_checkpoint_makes_it_durable(40 chats; at most 4 syncs on the opening thread; then an OS crash after the checkpoint keeps every line)a_freeze_makes_the_next_open_write_the_lines_again_and_only_that_open(the freeze record), and the review tests listed in the review sections belowa_retention_rewrite_of_a_checkpointed_chat_does_not_wait_for_a_checkpoint; guardsa_retention_rewrite_of_a_chat_with_records_past_the_checkpoint_checkpoints_first,a_retention_rewrite_of_a_replayed_chat_checkpoints_firstopen_time_after_a_kill_with_replayed_chats(10, 100 and 300 chats, 10 runs each; it printsACPMUX-PERF openbench)Red:
b2240f1b050: fleet 86535ce1 (aws-m4pro-4): "the open flushed the drive 283 times for 40 replayed chats".6def18f92c5: fleet 90d87e10 (aws-m4pro-5): "the open synced 283 times for 40 replayed chats before it returned".Red, the freeze marker (a mutant at
07dc13192c8: this branch at05cb007eebawith the open ignoringjournal-frozen; branchfix/r-durability-rsync-nomarker-nomerge, never merged):an_os_crash_after_a_restart_from_a_failed_sync_keeps_every_acked_linefails, in the full run and alone, "the restart's checkpoint retired records whose lines a failed sync lost": macOS fleet cbd6e808, Linux fleet e4e9634c (hq run 41899881551). Nothing else fails.Red, the retention wait, at
d860bef9d32:a_retention_rewrite_of_a_checkpointed_chat_does_not_wait_for_a_checkpointfails, in the full run and alone, "the retention rewrite of a checkpointed chat waited 1.72083425s for a checkpoint in flight" (macOS fleet 80f5ee16) and 1.70 s (Linux fleet 623bf9af, hq run 41908393200); the two guards pass.Green at the head
f9c6a16d1b8447f40274186314a64f67b80253c8:journal_356 of 356; fleet a93736f5 (owned Mac aws-m4pro-6),retention29 of 29 (the hub's retention tests and the three new ones).all store::: fmt, clippy and 357 of 357 tests (acpmux 159, cmux-tui 41, cmux-tui-core 154, cmux-worktree-care 3).b0aee122fe2(before the retention fix; the merge of the S3a N1 fix): macOS fleet eeaf9997,journal_353 of 353; Linux fleet 1e12de8d (hq run 41907479654),all store::: fmt, clippy and 354 of 354.At
05cb007eeba(the merge of the review fixes, before the retention fix): macOS fleet ea82537d (journal_345 of 345), ee6bc8a4 (a_failed_log_sync_stops_the_daemon_for_a_restart1 of 1); Linux fleet c44c6512 (hq run 41898799949,all store::: clippy and 350 of 350 tests; fmt red only on the base files #35845 fixed). fmt atb8159d903c8after #35845: fleet e56e4c55, green.Earlier, at
b80145ec8a6: fleet bfb0a243 (macOS,journal_328 of 328); fleet 0577ac62 (Linux,store::331 of 332, with one failure from the idle checkpointer race that #35562 fixed at5279af4bbf7; see that PR).a_chat_deleted_and_made_again_never_takes_the_old_chats_verified_bytes,a_folder_replaced_outside_the_store_never_takes_the_old_chats_verified_bytes,a_failed_write_back_at_the_open_writes_the_lines_again(store::journal_write_back_tests, delta review 3)Observed / Not run
Review of
f9c6a16d1b8: what changed (P1-1, P2-1, P2-2)Red tests first (the reviewer's) in
05493cad342. They fail at that commit, in the full run and alone, each for its reason (macOS fleet a3bd72b0, 3 of 27; Linux fleet 0f24f011):a_failed_checkpoint_after_the_open_keeps_the_segments_it_read(P1-1).a_freeze_during_a_checkpoint_still_makes_the_restart_write_the_lines_again(P2-1).a_freeze_in_a_home_that_takes_no_new_file_still_writes_the_lines_again(P2-2).The fixes:
f0ebe9e0ac4(P1-1):checkpoint_oncetookOrder::read_segmentsat its start and put the set back only whensync_read_segmentsitself failed. A checkpoint that failed before it (a journal sync slower than its wait, an image that could not be written) dropped the set, and the next checkpoint retired records whose log bytes no sync of this process covered. Now the checkpoint reads a copy and removes those paths only after their sync succeeds.101232eeb4d(P2-1 and P2-2): both came from one root. The restart learned of a freeze from a marker file whose write and removal the journal did not order. Nowfreezeappends afrozenjournal record (kind 6) under the appends' lock, after every line it covers. The open that replays it writes every replayed chat's lines again. No checkpoint can take it back, because a checkpoint in flight read its LSN before the record. The record needs no write that can fail apart from the journal's own. The marker file is gone. A chat whose lines come from a repair file is written again too (Order::rewrite).24cc4ae39e4: a compile fix for101232eeb4d(E0027:applydid not name the replay's newfrozenfield).24cc4ae39e4: macOSjournal_(fleet 8095cfdd), Linuxall store::(fleet 65386a18), macOS restart tests (fleet ea9d4c48).Merged down (head
60cce5e745b)0befb17bc02merges the S3b head6a5a6d138c9(acpmux: a tags or rules change logs its line before its metadata (R-DURABILITY S3b) #35565: feat with acpmux: event journal follow-ups: a failed repair keeps its records, a hole's last zero splits, an idle checkpointer sleeps #35562 squashed). The merge base is the S3b head this branch had (7fc15b776ef). One conflict, injournal_store_tests.rs: both sides appended tests at the end. Both blocks are kept whole (45 tests = 41 + 40 - 36).60cce5e745bmerges featb536ff861c5, which has acpmux: a checkpoint never writes an LSN past a sync that failed on another thread (R-DURABILITY; culprit 71bc09a1e63, S3a) #35895 (a checkpoint never writes its LSN past a sync that failed on another thread). Conflicts in 3 files, all resolved by keeping both sides:checkpoint.rs: acpmux: a checkpoint never writes an LSN past a sync that failed on another thread (R-DURABILITY; culprit 71bc09a1e63, S3a) #35895'scounted_sync, then this PR's journal-recordfreeze().checkpoint_oncekeeps this PR's start (P1-1) and acpmux: a checkpoint never writes an LSN past a sync that failed on another thread (R-DURABILITY; culprit 71bc09a1e63, S3a) #35895's end: the closes' note read under the writers' lock, the wait for the syncs under way, thenfrozen.log_sync.rs: acpmux: a checkpoint never writes an LSN past a sync that failed on another thread (R-DURABILITY; culprit 71bc09a1e63, S3a) #35895'sclose_sync_failedbeside this PR's orderedsync_log, with acpmux: a checkpoint never writes an LSN past a sync that failed on another thread (R-DURABILITY; culprit 71bc09a1e63, S3a) #35895's test seam before its segment sync.journal_store_tests.rs: both appended blocks (49 tests = 45 + 45 - 41).freeze()appends thefrozenrecord. When the journal's queue is full, that append waits for the writer (up to 5 s) and appends again, so a checkpoint in flight waited for that too. Nowfreeze()has two steps:freeze_now, inside the count and under the appends' lock:frozenset and the record appended. The journal's append never blocks (it checks its queue and copies the record), so this step is as short as before, and a seal still cannot come betweenfrozenand the record.freeze_later, after the ticket ends: only when the record found the queue full, the wait for the writer and the second append; then the ask to stop, so the shutdown's seal still comes after the record.flush_session(some callers are async functions on a runtime worker, such asfork_with_boundaryandtransfer_finish;fork_beforeandcommit_watch_pagecall it throughspawn_blocking), the caller ofsession_dir, the checkpointer (its ownsession.jsonwrites; its ticket ends before its wait, so it never waits for itself), or the folder syncer. No request path runs a checkpoint, so before this fix no request waited for the checkpointer. One indirect wait existed: the retention pass holds the chat's append lock whilecommit_compactionruns a checkpoint (when the chat is not covered yet), so an append to that chat could wait for that checkpoint's extra wait. That chat is closed and untouched, so this was rare. The shutdown'sflushcould also wait the extra 5 s. Both now wait only for the syncs.a_checkpoint_does_not_wait_for_a_frozen_record_on_another_threadfails atdf1815e7fd4("a checkpoint waited for another thread's frozen record, not only for its failed sync", macOS fleet 63217ea0), and in its queue-full form at9e90c42de24(macOS fleet c20b3ace, same message, in the full run and alone). Handshakes only: the checkpoint is held after its start; the failing thread's record finds the queue full (FORCE_FROZEN_BACKLOG) and it stops before its wait for the writer (HOLD_BEFORE_FROZEN_RECORD); the checkpoint must return, refused, before that thread goes on. After the release, the restart must still write the lines again.60cce5e745b(before the freeze split): fleet clippy-D warningsgreen (fleet a8038a8a, hq run 41939067327); macOSjournal_368 of 368 (fleet 2e614ff0); Linuxall store::369 of 369 with fmt and clippy (fleet 4332aaf1, hq run 41940083373).f62feba6e3d: macOSjournal_(fleet d7884d24) 369 of 369; Linuxall store::with fmt and clippy (fleet 1172395c) 370 of 370 (Blacksmith; hq run 41946086686).Delta review of
f62feba6e3d(FIX): what changed (D1)D1 (P2): when the journal refused the store's
frozenrecord, the freeze only logged an error, and a restart in the same boot trusted the log pages the failed sync dropped; an OS crash then lost acknowledged lines. Three ways, one test each, red at4a883c4f006(and test 1 armed before the seal at32fd1f845f4) on macOS (fleet c07e8938, ce3f21e3) and Linux (fleet 5234e655, hq run 41954644923), each in the full run and alone, each lost lines 2 to 5:a_log_sync_that_fails_in_the_shutdowns_last_checkpoint_keeps_the_rewrite(the reviewer's):Hub::sealsealed the store, then ran its last checkpoint; a log sync that failed there found the journal closed.a_frozen_record_while_the_journals_queue_stays_full_keeps_the_rewrite(the reviewer's, no seam in the store): a slow writer and other chats' lines kept the queue full, so the record was refused twice.a_seal_while_a_freeze_is_under_way_keeps_the_rewrite: a failingflush_sessionheld right before its freeze while another thread seals; handshakes only. (The reviewer's third test held the freeze between the two steps off62feba6e3d, which the fix removes.)The fix, three commits:
1617013d136:cmux-journalgetsJournal::append_control, an append that a full queue never refuses (stillClosedwhen the journal is closed,TooLargepastMAX_PAYLOAD_LEN; unit testa_control_record_is_taken_while_the_queue_is_full). Thefrozenrecord uses it (one per store, its reason cut to 4 KiB). So the freeze is one step again, inside the failed sync's ticket, with no retry and no wait: a checkpoint's wait is bounded by the syncs under way (the bound of acpmux: a checkpoint never writes an LSN past a sync that failed on another thread (R-DURABILITY; culprit 71bc09a1e63, S3a) #35895). The two-step freeze off62feba6e3d, its seams, and the test of its retry are gone.7b27d29b8be:JournalStore::sealseals the logs, runs the shutdown's last checkpoint while the journal is still open, then seals the journal. A failure in that checkpoint, or in a sync it waits for, journals its record before the journal closes.b3988138ef9(red at510cade7cf0:a_refused_frozen_record_falls_back_to_a_durable_marker, macOS fleet 8f90a826, fails, in the full run and alone: "a refused frozen record left no marker"): fail closed. A record the journal still refuses (its writer stopped), or a frozen store whose journal does not close with every record durable, leaves a durable marker<home>/journal-frozen(written whole, fully synced, renamed, the home folder synced), after the failed sync's ticket ends. The open reads it as it reads the record. The first checkpoint that succeeds after that open removes it, never while the store is frozen (so the P2-1 race cannot come back). Guard:a_checkpoint_does_not_wait_for_a_freezes_fallback_marker.P3s: the module docs, the moved doc comment, and the two P2 review tests' text now describe the freeze as it is; the P2-1 test orders its threads by handshakes (
a2475c77387). DESIGN.md 3.3c has the new bound and the rollback note: a build without this PR cannot open a journal with afrozenrecord (unknown kind 6); with the switch off, its catch-up fails and only logs, and the logs open alone.Green at the head
a2475c77387: macOSjournal_(fleet eca8d85e) 374 of 374; macOScmux_journal(fleet b0970653) 42 of 42; Linuxall store::with fmt and clippy-D warnings(fleet 296eb354) 374 of 374 (Blacksmith; hq run 41957521689).Delta review 2 of
a2475c77387(FIX): what changed (D2)D2 (P2): after a failed sync, the restart trusted a whole log unless it found a frozen record or a marker. A kill before either was durable broke that. Two windows: the record still waited in the journal's queue (its append succeeded, so no marker), or a log's close only set its flag and no checkpoint had read it yet. The base wrote every replayed chat's lines again at each open, so it had neither window; this PR's fast path brought them.
9125b99c745:a_kill_while_the_frozen_record_waits_in_the_queue_keeps_the_lines, the reviewer's repro (red on fleet e150f624 and e2ba4960), rebuilt with a hold in place of a 2 s sync and a 300 ms sleep (newSimStorage::hold_next_sync). It fails atjournal_meta_tests.rs:1353, in the full run and alone,left: [1]: macOS fleet e8626223, Linux fleet a5d401ae (hq run 41965232344).dcf9ed7a8aa:a_kill_after_a_failed_log_close_before_any_checkpoint_keeps_the_lines(the reviewer found it by reading): it fails atjournal_meta_tests.rs:1393after a clean compile, in the full run and alone,left: [1]: macOS fleet 11761812, Linux fleet e8e5017f (hq run 41966752715).2a065d25cd3, the reviewer's direction: the checkpoint writes back the bytes the open verified, then syncs (above, "What changes"). It needs no note, so neither window matters. A segment that is gone, replaced (another file id) or shorter is passed; a failed write or sync freezes the store. The crash model learns the write-back (sync_model::rewritten). The frozen record, the seal order and the marker stay for the chats the fast path does not take.JournalStore::seal's doc comment no longer starts with the old text. P3 3: "What changes" and "Testing" above describe the record, the write-back and the marker. P3 1 (apply_metasdoes not readrewrite_all, so asession.jsonfrom a failed sync is trusted) is R-DURABILITY: four older durability gaps from the reviews of #35895 and #35562 #35915 item 2. P3 2 (at shutdown only, a freeze can wait up to 5 s while the seal holds the appends' lock) is noted here; the wait is the journal's own seal wait, and nothing visible waits behind it.Green at
6b0547afc43(before the merge with #35963): macOSjournal_(fleet 1ef76c60) 376 of 376; macOScmux_journal(fleet d9ac896f) 42 of 42; Linuxall store::with fmt and clippy-D warnings(fleet b33baa1b) 376 of 376 (Blacksmith; hq run 41967437029).Merged down after #35963 landed (head
7f3e036d277)#35963 (the #35905 fix) landed on feat and changed the same store files, so this PR conflicted with feat.
2e36ab0db02merges feat1b585e3ddca, with conflicts in 2 files, resolved by keeping both sides:JournalStore::commit_compaction: this PR's "covered" check (a chat whose newest log record is at or below the checkpoint runs no checkpoint,Order::log_lsn) runs first, then acpmux: a failed sync of a retention rewrite or a kept-aside image freezes the store (R-DURABILITY, #35905) #35963'scounted_syncaround the commit. A frozen store is not covered, so its checkpoint runs, fails and stops the rewrite, as on both sides.counted_sync's doc comment: acpmux: a failed sync of a retention rewrite or a kept-aside image freezes the store (R-DURABILITY, #35905) #35963's list of counted syncs, plus this PR's note that the freeze never waits and that a fallback marker comes after the count.Checked, with no textual conflict:
write_throughcounts the kept-aside image, whichkeep_aside_metanow writes throughwrite_kept(a new file each time); the ordered folder sync honors the same test seams, so #35963'sFAIL_DIR_SYNC_WHENtests name the same syncs; and the D2 write-back passes a segment that a retention rewrite renamed over (another file id).7f3e036d277is a clean merge-main of featcd9b4166785.Green at the head
7f3e036d277: macOSjournal_(fleet c03d8a73) 380 of 380 (the 376 of6b0547afc43plus the 4journal_sync_testsfrom #35963); macOScmux_journal(fleet ed5a03c6) 42 of 42; Linuxall store::with fmt and clippy-D warnings(fleet 86498e8b) 380 of 380, fmt and clippy pass (Blacksmith; hq run 41969667583).Delta review 3 of
7f3e036d277(FIX): what changed (D3)D3 (P2): the checkpoint's write-back found its segment by path, inode number and length (
open_verified), and an inode number is not a file's identity. A chat deleted while its write-back was pending and made again under the same id, or a folder that code outside the store replaced (a transfer-in), could put a file with the old number at the path: ext4 hands a freed inode out again (the reviewer's Linux repro got the freed inode 13893637 back), and on Windows nothing was compared. The checkpoint then wrote the old chat's bytes over the new chat's acknowledged lines, synced them and retired their records.Red
b53a750bcd1:a_chat_deleted_and_made_again_never_takes_the_old_chats_verified_bytes(the reviewer's test,rsync_review5_a_deleted_chats_write_back_never_lands_in_a_new_chats_log, renamed; a second link stands in for the reused inode) anda_folder_replaced_outside_the_store_never_takes_the_old_chats_verified_bytes(the reviewer's request: no delete, the folder renamed aside and a new one put in, as a transfer-in does). Both fail at their own assertions after a clean compile, in the full run and alone: macOS fleet 6b7b8fa2, Linux fleet 55b36bf4 (hq run 41976946052), "the checkpoint wrote the old chat's verified bytes into a new chat's log" (line 179) and "... into a folder put in its place outside the store" (line 229). The reviewer's ext4 test is not taken: on a file system that does not give the inode out again it fails as "inconclusive".Fix
4846ec2b09e, the reviewer's direction: the open writes the verified bytes back (Inner::write_back), and the checkpoint only syncs (sync_read_segments). A write error at the open neither fails the open nor freezes: that chat takes the slow path, whose cut also removes a range written back in part (new testa_failed_write_back_at_the_open_writes_the_lines_again). The write opens the segment as every read does, plus the lost-home check (O_NOFOLLOW, never made, owner, mode and macOS ACL checks; Windowsowner_only::open_rw_existing): the reviewer's P3 2.Store::deletedrops its chat's listed segments.4846ec2b09ekept the handle the open wrote through until the checkpoint synced it. Before its review, the reviewer named two costs of that, and3452068e332removes them: the checkpoint now syncs each listed segment by path and holds no handle.Store::delete).ff6922678dd: clippy-D warningsfailed the Linux step at4846ec2b09eon an unusedstd::io::Writeimport in the new tests (fleet bd0548b2, hq run 41979974134; every test passed there).Open-time A/B (the reviewer asked for it), the PR's bench, 10 runs per size, all on owned Mac aws-m4pro-8, in ms, median / p90 / max:
7f3e036d277(fleet e5daffbe)4846ec2b09e(fleet c69599be)3452068e332(fleet 93887e54)The three ran one after the other (06:36Z, 07:01Z, 07:31Z), not interleaved. B against A is inside the same-arm spread on the open. C is worse than A and B on the open, ready and the first acknowledged change at 100 and 300 chats, beyond the same-arm spread (C's fastest open with 300 chats, 113.8 ms, is slower than A's slowest, 47.8 ms); with 10 chats nothing changes. In the open, B and C differ in one thing only: B closes each written-back segment after the checkpoint's sync, C right after the write.
The rerun confirmed it on two more Macs and in both orders: median open with 300 chats, B 52.8 against C 130.8 ms on aws-m4pro-6 (the two run at the same time, fleet dab0d89f and 15f9cd66), B 42.0 against C 121.3 ms on aws-m4pro-9 (C first, then B: fleet 69c4e412, d741646f). The first acknowledged change: 2.3 against 29.4 ms, and 2.4 against 30.4 ms. So on macOS the close right after the write costs the open about a quarter of a millisecond per replayed chat.
29967f9f5f7: the open keeps the handle it wrote each segment through (repair::WrittenBack::Held), up torepair::held_cap: a quarter of the open-file limit (which the daemon raises before the store opens), at most 4096, and 64 when the limit cannot be read. The checkpoint syncs a held segment through its handle and closes it on its own thread, after the sync. A segment past the cap, and every segment on Windows, is closed at the open and synced by path, as at3452068e332. The checkpoint still writes nothing, so D3 stays closed. Within the cap on Unix, a held handle also covers a folder moved aside and put back before the checkpoint (R-DURABILITY: four older durability gaps from the reviews of #35895 and #35562 #35915 item 6), since a rename keeps the file the handle holds.Store::deletetakes its chat's entries out under the appends' lock and closes their handles after it lets the lock go. New test:the_written_back_segments_past_the_cap_are_synced_by_path(a cap of 1: one segment held, two closed, then every acknowledged line after the checkpoint and an OS crash).Open-time A/B at
29967f9f5f7(arm E), median / min to max, ms:7f3e036d277, aws-m4pro-6 (fleet c74909f1)29967f9f5f7, aws-m4pro-6 (fleet 1021ba81)29967f9f5f7, aws-m4pro-8 (fleet cfcb5ffd)A and E on aws-m4pro-6 ran at the same time, so they share the host's load: every difference is inside the same-arm spread, so the head is not worse than the code before D3 by the floor rule. E on aws-m4pro-8 ran an hour and a half after the D draft there (09:32Z against 07:59Z) (18612dc, which has the same open path: it differs only in the delete), whose open with 300 chats was 40.8 ms; that gap is the host between the two times, not the code.
Green at the head
29967f9f5f72b3fc668a1d20c6d5211edbc92f81: macOSjournal_(fleet dbb06014) 384 of 384; macOScmux_journal(fleet 31ef1527) 42 of 42; Linuxall store::with fmt and clippy-D warnings(fleet fc960a45, Blacksmith, hq run 42003274872) 384 of 384. Each has the fourjournal_write_back_tests. (At3452068e332: 383, 42 and 383 of 383, fleet 01ee21d5, 4fff42b7, e125df6c.)Changelog
none
Proof
No capture: a daemon storage change behind a default-off setting, with no UI.