Skip to content

Background compaction scheduler with reader-safe deferred deletion - #5

Merged
pbudzik merged 1 commit into
mainfrom
feat/compaction-scheduler
May 16, 2026
Merged

Background compaction scheduler with reader-safe deferred deletion#5
pbudzik merged 1 commit into
mainfrom
feat/compaction-scheduler

Conversation

@pbudzik

@pbudzik pbudzik commented May 16, 2026

Copy link
Copy Markdown
Owner

Summary

Closes the compaction-scheduler gap. The compaction engine has existed since the durability PR but wasn't reached by any background task. This PR adds the scheduler, makes the manifest swap atomic, and implements a reader-safe deferred-deletion grace window (spec §15.3) so queries that snapshotted the manifest just before a compaction commit don't race with file unlinks.

Each tick (default 60s)

  1. Sweep pending deletions. For every compacted_replacements entry older than compaction_grace_ms (default 30s), delete the old segment files and drop the record from the manifest.
  2. Plan + execute. Per-bucket plans (planner triggers when a bucket has > max_small_segments segments) are merged: sort by (account, product, meter, model, ts), cold-dedupe by event_id, write a single output segment. Manifest swap is atomic — old metas dropped from raw_segments, new meta added, ReplacementRecord appended, save.

The plan is defensively aborted if the input set isn't fully present in raw_segments at commit time. Not reachable today (one worker), but it future-proofs against concurrent compaction.

Key design choices

  • ReplacementRecord.committed_at_ms is the deferred-delete clock. #[serde(default)] means existing manifests read back as 0; pre-existing records (none, currently) never get finalized by the new code, matching the prior "leave alone forever" behavior.
  • CompactionPlanner returns Vec<CompactionPlan> with an explicit bucket field per plan instead of an untagged Vec<Vec<String>>. The bucket flows through to build_segment_meta so the compacted output keeps its label.
  • Config gains compaction_tick_interval_secs, compaction_grace_ms, compaction_max_small_segments.
  • Shutdown: same Notify pattern as the rollup worker.

Test plan

  • cargo build --all-targets clean with -D warnings
  • cargo test --all-targets — 32 tests pass (was 27; +5 in tests/compaction.rs)
  • CI green

New tests:

  • Per-bucket plans trigger only when the bucket exceeds the threshold
  • The compacted segment contains every input event (no loss, exact event_id dedup)
  • Re-tick post-compaction is a no-op (no extra segments, no premature deletion)
  • Old files survive the grace window, then disappear on the next post-grace tick; replacement record drops out of the manifest
  • Independent buckets are processed in one tick

Storage layer status

With this PR, the storage path is end-to-end complete:

ingest → WAL → memtable → raw segment → rollup segment + compaction
                                                          ↓
                                                  deferred file deletion

Still on the backlog

These are smaller items suitable for a single "polish" PR.

🤖 Generated with Claude Code

Wires the compaction engine up to a tokio background task and applies
ReplacementRecord atomically to the manifest. Old segment files are
not deleted immediately — instead, each replacement is timestamped
(committed_at_ms) and a future tick past the grace window cleans them
up. This protects queries that snapshotted the manifest just before a
compaction commit from races where the file gets unlinked mid-read.

Per tick (default every 60s):
  1. Sweep pending deletions. For every compacted_replacements entry
     older than `compaction_grace_ms` (default 30s), delete the old
     segment files and drop the record from the manifest.
  2. Plan + execute. Per-bucket plans group small segments together;
     each plan is merged (sort + cold-dedupe by event_id) into a
     single output. Manifest swap is atomic: old metas dropped from
     raw_segments, new meta added, replacement record appended, save.

Plan is aborted defensively if the input set is no longer fully in
raw_segments at commit time (future-proofing against concurrent
compaction workers; not possible today with single-worker setup).

Other changes:
- CompactionPlanner returns Vec<CompactionPlan> with explicit bucket
  per plan, instead of an untagged Vec<Vec<String>>. The bucket flows
  to build_segment_meta so the output keeps its label.
- ReplacementRecord gains committed_at_ms (#[serde(default)] so it
  reads back from existing manifests as 0 — those records never get
  finalized by the new code, which matches the pre-existing behavior
  of "leave alone forever").
- Config gains compaction_tick_interval_secs, compaction_grace_ms,
  compaction_max_small_segments.
- main.rs spawns the worker with Notify-based shutdown.

Tests (tests/compaction.rs, 5 tests):
- Per-bucket plans only trigger when the bucket exceeds the threshold
- Single output segment receives every event from the inputs (no loss
  + event_id cold-dedupe)
- Re-tick post-compaction is a no-op (no extra segments written, no
  deletions until grace expires)
- Old files survive the grace window, then disappear on the next
  post-grace tick; replacement record is removed from the manifest
- Buckets are processed independently in one tick

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@pbudzik
pbudzik merged commit 055b0dc into main May 16, 2026
1 check passed
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