Skip to content

blob: metadata-page journal with torn-write protection (md-journal v1) - #61

Open
schmidt-scaled wants to merge 10 commits into
R26.3from
md-journal
Open

blob: metadata-page journal with torn-write protection (md-journal v1)#61
schmidt-scaled wants to merge 10 commits into
R26.3from
md-journal

Conversation

@schmidt-scaled

Copy link
Copy Markdown

Blobstore metadata-page journal (md-journal v1)

Adds a write-ahead journal for blobstore metadata pages, closing the torn-write inventory found in the R26.2-PRE audit (root/chain/extent/used_blobids pages written in place, silent used_blobids loss, super block without backup): every md-page write is first appended to a dedicated journal region with per-record CRC framing, so a torn in-place write is always repairable from the journal on the next load. Design doc: blobstore_metadata_journal_design.md.

Commits (bottom-up)

  • 4a88794 — md-page journal core with torn-write protection: append-before-write ordering, CRC-framed records, replay on load.
  • c9003f3 — super-block feature flag (journal is opt-in per lvstore), cb_args lifetime fix, unit tests.
  • e25f183 — fix the read-vs-drain race (a page read concurrent with journal drain could observe the pre-drain state); single-node integration tests.
  • 6f454c5 — overlay the super-block read on load and repair a torn home location from the journal (test I6).
  • a7db57b — port the journal IO path to the fork's bs_dev io-opts vtable.
  • e4ea8da — fix append iov lifetime: ring write iovs must outlive submit.
  • 97ca09c — journal md-range write_zeroes/unmap as zero-page appends, so metadata trims are replayable like writes.
  • 7a5842b — rescan the md journal when a peer takes over the lvstore (HA takeover reads the predecessor's tail).
  • c496967 — stop the journal drain when the node is not the lvstore leader (non-leaders never write back md pages).
  • 0a38843 — record the remaining non-leader append gap (test F3b).

Validation

  • Unit and integration tests included per commit (single-node lifecycle, torn-home repair I6, non-leader gap F3b).
  • 2026-08-06/07 full FTT=2 dual-node-outage churn soak (60 scenarios: graceful/forced/container_kill/host_reboot/65s network partitions × unrelated/P-T/P-S pairs, 6-node lblk cluster on ultra:md-journal-latest + sbcli md-journal-05ed69d6): 60/60 passed, zero fio faults, zero cluster suspensions, ~120 node recoveries clean.

🤖 Generated with Claude Code

michixs and others added 10 commits August 4, 2026 23:25
Implements the 'LVS meta-data (page) journaling with torn-write
protection' specification:

- per-LVS 64 MB journal ring at the highest offset of the backing
  virtual device (actual runtime size, not a 2 PB constant); entry =
  [header block: magic+crc32c+target lba][4K md page]
- every md-region write (super, masks, blobstore/blob md pages) is
  appended to the ring FIFO and acknowledged on journal durability;
  the home write is deferred
- in-memory copy of journaled pages + lba->slot dictionary (spinlock
  vs the drain thread) serves md reads as an overlay - no read
  amplification from the journal
- async drain poller writes pages home from the in-memory copy, then
  zeroes both entry blocks; supersede-coalescing skips home writes
  covered by a newer entry; journal-full stalls appends until a slot
  frees
- recovery on load: whole-ring scan with up to 32 parallel 64 KB
  reads; zeroed/corrupt entries are empty; rebuilds the contiguous
  valid run into buffer/dictionary/pointers and drains in background
- integration via a bs_dev proxy that shrinks the visible device by
  the ring (no data/journal collision) with explicit shims for every
  vtable member; spdk_bs_init zeroes the ring in its format batch,
  spdk_bs_load runs recovery before the first md read

Syntax-checked (gcc -fsyntax-only -Wall); not yet built or tested
against a full SPDK build.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… tests

Build the md-journal branch for real (Rocky/RHEL 9, gcc 11, --enable-debug)
and bring the journal to green under a new CUnit suite.

Fixes found by building and testing:

- cb_args lifetime: the append pump and the drain poller passed
  stack-allocated spdk_bs_dev_cb_args to asynchronous base-dev IO; the
  bs_dev contract requires them to stay valid until completion
  (use-after-return crash under the UT mock dev). They are embedded in
  struct spdk_bs_md_journal now (single append + single drain IO in
  flight).

- md_journal super-block feature flag (design 10): spdk_bs_load wrapped
  every sufficiently large device unconditionally, which would have run
  ring recovery - and later ring writes - over the top 64 MB of legacy
  (journal-less) stores. The super block now carries an md_journal flag
  (carved out of reserved[], set once at format time); spdk_bs_load
  pre-reads page 0 raw and wraps the device only when a valid signature
  carries the flag, otherwise the legacy path is byte-for-byte
  unchanged. A flagged store whose device cannot carry the ring fails
  the load with -EILSEQ instead of loading unprotected.

- spdk_bs_init now arms write/read interception only after the super
  block (with the flag) is home-durable (bs_init_persist_super_cpl):
  format writes go raw, so any store whose init was acknowledged always
  carries the flag on disk; md writes after init completion are
  journaled as before. The flag never changes over a store's lifetime,
  so even a stale or torn-on-drain raw super still routes the load
  through recovery (I1 holds across power-off).

New unit-test suite test/unit/lib/blob/blob_md_journal.c (cases U1-U12
of md_journal_test_plan.md) driving the journal through its proxy
bs_dev against a manual-completion mock dev: append/ack ordering (I1),
read overlay, drain + entry zeroing (I2), supersede-coalescing, ring
full stall (8191 + guard), recovery (basic, torn tail entry, duplicate
LBAs), power-off snapshot simulation, proxy geometry/passthrough,
multi-page FIFO append (I3), destroy with in-flight IO. blob_ut now
compiles the journal in (blobstore.c includes it); suite wired into
test/unit Makefiles and unittest.sh.

Results (c6i.4xlarge, Rocky/RHEL 9): blob_md_journal_ut 12/12 passed
(117837 asserts, valgrind clean), blob_ut 376/376 passed, blob_bdev_ut
8/8, lvol_ut 37/37, vbdev_lvol_ut 23/23; full tree make -j16 clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Single-node integration testing (real spdk_tgt, lvstore on a 4 GiB
file-backed AIO bdev, journal active) hit sporadic blob-md CRC
mismatches on lvol create. Root cause is a read-vs-drain race in the
overlay path: the proxy issued the home read first and looked up the
lba->slot dictionary only on read completion. The drain poller could
complete the home write, zero the ring entry and drop the dictionary
entry while that read was in flight - the completion-time lookup then
missed and the caller was served the pre-drain (stale) page content
the concurrent read had captured. The design's assumption that a
completion-time lookup is safe was wrong.

Fix: snapshot the dictionary hits (page copies, under the lock) at
read ISSUE time and overlay the snapshots on completion. Any version
between issue and completion is a linearizable read result, and a miss
at issue time implies the home write was already durable before the
read was issued. A failed overlay allocation now completes the read
with -ENOMEM instead of falling back to a raw read that could serve
pages the journal has not written home yet. Design doc section 7
updated accordingly.

Unit tests: the mock dev now snapshots read data at issue time
(worst-case device reordering of concurrent IOs) and new case U13
reproduces the race deterministically: append acked, home read issued,
drain overtakes (home write + zero + dict drop), read completes with
stale device data -> the issue-time snapshot must win. 13/13 pass;
blob_ut still 376/376.

New test/md_journal/mdj_integration.py (cases I1-I5 of the plan,
ultra-independent): lvol/snapshot lifecycle under active journal,
4x kill -9 rounds mid create/delete workload with RPC-ack tracking
(every acked object present after recovery, every acked delete stays
deleted, no torn-md load abort), torn-write injection into ring slots
while down, 480 parallel create/delete pairs, and drain-to-zero
verification on the live ring. Two consecutive full-suite passes on
the build host; 'md journal enabled' confirmed on every load; zero md
errors in the target log.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Torn-write states cannot occur naturally on this test rig: kill -9
never tears a write (io_submit'ed IOs complete in-kernel, unsubmitted
ones never start) and AWS Nitro EBS/instance store adds 16 KiB
torn-write prevention on top. All torn states are therefore synthesized
by injection, modeling the 512B-sector-atomic worst case of the distr
virtual device the design targets. Doing that exposed a load-order gap:

spdk_bs_load read the super block BEFORE bs_md_journal_enable armed
interception (the md limit is only known after parsing that very super
block), so the super always came raw from its home LBA. After a crash
the newest super may still sit in the ring - the home copy stale, or
torn mid-drain by the power loss. A torn home super aborted the load
(crc failure) although a valid acked copy sat in the journal; a stale
one would silently roll back acked metadata state.

Fix: recovery completion arms interception for the WHOLE proxy range
(a dictionary miss passes through untouched, so overlaying any LBA is
correct); bs_parse_super then tightens the limit to the md region as
before. Fresh format is unchanged (interception stays off until the
super with the feature flag is home-durable).

Tests:
- unit: recovery path now asserts the wide interception window;
  13/13 pass, blob_ut 376/376.
- integration I6 (new): synthesize bit-exact valid ring entries
  (magic + raw CRC32C) for three blob md pages and the super block,
  tear all four home pages on the raw file (512B-granular, sector 0 of
  the super kept for the load probe), restart: the store must load and
  repair every page byte-exactly from the ring. Passes, along with two
  consecutive full I1-I6 suite runs; test plan updated with the
  representativeness note.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The simplyblock fork (nvmf-blocking-from-base) extends every spdk_bs_dev
IO op with a trailing struct spdk_bs_io_opts * carrying priority class,
geometry id and the special-io flag, and callees dereference it
unconditionally. Ported after rebasing md-journal onto that branch:

- proxy ops forward the caller's bs_io_opts unchanged (md overlay reads
  included), so passthrough IO keeps its routing bits;
- journaled md writes snapshot the caller's opts per ring entry and the
  entry header now persists them (priority/geometry/special_io), so the
  deferred home write - including one replayed by recovery after a
  crash - carries the same routing as the original write;
- journal-internal ring IO (entry writes, recovery reads, zero-on-drain)
  uses default zeroed opts, matching bs-level md sequences in request.c;
- spdk_bs_load's raw super-block probe passes default opts;
- unit-test mock dev and all proxy call sites updated accordingly.

On-disk note: the entry header grows by 4 bytes (within the reserved 4K
header block); pre-port rings are not format-compatible, which is fine
pre-release.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The journal append write passed a stack-allocated iovec[2] to
base->writev. bs_dev consumers may read the iov array long after
submission returns: the distrib bdev queues the request and copies the
payload on its own poller thread, so the dead stack frame turned into a
wild memmove (SIGSEGV in alg_io_split copy_to_buf during
bdev_lvol_create_lvstore on the raid/distrib stack). aio- and
malloc-backed devices consume iovs at submit time, which is why the
spdk-only phase-1 runs never tripped this.

Move the iov pair into spdk_bs_md_journal — there is exactly one append
IO in flight by design (I3), like append_cb_args.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
blob_persist_zero_pages zeroes released md pages (blob delete zeroes the
blob's first page; every persist zeroes the released chain pages) with
write_zeroes on md-region LBAs. The proxy passed those raw to the base
dev, breaking FIFO ordering with journaled writes of the same pages: an
older journaled copy still waiting in the ring would later be drained
home OVER the zeroes, resurrecting the page. Observed on the ultra
distr stack as every acked-deleted lvol reappearing after kill -9 +
reload (mdj integration test, kill round 1).

Route md-region write_zeroes and unmap (page-aligned; unmapped ranges
read back as zeros, so an all-zero page is equivalent) through the
append queue as payload-less all-zero page entries. Non-md ranges and
unaligned ranges still pass through.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Recovery ran only in spdk_bs_load, but the product never loads the lvstore on
failover: the secondary loaded it when the LVS was created or activated, and
takeover is bdev_lvol_update_lvstore + bdev_lvol_set_leader_all (or the
IO-driven spdk_bs_update_on_failover). The ring is shared state on the shared
device while the buffer and dictionary that overlay md reads from it are per
process, so a promoted peer served home pages for everything the dead leader
had acknowledged but not drained, and then appended at its own stale head over
those entries, breaking the contiguous run recovery derives tail/head from.

spdk_bs_update_live now re-runs recovery (bs_md_journal_rescan) before it
re-reads the super block whenever the whole store is reloaded (id == 0), which
covers both promotion paths; the per-blob variant used by bdev_lvol_register
keeps its old cost. The rescan waits for the append/drain pipeline to quiesce,
drops the dictionary and both pointer pairs, re-reads the ring as recovery does
and re-arms the drain poller, so the new leader inherits and drains the dead
leader's backlog.

Also adds the ring introspection and drain test hook the failover suite needs,
bdev_lvol_get_md_journal_stats and bdev_lvol_set_md_journal_drain: the drain
keeps the ring at about one entry under any md rate the blobstore can produce
(six parallel md clients still left zero entries at kill time), so neither
recovery-with-entries nor journal-full is reachable by workload alone.

Verified on the two-instance failover rig (ultra mdj_failover_tests.py):
promotion of a preloaded peer now logs the rescan followed by
"md journal recovery: N entries", and a 915-entry backlog left by a kill -9 is
replayed entry for entry and drained to empty.

Design doc: new section 8.1 for the rescan; section 11.2 records that
single-writer fencing does NOT exist in the data plane (test F3).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The drain poller is a background writer the pre-journal md path did not have:
it keeps pushing this process's in-memory md pages to their home LBAs on the
SHARED device with no IO to trigger it. Nothing stopped it when the node lost
leadership, so a demoted but still living node kept writing metadata behind the
new leader's back.

That is not hypothetical. The product fences by fail-stop - a writer conflict is
detected by the JM/JC layer, which signals the offending node to freeze and
cancel IO and then abort - but the network-outage path
(spdk_lvs_change_leader_state, groupid 0) demotes the LVS and leaves the process
running, and a promotion elsewhere can precede the abort in the conflict case.
In both windows the old leader still reaches the shared device.

spdk_bs_set_leader() now tells the journal, and the drain poller idles while the
node is not the leader; the buffer it was holding is rebuilt from the ring by
bs_md_journal_rescan() if this node is promoted again. Together with that rescan
the lifecycle is symmetric: demote stops writing, promote re-reads the ring
before it resumes.

The new state is reported by bdev_lvol_get_md_journal_stats as drain_demoted so
the failover suite can assert on it (test F3b).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The drain no longer runs on a demoted node, but that node can still append: the
lvol layer gates destroy and async delete on lvs->leader, not create, and it
deliberately allows a sync delete on a non-leader - so "a non-leader never
writes md" is not the fork's model today. Entries such a node appends stay in
its ring until it is promoted and rescans, or the real leader's head marches
over those slots. Deciding this belongs to the lvol layer (refuse md-mutating
work on a non-leader) or to an owner/epoch in the ring; documented rather than
changed unilaterally.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants