Phase A: manifest generations, rebuildable rollups, correction workflow - #12
Merged
Conversation
Three correctness-layer additions from the expanded roadmap.
Manifest generations. Replaces the single fail-closed manifest.json
with a generation directory:
manifest/
CURRENT (text: latest valid generation u64)
manifest-NNNNNN.json (last 10 retained)
Manifest::save bumps the generation, writes the new file (tmp+rename+
fsync), atomically advances CURRENT, fsyncs the directory, and prunes
old generations beyond KEEP_GENERATIONS. Manifest::load reads CURRENT
and tries that generation; on corruption it walks backwards through
older generations and uses the first one that parses. Logs a warning
about the rollback. Only fails closed when no generation is valid.
Legacy single-file manifest.json is auto-migrated on first load: read,
save as generation 1, delete the old file. Existing DBs upgrade
transparently.
Recovery uses Manifest::load instead of inline serde_json parsing;
none of the prior behavior changes for happy-path startup.
Rebuildable rollups. Adds RollupWorker::rebuild_rollups(from, to):
drops rollup segments overlapping [from, to), rewinds the watermark
to `from`, saves the manifest. The next worker tick refills the gap
from raw segments. Use cases: rollup-builder bug fixes, late events
arriving for a sealed hour, operator drift verification.
Correction event workflow. The infrastructure was already there
(validate_event requires correction_ref for Correction/Retraction;
the rollup builder sums quantities uniformly so negative corrections
naturally subtract). Adds two pieces:
- matches_plan supports `kind` as a filterable field
- group_key_value supports `kind` as a group key
so operators can isolate or break out adjustments in queries. Three
new tests verify: SUM(original + correction) nets correctly, filter
kind=Correction isolates adjustments, group by kind splits them.
11 new tests in tests/phase_a.rs covering each addition. Total now
77 (was 66; +11). Clean under RUSTFLAGS=-D warnings.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Three correctness-layer additions from the expanded roadmap. After this, items 1–4 of Phase A from the external reviewer's framing are closed (persistent dedupe index deferred to a perf-focused PR — current recovery scan is asymptotically the same as on-disk lookup at MVP scale).
Changes
Manifest generations
Replaces the single
manifest.jsonwith a generation directory:Manifest::savebumps the generation, writes the new file via tmp+rename+fsync, atomically advancesCURRENT, fsyncs the directory, and prunes old generations beyondKEEP_GENERATIONS = 10.Manifest::loadreadsCURRENTand tries that generation; on corruption it walks backwards until it finds one that parses, logs a warning, and uses it. Fails closed only if no generation is valid.manifest.jsonis auto-migrated on first load: read → save as generation 1 → delete the old file. Existing DBs upgrade transparently.Recovery::run_startup_recoveryusesManifest::loadinstead of inline serde_json — happy-path behavior unchanged.This is what the external reviewer asked for under "Strict manifest generations — Corrupt manifest must not start empty." The previous code did fail closed, but had no recovery path; now operators can roll forward through one bad write without intervention.
Rebuildable rollups
RollupWorker::rebuild_rollups(from_ms, to_ms)drops rollup segments overlapping the range, rewindsmanifest.watermarks.hourly_rollup_mstofrom_ms, and saves the manifest. The next worker tick refills the gap from raw events.Use cases: rollup-builder bug fixes, late events arriving for a sealed hour, operator drift verification. Surface for a future admin CLI to call.
Correction event workflow
The infrastructure was already there:
validate_eventrequirescorrection_refforCorrection/Retraction(shipped in P1 bundle)What this PR adds:
matches_plansupportskindas a filterable fieldgroup_key_valuesupportskindas a group keySo operators can isolate or break out adjustments in forensic queries. The rollup key intentionally doesn't include
kind— that would split corrections into separate rollup rows and break the "net total" semantics the billing layer wants.Tests
11 new tests in
tests/phase_a.rs:Manifest generations:
save_creates_generation_files— CURRENT advances, files are numberedload_rolls_back_through_corrupt_generations— corrupt gen 3, loader uses gen 2load_fails_closed_when_no_generation_is_valid— every gen corrupt → InvalidDataload_migrates_legacy_manifest— old single-file format → gen 1, old file removedload_returns_none_for_fresh_db— no manifest at all → Ok(None)old_generations_get_pruned— 12 saves → 10 files remainrecovery_picks_up_generations— round-trip through RecoveryRebuildable rollups:
rebuild_rollups_drops_segments_and_rewinds_watermark— full cycle: tick → rebuild → tick → restoredCorrection workflow:
correction_event_subtracts_from_sum— 100 + (-40) → SUM = 60query_filter_by_kind_isolates_corrections— filterkind=Correction→ only -40group_by_kind_splits_originals_from_corrections— Usage=100, Correction=-40Test plan
cargo build --all-targetsclean with-D warningscargo test --all-targets— 77 tests pass (was 66; +11)Still on the Phase A backlog
event_id_hash) — pure perf at MVP scale, deferred to Phase B where it lives alongside other segment-format work.Next
After this lands, the natural next slice is Phase B (per-column encodings — dictionary for IDs, delta for timestamps, zigzag-varint for quantities) or DST (deterministic simulation testing).
🤖 Generated with Claude Code