Skip to content

test: add 24-hour ShardedPulseMap soak example - #14

Open
VedantMadane wants to merge 1 commit into
ddsha441981:mainfrom
VedantMadane:test/issue-8-soak
Open

test: add 24-hour ShardedPulseMap soak example#14
VedantMadane wants to merge 1 commit into
ddsha441981:mainfrom
VedantMadane:test/issue-8-soak

Conversation

@VedantMadane

Copy link
Copy Markdown

Summary

Adds examples/soak_test.rs — a long-running endurance test for ShardedPulseMap under sustained concurrent load.

What it does

  • Configurable --duration <secs> (default 24 hours; short values for local/CI smoke)
  • 8 writer threads + 4 reader threads
  • ShardedPulseMap<u64, u64> with 4096 buckets/shard and set_ttl(10_000)
  • Stats every 60s (every 1s when duration < 2 minutes)
  • Checks: len() <= capacity(), monotonic eviction_count(), sentinel key integrity, Linux RSS growth ≤ 10%
  • Non-zero exit on failure / worker panic

How to run

\\�ash

Full 24h

cargo run --release --example soak_test --features std

Quick smoke

cargo run --release --example soak_test --features std -- --duration 5

1-hour CI-style run

cargo run --release --example soak_test --features std -- --duration 3600
\\

Test plan

  • cargo build --example soak_test --features std
  • cargo run --example soak_test --features std -- --duration 3 → PASSED (~4.3M ops)

Fixes #8

Add examples/soak_test.rs with configurable --duration (default 24h),
8 writers + 4 readers, TTL churn, periodic stats, sentinel integrity
checks, monotonic eviction_count, capacity bounds, and Linux RSS growth
detection. Short --duration values enable fast local/CI smoke runs.

Fixes ddsha441981#8

Signed-off-by: Vedant Madane <6527493+VedantMadane@users.noreply.github.com>
@ddsha441981

Copy link
Copy Markdown
Owner

Thanks for this — and sorry for the delay, the workflow run was stuck behind the fork-PR approval gate on my side, not on you.

I ran it locally before merging and it works well. Two runs:

10s : 154.5M ops, evictions monotonic, RSS drift +0.0MB, exit 0  -> PASSED
190s: 1.82B  ops, stats at 01:00/02:00/03:00, RSS drift +0.0MB   -> PASSED

I deliberately ran 190s so the 60-second stats path was exercised — a short run takes the < 120s branch and never touches it. Sentinel checks held, no panic, no deadlock, memory completely flat across 1.8 billion operations. That is a good result for the map as much as for the test.

One blocker

cargo fmt --check fails — 6 diffs, all in examples/soak_test.rs:

$ cargo fmt --check
exit=1
6 diffs, all in examples/soak_test.rs

The Rustfmt job in CI will go red on this. cargo fmt and it's done.

Two small things

1. The file has a UTF-8 BOM (efbbbf at offset 0). It's the only file in examples/ with one — I checked the rest. Worth stripping for consistency.

2. The set_fail closure is dead code. It's defined at line 165 and never called; line 173 (let _ = &set_fail;) exists only to silence the unused warning. Meanwhile the same five lines — lock fail_reason, set it if empty, store fail, store stop — are inlined six times below. Either use the closure or drop it; right now it's the worst of both.

Design note — this one is on me, not you

The map saturates almost immediately and stays there:

[00:00:01] len=262144 cap=262144 load=100.0% evictions=4603145   ...
[00:03:10] len=262144 cap=262144 load=100.0% evictions=486091948 ...

At ~15M ops/sec, a TTL of 10,000 insertion epochs is crossed in well under a millisecond. So the test hammers the eviction path hard — which is valuable — but the TTL-expiry / slot-reuse path that #8 was aiming at is effectively not exercised, and load is a constant 100% so it carries no information. Those parameters came from the issue I wrote, so this is mine to fix, not yours. I'll follow up separately on better params.

Also: you set MIN_DURATION_SECS = 1 instead of the 1 hour the issue asked for, and documented why. That's the right call — a 1-hour minimum would make this unusable as a quick local or PR smoke test. Keeping it.

Run cargo fmt and strip the BOM, and I'll merge. I'll approve your workflow run too so you get CI feedback directly instead of waiting on me.

@ddsha441981

ddsha441981 commented Aug 29, 2026

Copy link
Copy Markdown
Owner

Correcting my own "Design note" above — nothing here changes anything for you, all three items I asked for stand as written. This is only about my explanation of why the TTL path isn't covered, which was wrong.

I said:

At ~15M ops/sec, a TTL of 10,000 insertion epochs is crossed in well under a millisecond.

That's off by roughly 60x. Two mistakes: I used the total op rate, but the epoch counter only advances on insert, and in ShardedPulseMap each shard counts its own epochs — which src/sharded.rs:128 documents and I failed to apply. Measured with the same parameters as your test:

epoch(max shard) = 1973605 over 15.0s
                 -> 131570 epochs/s/shard
                 -> TTL window = 76.0 ms

So ~60–76 ms, not sub-millisecond.

The bigger error is what I concluded from it:

Those parameters came from the issue I wrote, so this is mine to fix, not yours. I'll follow up separately on better params.

No parameter choice can fix it, because the path I was worried about does not exist on this type. ConcurrentPulseMap::insert_internal uses find_free_slot():

// src/sync.rs:345
let (target_slot, is_eviction) = if let Some(free) = bucket.meta.find_free_slot() {

whereas PulseMapRaw::insert_internal uses find_free_or_expired():

// src/raw.rs:178
let (target_slot, is_eviction) = if let Some(free) = self.find_free_or_expired(bucket_idx) {

ShardedPulseMap has no insert-time TTL slot reclaim at all — TTL only filters on get/peek. This is a known, documented divergence in my own notes (brain/09-watch-areas.md item 5, brain/05-concurrency.md), which I should have remembered while reviewing.

I instrumented sync.rs to confirm it rather than argue from the code, same parameters as your test, 15s:

insert placements  = 31543891
  free_slot found  =         262144 (0.831%)   <- exactly the initial fill to capacity
  LFU/LRU evict    =       31281747 (99.169%)
    of which the victim was ALREADY EXPIRED = 2751995 (8.80% of evictions)

After the first 262,144 inserts fill every slot, 99.2% of inserts go through LFU/LRU eviction and find_free_slot() never succeeds again. 8.8% of those evictions destroy a slot that had already expired — work a TTL-aware insert path would have gotten for free — and the other 91.2% destroy a live entry, possibly while expired entries sit in other slots of the same bucket.

So: your test does exercise the eviction path extremely hard, which is the valuable part, and my conclusion that the TTL-expiry path goes untested was right. But the reason is structural, not a parameter problem, and it is not something better params would repair. My follow-up will be about that asymmetry, not about tuning your numbers.

One related thing worth flagging for anyone reading the output: eviction_count() means different things in the two implementations. In sync.rs it counts only LFU/LRU evictions; in raw.rs:185 expired-slot reuse increments the same counter. So the evictions=486091948 figure your test prints is not comparable to a PulseMapRaw number.

The rest of my review is unchanged — cargo fmt, strip the BOM, and the set_fail closure, and I'll merge.

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.

Add 24-hour soak test for ConcurrentPulseMap and ShardedPulseMap

2 participants