Fix three P0 billing-safety bugs from external review - #8
Merged
Conversation
Closes the bugs the external review flagged. Each is a real path to billing-incorrect answers under realistic operating conditions, not just hypothetical edge cases. #1 — Rollup watermark advanced past unflushed data. RollupWorker.tick now respects three safety bounds before moving the watermark: (a) time_target = floor((now - safety_lag) / 1h) * 1h (b) skip the tick entirely if a flush is in flight — detected by wal.active_id > manifest.last_sealed_wal_id + 1, meaning a sealed WAL file hasn't yet been committed to a raw segment (c) cap by floor-of-hour of the oldest event in the memtable — the watermark never crosses unflushed data To prevent the watermark from stalling forever, if the memtable's oldest entry has been pending longer than memtable_max_age_ms (new Config field, default 60s), the worker force-drains the memtable (drain + WAL rotate + send to flusher) and skips this tick. The next tick observes the flushed state. Adds Memtable.oldest_insert_at_ms and Memtable.min_event_timestamp_ms. #2 — Dedupe lost across restart after WAL was sealed. Previously, recovery only rebuilt dedupe from un-sealed WAL files. After one full flush cycle the WAL was deleted, so a restart left the cache empty — a retry of a previously-committed event would be accepted as new and re-billed. Recovery now also walks raw segments whose max timestamp is within the dedupe TTL window (DEFAULT_TTL_MS, 7 days) and re-registers their events in the rebuilt cache. Older segments are skipped — TTL says the upstream pipeline shouldn't be retrying events that stale. #3 — Rollups dropped `source` and `unit`. HourlyRollupKey and HourlyRollupRecord now carry source + unit, so RollupHourly queries that group or filter on either return correct answers. Old rollup segments deserialize with empty strings via #[serde(default)] on the record fields; new segments are populated correctly. Test additions (tests/billing_safety.rs, 7 tests): - rollup_watermark_bounded_by_oldest_memtable_event - rollup_tick_skips_when_flush_is_in_flight - rollup_force_drains_stale_memtable (verifies the FlushMessage actually reaches the channel) - dedupe_rebuilds_from_recent_segments_on_recovery - dedupe_rebuild_skips_segments_older_than_ttl - rollups_preserve_source_and_unit - rollups_group_by_source Each is shaped to fail on the pre-fix code path. Existing 37 tests all still pass; the rollup tests pass `i64::MAX` as memtable_max_age to keep their semantics unchanged. Also adds .gitignore entry for usagedb.tar.gz (review artifact). 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
External code review surfaced three P0 paths to silently-wrong billing answers. This PR fixes all three and adds regression tests shaped so they would fail on the pre-fix code.
P0 #1 — Rollup watermark advanced past unflushed data
Bug.
RollupWorker.tickadvanced the watermark tofloor((now - safety_lag) / 1h) * 1hregardless of whether the events for that hour had actually reached raw segments. With low traffic, the 64 MB memtable threshold isn't hit for hours; meanwhile the rollup worker seals hours and writes empty/partial rollups. The query path's raw fallback only kicks in for timestamps above the watermark, so events stuck in the memtable were silently dropped from rollup queries.Fix. Three new safety bounds in
tick:wal.active_id > manifest.last_sealed_wal_id + 1means a sealed WAL file hasn't been committed to a raw segment yet. Don't advance the watermark — let the flusher catch up.min_event_timestamp_msfloor-of-hour is the upper bound ontarget_hour. The watermark never crosses unflushed data.memtable_max_age_ms(new config, default 60s), the worker drains it + rotates the WAL + queues a flush, then skips this tick. The next tick sees the flushed state. This prevents the watermark from stalling forever in low-traffic deployments.Memtablegainsoldest_insert_at_msandmin_event_timestamp_ms.P0 #2 — Dedupe lost across restart after WAL was sealed
Bug. Recovery rebuilt dedupe only from un-sealed WAL files. After one full flush cycle the WAL is deleted, so a restart left the cache empty — and the compaction worker's cold dedupe only triggers on >16 segments per bucket and silently keeps "first wins" without payload comparison. So a retry of an ack'd event across restart was accepted as new and re-billed.
Fix. After WAL replay, recovery now scans raw segments whose
max_timestamp_msis within the dedupe TTL window (DEFAULT_TTL_MS, 7 days) and re-registers each event in the rebuilt cache. Older segments are skipped — TTL says the upstream pipeline shouldn't be retrying events that stale.P0 #3 — Rollups dropped
sourceandunitBug.
HourlyRollupKeyandHourlyRollupRecordcarried account / sub / product / meter / model / hour / dims but notsourceorunit. The query executor's synthetic rollup event filled both with empty strings, so anyRollupHourlyquery that grouped or filtered on either returned wrong answers silently.Fix. Added
source: SourceIdandunit: Unitto both the key and record.#[serde(default)]on the record fields means old rollup segments deserialize with empty strings — new segments populate them correctly.SourceIdandUnitgainDefaultfor the serde default.Tests
tests/billing_safety.rs— 7 new tests, each shaped to fail on the pre-fix path:rollup_watermark_bounded_by_oldest_memtable_event— fails on pre-fix (watermark would jump past h10 with event still in memtable)rollup_tick_skips_when_flush_is_in_flight— fails on pre-fix (would advance watermark while sealed WAL not yet committed)rollup_force_drains_stale_memtable— verifies the force-flush path also delivers aFlushMessageto the channeldedupe_rebuilds_from_recent_segments_on_recovery— fails on pre-fix (retry after restart accepted as new)dedupe_rebuild_skips_segments_older_than_ttl— confirms the 7-day window boundrollups_preserve_source_and_unit— fails on pre-fix (filter by source ignored)rollups_group_by_source— fails on pre-fix (all rows collapse into one empty-source group)Test plan
cargo build --all-targetsclean with-D warningscargo test --all-targets— 44 tests pass (was 37; +7 intests/billing_safety.rs)Existing tests
The four existing
tests/rollups.rstests passi64::MAXasmemtable_max_age_ms, which preserves the prior "never force-drain" behavior they were written against.Still on the backlog from the same review (P1s)
Manifest::default()SUM(anything) → SUM(quantity),<≡<=, etc.toboundary🤖 Generated with Claude Code