Skip to content

fix(storage): TTL millisecond fidelity — entry expiry stored as ms, fossil base_ts plumbing removed (W3)#398

Merged
pilotspacex-byte merged 1 commit into
mainfrom
refactor/w3-ttl-ms
Jul 20, 2026
Merged

fix(storage): TTL millisecond fidelity — entry expiry stored as ms, fossil base_ts plumbing removed (W3)#398
pilotspacex-byte merged 1 commit into
mainfrom
refactor/w3-ttl-ms

Conversation

@pilotspacex-byte

Copy link
Copy Markdown
Contributor

Summary

Third PR of the storage-unification campaign (stacked on #397#396). Fixes finding F5 (TTL truncation) and finishes F6's fossil-parameter cleanup.

The bug (F5)

CompactEntry stored key expiry as seconds in its u64 TTL field (ms / 1000 floor on every set_expires_at_ms):

  • PEXPIRE key 1500 expired at the 1000 ms boundary — every millisecond-granularity TTL fired up to 999 ms early.
  • PTTL reported the second-truncated deadline, not the one the client set.
  • The RDB round-trip test needed a 5-second tolerance to pass.

The fix

Store absolute Unix milliseconds directly (the u64 was already paid for; 32-byte entry layout and const assert unchanged; 0 stays the no-expiry sentinel). is_expired_at compares in ms; PTTL reads back the exact deadline. Red-first ttl_millisecond_fidelity test pins exact readback + boundary behavior (alive at deadline−1 ms, dead at deadline); the RDB round-trip test now asserts exact ms equality. The old saturating_mul/.max(1) guards became obsolete (no arithmetic on the stored value remains).

On-disk formats untouched — RDB/spill/AOF always carried absolute ms; the truncation happened only at the in-memory Entry boundary.

Fossil base_ts plumbing removed (F6)

The expiry API carried an ignored base_ts: u32 parameter since expiry became absolute. Gone:

  • is_expired_at / expires_at_ms / set_expires_at_ms / new_string_with_expiry params + ~60 dead call-site args + dead let base_ts = … bindings across command/persistence/storage/shard
  • Database::check_expired / is_expired, rdb::write_entry, redis_rdb::write_rdb_entry fossil params
  • Never-read SnapshotState.base_timestamps field (+ ctor params + the per-tick Vec persistence_tick built to fill it)
  • The dead u32 in the AOF-rewrite/BGSAVE snapshot tuple end-to-end: AofFoldSnapshot.dbs, the shard-thread builder, save_from_snapshot / save_snapshot_to_bytes, both BGSAVE builders, the rewrite temp-db loop
  • merge_shard_snapshots (zero callers)

Net −128 lines.

Gates

  • ✅ Red-first: ttl_millisecond_fidelity confirmed RED pre-fix, GREEN post
  • ✅ macOS lib 4422 · Linux VM release-fast lib 4444 (monoio) + 3611 (tokio,jemalloc)
  • crash_matrix_cross_plane 46/46, spill_batch_kill9 2/2, disk_offload_no_aof 1/1 on Linux VM with pinned fresh-ELF MOON_BIN
  • ✅ clippy -D warnings ×2 feature sets · fmt · CHANGELOG entry

@coderabbitai

coderabbitai Bot commented Jul 20, 2026

Copy link
Copy Markdown

Warning

Review limit reached

@TinDang97, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 57 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: c0b4e33b-84fd-4974-a1a3-1c3e128408d8

📥 Commits

Reviewing files that changed from the base of the PR and between b5204f4 and 5b9efaa.

📒 Files selected for processing (21)
  • CHANGELOG.md
  • src/command/key.rs
  • src/command/persistence.rs
  • src/command/string/mod.rs
  • src/command/string/string_bit.rs
  • src/command/string/string_read.rs
  • src/command/string/string_write.rs
  • src/persistence/aof/mod.rs
  • src/persistence/aof/rewrite.rs
  • src/persistence/rdb.rs
  • src/persistence/redis_rdb.rs
  • src/persistence/snapshot.rs
  • src/server/expiration.rs
  • src/shard/dispatch.rs
  • src/shard/persistence_tick.rs
  • src/shard/spsc_handler.rs
  • src/storage/db.rs
  • src/storage/entry.rs
  • src/storage/eviction.rs
  • src/storage/tiered/cold_read.rs
  • src/storage/tiered/kv_spill.rs
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch refactor/w3-ttl-ms

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@qodo-code-review

Copy link
Copy Markdown

Qodo reviews are paused for this user.

Troubleshooting steps vary by plan Learn more →

On a Teams plan?
Reviews resume once this user has a paid seat and their Git account is linked in Qodo.
Link Git account →

Using GitHub Enterprise Server, GitLab Self-Managed, or Bitbucket Data Center?
These require an Enterprise plan - Contact us
Contact us →

…ossil base_ts plumbing removed (W3)

CompactEntry stored key expiry as SECONDS in its u64 ttl field
(`ms / 1000` floor on every set_expires_at_ms). Consequences:

- PEXPIRE key 1500 expired at the 1000 ms boundary — every
  millisecond-granularity TTL fired up to 999 ms EARLY.
- PTTL reported the second-truncated deadline, not the one the client
  set.
- The RDB round-trip test needed a 5-second tolerance to pass.

The u64 was already paid for; this stores absolute Unix milliseconds
in it directly (0 = no expiry, unchanged sentinel; 32-byte entry
layout and const assert unchanged). is_expired_at compares in ms,
expires_at_ms/PTTL read back the exact deadline, and the RDB
round-trip test now asserts exact millisecond equality (red-first:
ttl_millisecond_fidelity pinned exact readback + boundary behavior
at deadline-1 ms and deadline).

Second commit-sized cleanup folded in: the expiry API carried a
fossil base_ts: u32 parameter (ignored since expiry became absolute)
on is_expired_at / expires_at_ms / set_expires_at_ms /
new_string_with_expiry. The parameter is gone, along with ~60 dead
call-site arguments across command/persistence/storage/shard, the
dead `let base_ts = …` bindings that fed them, Database::
check_expired's and is_expired's fossil params, rdb::write_entry's
and redis_rdb::write_rdb_entry's fossil params, and the never-read
SnapshotState.base_timestamps field (plus its constructor params and
the per-tick Vec that persistence_tick built to fill it). The AOF
rewrite snapshot type shrank from Vec<(Vec<(Key, Entry)>, u32)> to
Vec<Vec<(Key, Entry)>> end-to-end (AofFoldSnapshot.dbs, the shard-
thread builder, save_from_snapshot / save_snapshot_to_bytes, both
BGSAVE builders, and the rewrite temp-db loop) — every consumer
ignored the u32. merge_shard_snapshots (zero callers) is deleted.

On-disk formats are untouched: RDB/spill/AOF always carried absolute
milliseconds — the truncation happened only at the in-memory Entry
boundary on load/set.

Gates: lib suite 4422 green (macOS + Linux VM release-fast, monoio +
tokio,jemalloc feature sets); clippy -D warnings on both feature
sets; fmt; crash-matrix + offload suites on Linux VM.

Stacked on refactor/w2-spill-pipeline (PR #397).

author: Tin Dang
@TinDang97
TinDang97 force-pushed the refactor/w3-ttl-ms branch from edbc9e6 to 5b9efaa Compare July 20, 2026 05:36
@pilotspacex-byte
pilotspacex-byte merged commit 29e0ab3 into main Jul 20, 2026
8 checks passed
TinDang97 added a commit that referenced this pull request Jul 20, 2026
…ionRun replace the 13-name try_evict_if_needed* family (W4)

The eviction API had grown 13 public variants that encoded every
optional parameter combination in the function name:

  try_evict_if_needed
  try_evict_if_needed_budget[_reporting]
  try_evict_if_needed_with_spill[_and_total[_budget[_reporting]]]
  try_evict_if_needed_async_spill[_with_total][_budget][_reporting]
  try_evict_if_needed_async_spill_with_total_budget[_reporting]

Five of them already had zero external callers (chain-internal only),
and the two "cores" at the bottom of each forwarding chain duplicated
the same reclaim-loop skeleton five times (sync plain, sync batch,
async plain-fallback, async durable-batch, async AOF-fast-path).

This PR replaces the family with one entry point:

  evict_to_budget(db, config, EvictionRun) -> Result<(), Frame>

where EvictionRun is an options struct built from three constructors
(plain / sync_spill / async_spill = the EvictionSink choice) plus
chainable knobs .total(n) (aggregate accounting), .budget(n) (elastic
per-shard override, capped at instance maxmemory), and .report(f)
(plain-drop DEL emission, task #34). The reclaim loop now exists once
with a per-iteration sink dispatch; the --appendonly durability-
ordering branches (AOF-backstopped fast path vs durable-batch vs
plain-drop fallback) are byte-for-byte the same decisions, now
documented in one place on EvictionSink::AsyncSpill.

Pure refactor — no behavior change:

- Red-first: three new entry-point behavior pins (plain sink honors
  noeviction-OOM + reports every plain-dropped victim; sync-spill sink
  batches 40 victims into shared .mpf files, all cold-readable;
  elastic override respected and clamped to maxmemory) written RED
  against the missing API, then GREEN.
- All ~27 call sites across 9 files migrated (connection handlers,
  SPSC cross-shard gate, Lua bridge, blocking path, memory-pressure
  cascade, eviction tick, db_quota docs); every historical eviction
  pin test migrated and green.
- Stale doc references to the old names scrubbed repo-wide.

Gates: lib suite 4425 green (4422 + 3 new pins) on macOS + Linux VM
release-fast, monoio + tokio,jemalloc feature sets; crash matrix +
kill-9 offload suites on Linux VM; clippy -D warnings on both feature
sets; fmt.

Stacked on refactor/w3-ttl-ms (PR #398).

author: Tin Dang
pilotspacex-byte added a commit that referenced this pull request Jul 20, 2026
…ionRun replace the 13-name try_evict_if_needed* family (W4) (#399)

The eviction API had grown 13 public variants that encoded every
optional parameter combination in the function name:

  try_evict_if_needed
  try_evict_if_needed_budget[_reporting]
  try_evict_if_needed_with_spill[_and_total[_budget[_reporting]]]
  try_evict_if_needed_async_spill[_with_total][_budget][_reporting]
  try_evict_if_needed_async_spill_with_total_budget[_reporting]

Five of them already had zero external callers (chain-internal only),
and the two "cores" at the bottom of each forwarding chain duplicated
the same reclaim-loop skeleton five times (sync plain, sync batch,
async plain-fallback, async durable-batch, async AOF-fast-path).

This PR replaces the family with one entry point:

  evict_to_budget(db, config, EvictionRun) -> Result<(), Frame>

where EvictionRun is an options struct built from three constructors
(plain / sync_spill / async_spill = the EvictionSink choice) plus
chainable knobs .total(n) (aggregate accounting), .budget(n) (elastic
per-shard override, capped at instance maxmemory), and .report(f)
(plain-drop DEL emission, task #34). The reclaim loop now exists once
with a per-iteration sink dispatch; the --appendonly durability-
ordering branches (AOF-backstopped fast path vs durable-batch vs
plain-drop fallback) are byte-for-byte the same decisions, now
documented in one place on EvictionSink::AsyncSpill.

Pure refactor — no behavior change:

- Red-first: three new entry-point behavior pins (plain sink honors
  noeviction-OOM + reports every plain-dropped victim; sync-spill sink
  batches 40 victims into shared .mpf files, all cold-readable;
  elastic override respected and clamped to maxmemory) written RED
  against the missing API, then GREEN.
- All ~27 call sites across 9 files migrated (connection handlers,
  SPSC cross-shard gate, Lua bridge, blocking path, memory-pressure
  cascade, eviction tick, db_quota docs); every historical eviction
  pin test migrated and green.
- Stale doc references to the old names scrubbed repo-wide.

Gates: lib suite 4425 green (4422 + 3 new pins) on macOS + Linux VM
release-fast, monoio + tokio,jemalloc feature sets; crash matrix +
kill-9 offload suites on Linux VM; clippy -D warnings on both feature
sets; fmt.

Stacked on refactor/w3-ttl-ms (PR #398).

author: Tin Dang

Co-authored-by: Tin Dang <tindang.ht97@gmail.com>
@TinDang97
TinDang97 deleted the refactor/w3-ttl-ms branch July 20, 2026 05:38
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.

2 participants