Skip to content

Hourly rollup scheduler + atomic watermark + rollup-aware query path - #4

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

Hourly rollup scheduler + atomic watermark + rollup-aware query path#4
pbudzik merged 1 commit into
mainfrom
feat/rollup-scheduler-watermark

Conversation

@pbudzik

@pbudzik pbudzik commented May 16, 2026

Copy link
Copy Markdown
Owner

Summary

Closes review item #9. Wires the orphaned rollup builder up to a real background scheduler. The headline spec use case — "monthly account usage from rollups" — now actually goes through rollups instead of falling back to a raw scan.

What works now

Scheduler. A tokio background task runs every rollup_tick_interval_secs (30s default). Each tick:

  1. Reads the manifest, computes target = floor((now - safety_lag) / 1h) * 1h
  2. For each hour in [current_watermark, target), scans raw segments that overlap the hour, aggregates events whose timestamp falls inside it (grouped by bucket and dimension canonical JSON), writes one rollup_<uuid>.rseg segment per bucket touched
  3. Atomically appends the new SegmentMeta entries to manifest.rollup_segments and advances manifest.watermarks.hourly_rollup_ms in a single save() — a crash mid-tick leaves the previous watermark in place and the next tick redoes the work, so the operation is idempotent (covered by test)

Query path. RollupHourly queries now scan rollup segments for hours ≤ watermark, then fall back to raw segments + memtable for the open-period tail (timestamps > watermark). SUM(quantity) matches across RawEvents and RollupHourly regardless of how the data is split (covered by test).

Open-period correctness. A separate test sets up two hours of data, ticks the worker with a now that places the watermark between them, and verifies that the rollup-source query sees both: the older hour from rollups, the newer hour from raw fallback. Bug found and fixed during this: collect_raw_events was returning all events in a segment regardless of the caller-supplied time range — fine for RawEvents (the aggregator filters later) but it caused double-counting on the fallback path. Now it filters per-event.

Notable design choices

  • HourlyRollupKey / HourlyRollupRecord drop the per-builder u32 dimensions_key (meaningless across segments) in favor of canonical JSON. Records are now self-describing and cross-segment comparable.
  • RollupSegmentWriter::finish() returns (rows, checksum). Rollup segment metadata carries a blake3 checksum like raw segments, verified on every open.
  • RollupHourly re-derives the bucket per event from account_id rather than trusting the source segment's bucket label, so future compaction that mixes buckets can't poison the rollup.
  • Config gains rollup_tick_interval_secs (default 30) and rollup_safety_lag_ms (default 60_000). The safety lag bounds the open-period duration the query path has to merge across.

COUNT caveat

Each rollup row counts as 1, not as the number of underlying events. This is documented in the executor doc-comment and in the README — callers needing exact event counts should use the RawEvents source. SUM(quantity) is correct in both.

Test plan

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

Still on the backlog

🤖 Generated with Claude Code

Wires the rollup builder up to a tokio background task. Each tick
(default every 30s) reads the manifest, computes a target watermark
of floor((now - safety_lag) / 1h) * 1h, and for each hour in the gap
[current_watermark, target):

  1. Scans raw segments overlapping the hour
  2. Aggregates events whose timestamp falls inside the hour, grouped
     by bucket (account-derived) and dimension canonical-JSON
  3. Writes one rollup segment per bucket touched (rollup_<uuid>.rseg)

The manifest update — appending the new SegmentMeta entries and
advancing watermarks.hourly_rollup_ms — happens in a single save(),
so a crash mid-tick leaves the previous watermark in place and the
next tick re-does the work. Re-tick over an already-rolled hour is a
no-op (covered by test).

Query path now distinguishes RawEvents vs RollupHourly:
- RawEvents: as before, raw segments + memtable
- RollupHourly: rollup segments for hours <= watermark, plus raw scan
  for the open-period tail (timestamps > watermark). SUM(quantity)
  matches across both sources (covered by test). COUNT differs —
  documented as 1-per-rollup-row, not per underlying event.

Other changes:
- HourlyRollupKey/Record drop the per-builder u32 dimensions_key in
  favor of canonical JSON, so records are cross-segment comparable.
- RollupSegmentWriter::finish returns (rows, checksum); meta carries
  the checksum like raw segments now do.
- New RollupSegmentReader verifies the checksum on open.
- Config gains rollup_tick_interval_secs and rollup_safety_lag_ms.
- main.rs spawns the worker with a Notify-based shutdown signal.
- Fix in collect_raw_events: per-event time filter at read time, not
  just segment-level pruning, so the rollup-fallback path doesn't
  double-count events that are already in a rollup segment.

Tests (tests/rollups.rs, 4 tests):
- tick seals completed hours and advances the watermark
- second tick over the same window is a no-op (idempotency)
- query through rollup returns the same SUM as raw scan
- open hour stays visible via raw fallback merged with rollup data

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@pbudzik
pbudzik merged commit 9654b01 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