Skip to content

feat: export/import pipeline — artifact codec, per-backend checkpoint, lease, transport, v0.4.0#5

Merged
jaredLunde merged 12 commits into
mainfrom
jared/rocksdb
Jun 11, 2026
Merged

feat: export/import pipeline — artifact codec, per-backend checkpoint, lease, transport, v0.4.0#5
jaredLunde merged 12 commits into
mainfrom
jared/rocksdb

Conversation

@jaredLunde

Copy link
Copy Markdown
Contributor

Summary

  • Artifact codec (SnapshotStore::{cursor,export_to}, MANIFEST.json with per-file BLAKE3 checksums, backend identity/format-generation, and the watch cursor the payload is exactly consistent with). Stage → seal → atomic rename so a present artifact is always complete; import verifies everything before any rename.
  • RocksDB export/import — native Checkpoint API (engine flushes memtable, hardlinks SSTs), verify-by-reopen + cursor-equality gate, hash after verify handle drains background work.
  • Fjall export/import — no checkpoint API, so built from parts: persist(SyncAll) → best-effort quiesce (rotate memtables, drain flushes/compactions, bounded 10s) → hardlink-copy → verify-by-reopen.
  • watch_applied export channelExportRequest lets consumers export a live fold between flushes; artifact cursor is exactly the applied cursor. Breaking: watch_applied gains an exports: Option<mpsc::Receiver<ExportRequest>> parameter.
  • ExportLease — create-only KV key coordinates at-most-one export per round fleet-wide; expired leases stolen via CAS; complete() publishes the cursor as the fleet-visible last-export record.
  • ArtifactTransport / ObjectStoreTransport / run_export_round — plain-tar wire format (<prefix>/<key> + sibling .manifest.json uploaded last as completeness marker); multipart-streaming upload; stage-then-rename download; run_export_round composes lease → export → upload → publish → delete local artifact.
  • Tests — conformance suite for all three backends (round-trip, tampered/missing payload rejection, wrong-backend/schema/cursor rejection); live bootstrap tests (node A folds under churn, node B imports + resumes with delta-only assertions); transport tests against real MinIO (no Docker).
  • CI — now tests --features fjall,rocksdb,transport + default-features + clippy --all-features; MinIO + mc via mise.
  • DocsBACKENDS.md with measured benchmark numbers; ARCHITECTURE.md export/import section and design decisions.

v0.4.0 breaking changes

What changed Migration
SnapshotStore gains required cursor() and export_to() Implement both on custom backends
watch_applied gains exports: Option<mpsc::Receiver<ExportRequest>> Pass None
SnapshotError gains ArtifactInvalid Add arm to exhaustive matches
Inherent FjallSnapshot::cursor() / RocksDbSnapshot::cursor() removed Use trait method

Test plan

  • CI passes all feature matrix jobs (default, fjall,rocksdb,transport, clippy --all-features)
  • Conformance suite passes for all three backends (append-log, fjall, rocksdb)
  • Bootstrap tests: delta-only assertions confirm no overlap/gap on import+resume
  • S3 transport tests: full round-trip including ~25 MiB multipart upload against MinIO

🤖 Generated with Claude Code

jaredLunde and others added 12 commits June 10, 2026 23:11
…s, benches, docs)

Found uncommitted in the working tree before the export/import feature work
began: the snapshot_record codec split, backend benches, nats.rs changes, and
doc updates from the unreleased 0.3.3 rocksdb-backend work. Checkpointed
verbatim so feature commits stay separable. The rocksdb backend module itself
lands with the export/import commit that extends it (the file mixes both).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…append-log export/import

An artifact is a transferable copy of a durable fold: backend files under
data/ plus a MANIFEST.json carrying per-file BLAKE3 checksums, backend
identity + on-disk format generation, and the watch cursor the payload is
exactly consistent with. Export stages, seals (manifest written last,
fsynced), and atomically renames — an artifact that exists is complete.
Import verifies everything (transport is untrusted), opens the staged copy,
gates on cursor equality, and renames — a bad artifact never becomes a fold.

Breaking: SnapshotStore gains required cursor() and export_to();
SnapshotError gains ArtifactInvalid; WatchCursor is now PartialEq + Eq.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Export = rocksdb::checkpoint::Checkpoint into the artifact stage (engine
flushes the memtable, hardlinks SSTs — consistent by construction), then
verify-by-reopen + cursor-equality gate, hash after the verify handle drains
background work, seal, rename. Import does not gate on backend_version:
RocksDB reads older on-disk formats; its own open is the arbiter.

Includes the previously-uncommitted RocksDbSnapshot backend itself (found
in-tree from the unreleased 0.3.3 work); the inherent cursor() accessor is
replaced by the trait method.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
fjall has no checkpoint API, so export is built from parts: persist(SyncAll)
(journal complete; &mut self means nothing commits after), best-effort
quiesce (rotate memtables, drain flushes/compactions, bounded 10s — uses
doc(hidden)-but-pub fjall APIs, compile-checked), copy the DB dir (hardlink
immutable tables/blobs with copy fallback, byte-copy journal+metadata+lock,
retry ×3 on background-GC races), then verify by reopening the copy — fjall's
own recovery is the consistency oracle and cursor equality is a complete
tail-loss detector since every apply commits cursor+data in one batch. Hash
runs only after the verify handle drops. Inherent cursor() replaced by the
trait method.

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

Round-trip (cursor + state equality), import-then-tail-replay equals the
continuous fold, tampered/missing/undeclared payload rejection with nothing
landing at the destination, wrong backend / schema / format-generation /
doctored-cursor rejection, non-empty-destination refusal (artifact survives),
empty-fold round-trip. rocksdb deliberately skips the format-generation gate.

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

watch_applied owns its snapshot store by value, so consumers could never
export a live fold. It now takes an optional mpsc::Receiver<ExportRequest>;
each request is handled between flushes (pending batch flushed first), so the
artifact's embedded cursor is exactly the applied cursor. The export runs on
a blocking task with the store moved in and taken back — the same offload as
the flush path. Export errors reply to the requester only; the watch keeps
running. Dropping the sender disarms the arm.

Breaking: watch_applied gains the exports parameter (pass None).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A single KV key coordinates the checkpoint loop across every replica of a
fold: acquisition is create-only (one winner by construction), the lease
lifetime is an expires_at embedded in the value (portable to any KvWriter —
no per-message TTL, no server-version or bucket-flag requirements), and an
expired/corrupt lease is taken over with a CAS update so the steal race also
has one winner. The ttl IS the round period; complete() publishes the
exported cursor on the key, making it the fleet-visible last-export record.
Clock-skew worst case is a duplicate export — safe, the lease is dedup, not
a correctness gate.

Integration-tested against a real nats-server: 8-way concurrent acquire has
exactly one winner; held lease blocks; expiry allows takeover; completion is
fleet-visible.

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

Wire format: plain tar of the artifact dir (payload files are already
lz4/zstd-compressed) at <prefix>/<key>, manifest duplicated as a sibling
<key>.manifest.json uploaded LAST (the remote completeness marker, and a
cheap cursor peek without downloading the payload). Download cross-checks
the embedded manifest against the sibling; the backend import re-verifies
every file hash regardless — transport is untrusted.

ObjectStoreTransport wraps any object_store backend (S3/GCS/Azure/local-fs;
feature 'transport'), multipart-streams uploads memory-bounded, and unpacks
downloads stage-then-rename so a torn download never lands at the dest.

run_export_round composes the at-most-once round: lease -> export through
the watch_applied channel -> upload -> publish completion (only after the
upload) -> delete the local artifact (transience enforced). Failures abandon
the lease (new LeaseGuard::abandon) so the fleet retries promptly instead of
waiting out the ttl. Per-backend import_remote bootstraps a fold from the
remote artifact in one call.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ons; MinIO tier

tests/bootstrap.rs (fjall + rocksdb): node A folds a real NATS bucket under
churn, exports through the watch loop's request channel, node B imports the
artifact and resumes from the embedded cursor. The load-bearing assertions
are delta-only: B is DELIVERED exactly the post-export tail (counted), the
first delivered revision is cursor+1 (no overlap, no gap), and a delete in
the tail reaches B — convergence alone would mask a full replay.

tests/transport_s3.rs: the same full loop through run_export_round against a
real S3 API — a throwaway MinIO per test (mise-installed binary + mc for
bucket creation, no Docker; same pattern as TestNats) — plus a ~25 MiB
incompressible artifact exercising real multipart upload. tests/common/
holds the shared TestNats/TestMinio guards; mise.toml pins minio (asdf
backend; ubi rejects MinIO's extension-less release assets) and mc (aqua).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…n; v0.4.0

CI now tests --features fjall,rocksdb,transport (previously default features
only, silently skipping the fjall/rocksdb conformance suites on every PR)
plus a default-features pass, and clippy covers --all-features. MinIO + mc
land on the runner via the existing mise-action — no Docker.

ARCHITECTURE.md gains the Export/Import section (artifact anatomy, the
cursor-consistency invariant, per-backend mechanics, storage accounting,
lease + transport), three Design Decisions entries, and Failure Modes rows.

v0.4.0 breaking inventory for downstream bumps:
- SnapshotStore gains required cursor() and export_to()
- watch_applied gains the exports: Option<mpsc::Receiver<ExportRequest>>
  parameter (pass None)
- SnapshotError gains ArtifactInvalid (breaks exhaustive matches)
- inherent FjallSnapshot::cursor()/RocksDbSnapshot::cursor() removed in
  favor of the trait method (returns owned WatchCursor)

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

Replace artifact-era latency claims with measured settled-tree data from
the 500M-route bench (percentiles, not means): cold gets p50 542us/292us
(fjall/rocksdb), absent-key ~400ns via restored bottom-level filters,
settle() costs (fjall ~19min full rewrite + 2x disk transient; rocksdb
~40s drain). BACKENDS.md collects the full cross-scale dataset and the
backend-selection heuristic in one place.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@jaredLunde jaredLunde merged commit 6220280 into main Jun 11, 2026
1 check passed
@jaredLunde jaredLunde deleted the jared/rocksdb branch June 11, 2026 19:57
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant