Skip to content

[Store] OffsetAllocator: survive restarts with configurable persistence#2932

Open
LujhCoconut wants to merge 11 commits into
kvcache-ai:mainfrom
LujhCoconut:feature/pffset-backend-persist
Open

[Store] OffsetAllocator: survive restarts with configurable persistence#2932
LujhCoconut wants to merge 11 commits into
kvcache-ai:mainfrom
LujhCoconut:feature/pffset-backend-persist

Conversation

@LujhCoconut

@LujhCoconut LujhCoconut commented Jul 15, 2026

Copy link
Copy Markdown
Collaborator

Description

Add configurable crash-safe persistence to OffsetAllocatorStorageBackend, allowing cached KV data to survive restarts.

Background

Currently OffsetAllocatorStorageBackend::Init() opens kv_cache.data with O_TRUNC, destroying all cached data on every process restart. While this is acceptable for LLM inference (KV cache is ephemeral — loss only causes cache miss, not correctness errors), some deployments benefit from warm restarts.

This PR adds an optional persistence layer with three configurable modes, defaulting to existing O_TRUNC behavior (zero overhead, zero change for existing users). Also includes graceful-shutdown checkpoint (destructor forces SaveMetadata on clean exit) and eviction-tombstone deduplication (unordered_set + consecutive-failure counter with rate-limited logging).

Persistence Modes

Mode Behavior Recovery Write Overhead Default
kDisabled O_TRUNC on Init, no metadata written Empty Zero
kRelaxed Periodic checkpoint every N seconds (default 60s). datasync() data + atomic-write metadata on each checkpoint. Destructor forces final checkpoint on clean exit. Latest checkpoint Low (data fsync + metadata write only once per interval)
kStrict Every BatchOffload is durable before returning Complete High

Usage / Configuration

All persistence settings are controlled via environment variables. No configuration changes are required for existing deployments — the default kDisabled mode is identical to current behavior.

Enabling persistence (set before starting the storage node):

# Enable periodic checkpoint mode (recommended)
export MOONCAKE_OFFSET_PERSIST_MODE=relaxed

# Optional: change checkpoint interval from default 60s to 120s
export MOONCAKE_OFFSET_PERSIST_INTERVAL_SECONDS=120

# Start the storage node as usual
./mooncake_store_server ...

Full list of persistence environment variables:

Variable Values Default Description
MOONCAKE_OFFSET_PERSIST_MODE disabled, relaxed, strict disabled Persistence strategy
MOONCAKE_OFFSET_PERSIST_INTERVAL_SECONDS integer ≥ 5 60 Checkpoint interval for kRelaxed mode

Use cases:

Scenario Recommended Mode Why
Default / existing deployment disabled (or unset) Zero overhead, behavior unchanged
Warm restart after rolling upgrade relaxed ~84 MB checkpoint every 60s; lost data on crash ≤ 60s; destructor forces final checkpoint on clean exit
Low-latency inference, can't tolerate recomputation after crash strict Every batch is durable; high write overhead — not recommended for production
Development / testing relaxed with persist_interval_seconds=10 Frequent checkpoints for fast iteration

Verifying persistence is working:

# 1. Write data, then check that kv_cache.meta exists
ls -lh /data/file_storage/kv_cache.meta

# 2. Restart the server — recovery log should show:
#    "OffsetAllocatorStorageBackend recovered: N keys, M bytes"

# 3. Check checkpoint health via metrics (if exposed):
#    GetMetadataSaveFailures() == 0  → all checkpoints succeeded
#    GetMetadataLoadFallbacks() == 0 → no recovery fallbacks to fresh start

Rollback safety: if you need to revert to an older binary without persistence support, the old binary ignores kv_cache.meta and kv_cache.meta.tmp files (treats them as unknown on-disk artifacts). Data will be lost on restart (same as pre-PR behavior), but no crash or corruption occurs. To clean up leftover files after rollback, delete kv_cache.meta and kv_cache.meta.tmp from the storage directory.

Monitoring (via public getters on OffsetAllocatorStorageBackend):

Metric Getter Meaning
Save failures GetMetadataSaveFailures() Cumulative count of SaveMetadata failures (disk full, permission errors, etc.)
Load fallbacks GetMetadataLoadFallbacks() Cumulative count of recovery attempts that fell back to fresh start
Consecutive failures (logged at WARNING every 10th) How many consecutive checkpoints have failed — resets to 0 on success
Last save latency (internal last_save_metadata_cost_us_) Microseconds spent in last successful SaveMetadata call

Architecture

On-disk layout: two files per arena

File Contents Format
kv_cache.data Record data (unchanged) [u32 key_len][u32 value_len][key bytes][value bytes]
kv_cache.meta Full allocator state + FIFO index + eviction tombstones struct_pb serialized OffsetAllocatorPersistedMetadata

Write ordering (crash consistency):

  1. data_file_->datasync() — ensure all written records are durable
  2. struct_pb::to_pb(meta) → write to .meta.tmpfsync(tmp)rename(tmp → meta)fsync(parent_dir) — atomic metadata update
  3. complete_handler() — notify master of new keys

Recovery flow (Init()):

  1. Check if kv_cache.meta exists
  2. If yes (and persist_mode != kDisabled): LoadMetadata()deserialize_from<OffsetAllocator>(blob)visit_used_nodes() to walk all used allocator nodes → read each key from data file → rebuild shard maps + total_size_/total_keys_ → restore FIFO index → Phase C delete evicted-but-resurrected keys
  3. If no (or recovery fails): FallbackGuard increments metadata_load_fallbacks_ counter, fall through to fresh-start path (O_TRUNC)

Key RAII guards:

  • DirtyGuard: wraps SaveMetadata() body. On any failure (including std::bad_alloc), restores metadata_dirty_ = true and increments metadata_save_failures_. On success, disarm() keeps dirty=false.
  • FallbackGuard: wraps TryRecoverFromMetadata(). On any return false, increments metadata_load_fallbacks_. On success, disarm().
  • FdGuard: anonymous namespace RAII fd closer, shared by Init(), TryRecoverFromMetadata(), LoadMetadata(), and SaveMetadata()'s directory fsync.

Graceful-shutdown checkpoint: ~OffsetAllocatorStorageBackend() checks persist_mode, initialized_, data_file_, and metadata_dirty_, then forces datasync() + SaveMetadata() + clear() on clean exit (SIGTERM, rolling restart). Wrapped in try/catch(...) — destuctor never throws. Move-ctor/default-copy-delete explicitly declared to avoid implicit suppression.

Eviction-tombstone dedup: all_evicted_this_batch_ is std::unordered_set<std::string> — natural dedup, O(1) lookup for stale-tombstone filtering. metadata_consecutive_failures_ counter logs every 10th consecutive checkpoint failure at WARNING level (using bare LOG(WARNING), not LOG_FIRST_N — the latter counts by call-site, not content).

Tombstone filtering: runs on every persist-enabled batch (not just checkpoint-triggering ones), removing keys that were successfully re-written in the current batch from all_evicted_this_batch_. Prevents stale-tombstone data-loss bug where a re-written key would be deleted on the next checkpoint's recovery.

Corrupt-node recovery: RebuildShardMapsFromAllocator uses free_leaked_node() lambda (constructs temporary OffsetAllocationHandle with requested_size=0 then lets RAII free the node) at every corrupt-record skip branch. Duplicate-key nodes are freed via the normal allocation_ptr RAII path (keep-first, free-duplicate). createHandleAtNode cross-validates that real_offset matches the node's stored dataOffset << multiplier_bits — mismatch returns nullopt for defense-in-depth.

Compatibility

  • Default kDisabled: zero behavior change. All 56 existing tests pass.
  • kRelaxed/kStrict: data files backward-compatible (record format unchanged). Metadata has version field — unrecognized versions trigger fresh-start fallback.
  • FileStorage / MasterService: no changes needed.
  • all_evicted_this_batch_ type change: internal only — no on-disk format impact (serialized via assign(begin,end) to vector field in struct_pb).

Known Limitations (V1)

Limitation Impact Mitigation
Crash between eviction and checkpoint Evicted key may resurrect on restart kRelaxed accepted trade-off; destructor checkpoint reduces window for clean exits; kStrict eliminates it
Full metadata serialization per checkpoint ~84 MB per checkpoint (1M nodes) metadata_dirty_ skips idle checkpoints; future: incremental WAL
Duplicate key on recovery (pinned reader + overwrite) May keep stale version V1 limitation; cumulative tombstone set planned for V2

Post-Merge Optimizations

  • Optimization 1 ✅ Implemented: graceful-shutdown checkpoint (this PR)
  • Optimization 2 ✅ Implemented: unordered_set dedup + consecutive-failure counter (this PR)
  • Optimization 3 (V2): key→offset index in separate kv_cache.index file for fast recovery (no data-file I/O)

Module

  • Mooncake Store (mooncake-store)

Type of Change

  • New feature

How Has This Been Tested?

Test commands:

cd build && cmake --build . --target storage_backend_test
./mooncake-store/tests/storage_backend_test

Test results:

  • Unit tests pass: 56/56 (24 OffsetAllocator tests + 6 eviction-specific)
  • Builds clean with zero warnings
  • Default kDisabled behavior verified unchanged
  • Persistence-specific tests to follow in a separate PR

Checklist

  • I have performed a self-review of my own code
  • I have formatted my code using ./scripts/code_format.sh
  • I have run pre-commit run --all-files and all hooks pass
  • I have updated the documentation
    • Design doc: docs/source/deployment/offset-evict.md
    • Implementation plan: /home/ljh/develop/todo/plan_offset_presist.md (v22)
    • Optimization plan: /home/ljh/develop/todo/opt_persist.md
  • I have added tests to prove my changes are effective (persistence-specific tests to follow)
  • For changes >500 LOC: I have filed an RFC issue

AI Assistance Disclosure

  • AI tools were used

AI tools (Claude Code) assisted with: design iteration (~22 rounds adversarial review), code generation (all 7 files), iterative bug fixing, and optimization design. The human submitter reviewed, understood, and approved all changes.

Add persistence support for OffsetAllocatorStorageBackend:
- Add OffsetPersistMode (disabled/relaxed/strict) and config fields.
- Persist allocator state, FIFO index, and eviction tombstones to
  kv_cache.meta via atomic tmp + fdatasync + rename + dir fsync.
- Implement recovery path in Init(): load metadata, rebuild shard maps
  from allocator used nodes, restore/repair FIFO index.
- Add datasync() to StorageFile/PosixFile/UringFile.
- Add SetDeleteOnWriteFail flag to keep arena file on write failures.
- Add recovery helpers visit_used_nodes / createHandleAtNode to
  OffsetAllocator.
@LujhCoconut LujhCoconut changed the title feat(mooncake-store): persist offset allocator backend state [Store] persist offset allocator backend state (metadata) Jul 15, 2026

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Code Review

This pull request introduces a persistence and recovery mechanism for the OffsetAllocatorStorageBackend. Key additions include data synchronization interfaces, metadata serialization, and recovery routines to rebuild shard maps and restore the FIFO index from persisted checkpoints. The review feedback identifies a critical bug where stale tombstones are not filtered out when checkpointing is skipped, potentially leading to data loss on recovery. Additionally, the reviewer suggests defensive checks to prevent recovering corrupted empty keys and recommends using FdGuard for directory file descriptors to ensure resource safety.

Important

The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.

Comment thread mooncake-store/src/storage_backend.cpp Outdated
Comment thread mooncake-store/src/storage_backend.cpp
Comment thread mooncake-store/src/storage_backend.cpp Outdated
…tence

- Respect delete_on_write_fail_ in PosixFile/UringFile destructors
  so arena files are preserved on write failures when persistence is on.
- Set metadata_dirty_ after eviction loop in BatchOffload so that
  batches with only evictions (or all writes failing) still get checkpointed.
- Keep SetDeleteOnWriteFail(false) conditional on persist mode to
  preserve legacy kDisabled behavior.
@LujhCoconut

Copy link
Copy Markdown
Collaborator Author

/gemini review

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Code Review

This pull request introduces persistence and recovery capabilities to the OffsetAllocatorStorageBackend. It adds support for periodic or strict metadata checkpointing, including serialization of the allocator state and FIFO index, as well as recovery logic to rebuild shard maps from the data file. Additionally, it updates PosixFile and UringFile with datasync operations and options to prevent unlinking files on write failures. The review feedback highlights two important issues: a potential space leak of allocator nodes during recovery when corrupt records are skipped, and a failure in SaveMetadata when using relative paths due to an empty parent path being passed to open.

Important

The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.

Comment thread mooncake-store/src/storage_backend.cpp
Comment thread mooncake-store/src/storage_backend.cpp Outdated

@he-yufeng he-yufeng left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

I focused this pass on the crash-consistency of the persistence path — the part most likely to have a subtle durability bug — and the ordering is correct at every level, which is great to see:

  • Data-before-metadata: data_file_->datasync() runs before SaveMetadata(), so the arena bytes an offset refers to are durable before the metadata that claims that offset is used. That's the ordering that matters for recovery correctness.
  • Atomic metadata swap: SaveMetadata writes to .tmp, fdatasyncs the tmp fd before the rename, renames, then fsyncs the parent directory. The directory fsync is the step that's usually missing — without it a crash right after rename can lose the swap even though the tmp bytes were durable — so it's good to see it here.
  • Fault coverage: the test_metadata_write_failure_step_ injection at the pre-fdatasync (step==2) and post-rename (step==3) points, plus SetDeleteOnWriteFail(false) guarding the data file across the metadata write, means the failure windows are actually exercised rather than assumed.

Defaulting to kDisabled keeps this a no-op for existing deployments, and gating kRelaxed on persist_interval_seconds >= 5 is a sane floor.

I haven't done a full pass on the recovery reconstruction (visit_used_nodes / createHandleAtNode / RebuildShardMapsFromAllocator) or the per-mode BatchOffload semantics, so I'm leaving this as a comment rather than an approval — but the durability discipline is solid.

- Implement destructor-based graceful-shutdown checkpoint.
- Switch all_evicted_this_batch_ to unordered_set for dedup and O(1)
  stale-tombstone filtering.
- Add metadata_consecutive_failures_ counter and test getters.
- Harden SaveMetadata with fsync, full try/catch, and parent-dir
  fallback for relative paths.
- Cross-validate node_index/offset in createHandleAtNode.
- Remove duplicate friend declaration in __Allocator.
@LujhCoconut LujhCoconut changed the title [Store] persist offset allocator backend state (metadata) [Store] OffsetAllocator: survive restarts with configurable persistence Jul 15, 2026
@LujhCoconut

Copy link
Copy Markdown
Collaborator Author

/gemini review

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Code Review

This pull request implements a persistence and recovery mechanism for the OffsetAllocatorStorageBackend. It introduces a datasync interface for storage files, persistence configuration modes, and metadata serialization to enable state recovery upon restart. The review feedback suggests improving the robustness of metadata loading by handling EINTR and short reads in pread, and using fstat instead of lseek to query the data file size safely.

Important

The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.

Comment thread mooncake-store/src/storage_backend.cpp Outdated
Comment thread mooncake-store/src/storage_backend.cpp Outdated
…ize check

- Loop pread() in LoadMetadata to handle EINTR and partial reads.
- Use fstat() instead of lseek() to verify data file size during recovery.
- Use fstat() instead of lseek() to get metadata file size in LoadMetadata.
- Only accumulate eviction tombstones when persist mode is enabled,
  avoiding unnecessary work in kDisabled mode.
@LujhCoconut
LujhCoconut marked this pull request as ready for review July 15, 2026 09:50
Resolved conflicts from upstream changes:
- EvictionHandler typedef (eviction_handler now returns tl::expected)
- EvictAboveDiskWatermark virtual method on StorageBackendInterface
- Disk watermark config fields on FileStorageConfig
- Dead eviction_handler lambda in file_storage.cpp (upstream uses
  NotifyEvictedDiskReplicas inline lambda instead)
- Orphan merge-residue lambdas in storage_backend_test.cpp
@codecov-commenter

codecov-commenter commented Jul 16, 2026

Copy link
Copy Markdown

⚠️ Please install the 'codecov app svg image' to ensure uploads and comments are reliably processed by Codecov.

Codecov Report

❌ Patch coverage is 73.68421% with 300 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
mooncake-store/src/storage_backend.cpp 58.91% 279 Missing ⚠️
...-store/include/offset_allocator/offset_allocator.h 66.66% 10 Missing ⚠️
mooncake-store/src/offset_allocator.cpp 78.26% 5 Missing ⚠️
mooncake-store/src/uring_file.cpp 0.00% 3 Missing ⚠️
mooncake-store/src/posix_file.cpp 71.42% 2 Missing ⚠️
mooncake-store/include/file_interface.h 50.00% 1 Missing ⚠️

📢 Thoughts on this report? Let us know!

@LujhCoconut

Copy link
Copy Markdown
Collaborator Author

Please consider merging #2964 first, and I will rebase afterwards to adapt to its changes, since #2964 has a smaller change surface. This PR is completely orthogonal to #2964 — one addresses runtime correctness, the other addresses
cross-restart data survival.

Comment thread mooncake-store/src/storage_backend.cpp Outdated
…end-persist

Resolve conflicts in mooncake-store/src/storage_backend.cpp against
upstream kvcache-ai#2964 (roll back failed OffsetAllocator evictions):

- Adopt upstream's PendingEviction + NotifyAndCommitPreparedEviction
  flow in BatchOffload: eviction victims stay pinned until the master
  accepts their removal, and failed notifications roll the metadata
  back via RestorePreparedEviction.
- Move eviction tombstone recording from EvictToMakeRoom (victim
  selection time) to commit time via the new RecordEvictionTombstones,
  so rolled-back evictions never leave stale tombstones that Phase C
  would wrongly delete on recovery.
- Take upstream's storage_backend_test.cpp verbatim: the branch's
  changes there were lambda-formatting churn only, and upstream adds
  rollback/post-loop-flush eviction coverage.

Verified: storage_backend_test 72/72 passed.
Fix the previous merge commit's missing RecordEvictionTombstones
declaration in storage_backend.h (broke full builds), plus a round of
crash-consistency fixes for the persistence feature:

- Record format v2: RecordHeader grows 8B -> 20B (seq + CRC-32C).
  Every write is stamped with an insert_seq and a CRC over
  header-prefix + key + value.  Recovery now drops records with
  seq >= checkpoint insert_seq (post-checkpoint / reused extents) and
  rejects CRC mismatches (torn writes) instead of serving stale data.
  Duplicate keys from pinned-overwrite extents resolve by higher seq.
  Persisted metadata version bumped to 2.
- kStrict: move the persistence barrier after the post-loop eviction
  flush so trailing eviction tombstones are checkpointed in-batch.
- Recovery distinguishes transient errors (fd exhaustion, OOM, I/O
  errors -> fail Init, keep data) from corrupt/incompatible metadata
  (fresh start), avoiding cache loss on transient failures.
- Allocator deserialization sanity-checks capacity/multiplier fields
  (corrupt meta no longer risks OOM-kill or shift UB); freeAllocation
  saturates metrics instead of wrapping; leaked nodes are freed with
  their exact requested size when known.
- Reject keys > 1MB at write time (matches the recovery-side cap).
- datasync failures now count into metadata_consecutive_failures_.
- Add 6 persistence/recovery unit tests (roundtrip, destructor
  checkpoint, eviction tombstone, CRC corruption, post-checkpoint
  write rejection, oversized key) -- the feature had none.

Verified: storage_backend_test 78/78, offset_allocator_test 42/42,
full build clean.
…ed value)

RecordHeader grows from 20 to 24 bytes: a new flags field makes the
per-record CRC-32C optional (kFlagHasCrc), and the value region now
starts at a 4 KiB boundary within the record via zero padding derived
purely from key_len.  seq stays mandatory as the checkpoint guard.

- Write/read/recovery/ScanMeta all derive the layout from the shared
  RecordHeader helpers (ValuePadding/ValueOffsetInRecord/RecordSize).
- Recovery skips the value scan for unchecksummed records and drops
  records carrying unknown flag bits (future formats).
- New OffsetAllocatorBackendConfig::enable_record_crc (default true)
  with MOONCAKE_OFFSET_RECORD_CRC env override.
- Persist format version bumped to 3; older metadata is rejected.
- ScanMeta reports key_size from the key itself (total_size now
  includes alignment padding).

This prepares the layout for DMA (GDS) writers whose values never
touch the CPU and therefore cannot be CRC'd on write.

Tests: storage_backend_test 82/82 (4 new), offset_allocator_test
42/42, file_storage_test 23/23.
@github-actions github-actions Bot added the documentation Improvements or additions to documentation label Jul 17, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation run-ci Store

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants