Skip to content

Phase E (part 1): proptest property tests for spec §19 invariants - #11

Merged
pbudzik merged 1 commit into
mainfrom
feat/proptest-invariants
May 16, 2026
Merged

Phase E (part 1): proptest property tests for spec §19 invariants#11
pbudzik merged 1 commit into
mainfrom
feat/proptest-invariants

Conversation

@pbudzik

@pbudzik pbudzik commented May 16, 2026

Copy link
Copy Markdown
Owner

Summary

Adds randomized property tests for the spec §19 billing-safety invariants. Two external reviews caught real bugs the existing test suite missed — the bugs lived in cross-subsystem interactions, exactly the territory proptest covers. This is the first half of Phase E from the expanded roadmap; deterministic simulation testing (DST) is the larger follow-up.

Properties (7, 32 random cases each = 224 invariant checks per CI run)

Property Invariant
raw_sum_equals_rollup_sum §19.8 — rollup totals reconcile with raw
duplicate_ingest_is_idempotent §19.5 — same-payload retry isn't double-counted
compaction_preserves_sum §19.9 — compaction doesn't change logical results
recovery_preserves_sum §19.1 — flushed events recoverable across restart
recovery_preserves_sum_without_flush §19.1 — unflushed events recovered via WAL replay
rollup_tick_is_idempotent §19.4 — a raw segment is never double-rolled-up
conflict_when_same_id_different_payload §19.6 — payload conflict detected

Bounded input space (acc_a..acc_d, 3 meters, ts within one hour, quantity 1..1000) keeps each case quick — the full suite runs in <1s.

Bug found while writing the tests

recovery_preserves_sum failed on a one-event input: restart was double-counting. Root cause was in the harness, not production code — my force_flush wrote segments + updated raw_segments but skipped the WAL rotation and last_sealed_wal_id advance. After "restart", recovery replayed the still-unsealed WAL into the memtable AND scanned the segment, doubling SUM. The fix mirrors what the production flusher does (rotate + bump watermark + delete sealed files). The shrunk case is committed to tests/properties.proptest-regressions so it re-runs on every CI run.

No production bug, but a clear signal that the harness was about to grow more drift from production — better to catch it now.

Design notes

  • Synchronous harness via tokio::runtime::Builder::new_current_thread().build() + block_on — proptest isn't async-aware, so each case spins up its own runtime.
  • Bypasses the channel + flusher worker (writes segments directly). Loses some realism but gains determinism. The DST follow-up will exercise the full async pipeline.
  • Each case spawns a fresh tempfile::tempdir(); auto-cleaned on harness drop.

Test plan

  • cargo build --all-targets clean with -D warnings
  • cargo test --all-targets — 66 tests pass (was 59; +7 properties)
  • CI green

Next in Phase E

Deterministic simulation testing — a state-machine driver that randomly interleaves record/retry/flush/compact/rollup/crash-before-fsync/crash-after-fsync/corrupt-manifest/restart/query over many steps and asserts every invariant. Much bigger than this PR; will land separately.

🤖 Generated with Claude Code

The two external reviews found bugs the existing test suite didn't
catch. Per-subsystem unit + integration tests cover the happy paths;
the bugs lived in cross-subsystem interactions (memtable × rollup ×
restart, dedupe × WAL × recovery, rollup × source/unit). Randomized
property testing is the systematic answer.

This PR adds tests/properties.rs with 7 properties, each running 32
random cases per CI run (224 total invariant checks):

  - raw_sum_equals_rollup_sum                    (§19.8)
  - duplicate_ingest_is_idempotent              (§19.5)
  - compaction_preserves_sum                    (§19.9)
  - recovery_preserves_sum                      (§19.1)
  - recovery_preserves_sum_without_flush        (§19.1, unflushed path)
  - rollup_tick_is_idempotent                   (§19.4)
  - conflict_when_same_id_different_payload     (§19.6)

Bug caught during writing:

  recovery_preserves_sum failed on a one-event input — restart was
  counting the event twice. Root cause: the harness's `force_flush`
  wrote segments and updated raw_segments but didn't rotate the WAL
  or advance last_sealed_wal_id. After restart, recovery replayed
  the still-active WAL into the memtable AND scanned the segment,
  so SUM doubled. Real production flusher does both; the harness now
  does too. (No production bug — just a regression-prone gap in the
  test harness that would have crept into more tests.)

The harness mimics the production critical section synchronously,
bypassing the channel/flusher worker for determinism. Each property
spawns a fresh tempdir; cases=32 keeps CI quick.

The shrunk failing case from the harness bug is committed in
tests/properties.proptest-regressions so it re-runs on every CI run.

Total tests: 66 (was 59; +7). Clean under RUSTFLAGS=-D warnings.

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