Skip to content

perf(store): reserve the prekey map for a batch insert - #1270

Merged
jlucaso1 merged 4 commits into
mainfrom
claude/prekeys-batch-hashmap-reserve-co85yl
Aug 10, 2026
Merged

perf(store): reserve the prekey map for a batch insert#1270
jlucaso1 merged 4 commits into
mainfrom
claude/prekeys-batch-hashmap-reserve-co85yl

Conversation

@jlucaso1

@jlucaso1 jlucaso1 commented Aug 10, 2026

Copy link
Copy Markdown
Collaborator

Summary

InMemoryBackend::store_prekeys_batch takes keys: &[(u32, Bytes)] and inserts them one at a time into state.prekeys: HashMap<u32, PreKeyEntry> with no reservation. A connect-sized batch therefore crosses hashbrown's 7/8 load factor eight times on the way in — each crossing allocates a table twice the size, rehashes and moves everything already inserted, and frees the old one. The batch length is known at the call site, so the table can reach its final size in one allocation instead.

The number that motivated this is not the number this fixes, and that is the main thing this PR wants on the record — see ## Cost.

Changes

  • wacore/src/store/in_memory.rs: reserve before the insert loop, but only for rows that provably must be added:

    let ascending = keys.windows(2).all(|pair| pair[0].0 < pair[1].0);
    if ascending {
        let at_least_new = keys.len().saturating_sub(state.prekeys.len());
        state.prekeys.reserve(at_least_new);
    }

    A table grown for rows that never arrive does not shrink back, so a naive reserve(keys.len()) would cost exactly the retained bytes this PR claims not to touch. Two one-sided guards prevent that, and each closes a different hole:

    • Subtract the stored rows. The trait permits a batch to overwrite ids, and no batch can overwrite more rows than exist, so keys.len() - len() is the floor on how many ids must be new. Without it, replaying a stored 812-key window would take the map from 896 to 1792 capacity and keep the extra ~42 KiB forever.
    • Require strictly ascending ids, which is a sufficient condition for distinctness. Without it, 812 entries sharing one id would reserve a 1024-bucket table to hold a single row. The check is one pass of integer compares and no allocation; deduplicating properly would need a set, whose own allocation plus 812 hashes costs more than the eight allocations being saved.

    Both can only skip or shrink a reservation and fall back to incremental growth — never inflate the resident table — so the guarantee holds for any caller, not just well-behaved ones. The connect path satisfies both: the map is empty and upload_pre_keys_pass emits gen_start + i from the monotonic NEXT_PK_ID counter, so the whole batch is reserved and gets the full win.

  • wacore/benches/prekey_store_benchmark.rs (new, registered in wacore/Cargo.toml): a divan::AllocProfiler bench over the path at 64 and 812 keys, plus a populated row that writes a second full batch into a map that already holds a window. It lands in the existing CodSpeed core shard, which runs both the simulation and memory instruments.

  • wacore/src/store/in_memory.rs tests: store_prekeys_batch_stores_every_key, store_prekeys_batch_reserve_does_not_over_grow_the_table, replaying_a_stored_batch_does_not_grow_the_table, and a_batch_of_repeated_ids_does_not_reserve_for_them.

  • agent_docs/observability.md: the prekey-window ledger entry gains the peak-vs-retained clarification below, so the next reader of a dhat profile does not re-derive it.

No signature, semantics, map type, or hash strategy changed. store_prekeys_batch stays idempotent per id.

Cost

Batch size, from the source rather than assumed. upload_pre_keys_pass calls store_prekeys_batch(&encoded_batch) with exactly plan.gen_count entries (src/prekeys.rs:572). plan.gen_count comes from plan_prekey_upload(first_unupload, next, max_id, wanted), where wanted is DEFAULT_WANTED_PRE_KEY_COUNT = 812 (src/prekeys.rs:25, mirroring WA Web's UPLOAD_KEYS_COUNT in WAWebUploadPreKeysJob), clamped into [5, 65535]. gen_count = wanted - available, and available counts only leftover generated-but-unuploaded window keys. On a fresh connect available == 0 and the plan is gen_count == 812 — pinned by the existing planner tests (src/prekeys.rs:1011, :1021, :1053, :1067). The batch is genuinely hundreds, not dozens. The one path that writes fewer is the retry-receipt allocation, and that calls single-key store_prekey, not the batch.

Allocations and bytes, divan::AllocProfiler, cargo bench -p wacore --bench prekey_store_benchmark, fresh backend per iteration, 100 samples:

batch allocs alloc bytes deallocs dealloc bytes max live in call
812 before 11 84.08 KB 9 42.01 KB 63.07 KB
812 after 3 42.13 KB 1 64 B 42.13 KB
64 before 8 10.56 KB 6 5.23 KB 7.97 KB
64 after 3 5.43 KB 1 64 B 5.40 KB
812 into a populated map before 2 84.04 KB 2 42.06 KB 84.04 KB
812 into a populated map after 2 84.04 KB 2 42.06 KB 84.04 KB

The populated row is byte-identical on both sides: a second window crosses the load factor exactly once either way, so reserving on an already-populated map neither helps nor over-grows. The 812 row was re-measured after each of the two guards was added and did not move.

Peak against retained — explicitly. Measured with a tracking GlobalAlloc that records live bytes on every alloc/dealloc, snapshotted after the call returns with the backend still alive, batch of 812:

before after
total bytes allocated during the call 84,084 42,136
high-water live bytes during the call 63,072 42,136
live bytes retained after it returns 42,072 42,072

Retained is bit-identical. It has to be: 812 rows need 1024 buckets on both paths, and 1024 × (size_of::<(u32, PreKeyEntry)>() = 40 + 1 control byte) = 41,984 B is the table that stays. So the 41.0 KiB/session that dhat attributed to hashbrown::RawTable::reserve_rehash under this function is the final resident table, not rehash churn — 41,984 B is that figure to three digits. dhat names the frame that allocated the block, and the frame that allocates the last table is reserve_rehash; the intermediate tables are all freed before the block that survives to the global peak. This change moves that allocation from reserve_rehash to reserve and does not shrink it by one byte. A profile diff taken after this lands will still show 41.0 KiB there.

What the change actually removes, per connect, per session: 8 allocations, 8 frees, ~42 KB of transient allocator traffic, ~890 element moves with a rehash each, and 20.9 KB of in-call transient high-water. Whether that 20.9 KB shows up in process RSS depends on whether sessions open concurrently; the external profile it came from reported exactly 41.0 KiB/session with no intermediate contribution, which is what sequential opening looks like — so the honest expectation is that RSS at the peak barely moves and the win is allocator work, not memory. Reported as such rather than as retention.

Time is secondary and reported with its methodology: divan wall-clock, --release, single unloaded container, 100 samples, median 48.4 µs → 26.0 µs at 812 (fastest 44.0 → 25.3 µs). Consistent with dropping ~890 rehash moves, but a single-box wall-clock number on a shared runner; the instruction-count instrument in CodSpeed is the one to trust.

Checked and not changed

  • storages/sqlite-storage/src/sqlite_store.rs has the analog, and it is worse. store_prekeys_batch there is not a transaction per key — the 812 inserts do share one conn.transaction — but inside it every row is its own insert_into(...).on_conflict(...).execute(conn), i.e. 812 round trips through Diesel where a multi-row VALUES insert (chunked under SQLite's variable limit) would do. On top of that the batch Vec is copied twice before any SQL runs: keys.to_vec() once per call, then keys_clone = keys.clone() once per retry attempt, which at 812 × size_of::<(u32, Bytes)>() = 40 B is 32.5 KB per copy — more than the whole in-memory map's table. Measured: one 812-key call against a shared-cache in-memory SQLite takes 8.0 ms release / 26.2 ms debug. Left alone per this batch's scope; worth a separate batch, which now has a number to beat.
  • store_prekeys_batch is the only bulk populator of state.prekeys. The map has exactly two writers in in_memory.rs: this one and single-key store_prekey. store_prekey's only non-test callers are the retry-receipt path (src/prekeys.rs:403, one key) and the libsignal adapter (src/store/signal_adapter.rs:432, one key) — neither loops. The default store_prekeys_batch in store/traits.rs does fan out to store_prekey, but InMemoryBackend overrides it, so that fallback is not a path into this map.
  • Hash strategy and map type untouched. Swapping RandomState would change these numbers too and is a different argument with a different risk; not attempted here.

Validation

  • cargo fmt --all --check
  • cargo clippy -p wacore --all-targets -- -D warnings — clean locally. The full cargo clippy --workspace --all-targets could not run in the dev container: alsa-sys's build script fails for want of libasound/pkg-config, pulled in by examples/voip-cli (cpal). Confirmed environmental by reproducing it on a stashed, clean tree — and CI's Build & Lint (all features) covers it and passes.
  • cargo test (workspace default members, includes doctests) — 55 test binaries, 0 failures. All four new tests pass.
  • cargo bench -p wacore --bench prekey_store_benchmark before and after, numbers above.
  • CI is green on 4dfd8b5: Build & Test, Clippy, Format, Test Stable (MSRV), Feature Matrix, Build & Lint (all features), Rustdoc, E2E, all three Miri jobs, wasm32, Cargo Deny, Binary Size, and all four CodSpeed jobs.
  • The one red check, Semver Checks (informational), is pre-existing and unrelated: continue-on-error: true by design ("Advisory only. The workspace is pre-1.0 and intentionally breaks API between minors"), and every failure it lists is in mex_operations.rs, the removed simd feature, or BinaryError::UnexpectedFormatByte. This PR adds no public API surface. The unstable mergeable state is that check alone.

`store_prekeys_batch` inserted a connect-sized batch one key at a time
into a map with no reservation, so the table crossed the load factor
eight times on the way to holding it: eight allocations, eight copies of
everything already inserted, and eight frees. The batch length is known
at the call, and every id in it is new (prekey ids come from the
monotonic NEXT_PK_ID counter), so one `reserve` reaches the final size
in a single allocation.

Measured on the connect batch of 812 (`DEFAULT_WANTED_PRE_KEY_COUNT`):
11 allocations / 84.08 KB become 3 / 42.13 KB, and the in-call transient
high-water falls from 63.07 KB to 42.13 KB. Retained is unchanged at
42,072 B — the final table is the same size either way — so this buys
allocator traffic, not residency. The observability ledger gains that
distinction, since the profiler attributes the retained table to
`reserve_rehash` and makes it look like churn.

Adds a divan `AllocProfiler` bench for the path and two tests: one that
a batch round-trips and stays idempotent per id, one that the reserved
table matches an incrementally grown one so the reservation cannot
over-grow a map that already holds a window.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A5Vcfvdd5n2PX2Z9M8Ympc
@coderabbitai

coderabbitai Bot commented Aug 10, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Summary by CodeRabbit

  • Performance

    • Improved bulk prekey storage by reserving capacity before inserts, reducing temporary allocation overhead.
    • Preserved existing memory usage while improving handling of large batches and repeated uploads.
  • Tests

    • Added coverage for large-batch persistence, per-ID overwrites, and repeated batch reservations.
    • Added benchmarks for small, large, and populated prekey stores.
  • Documentation

    • Clarified memory allocation and peak usage behavior for prekey storage.

Walkthrough

store_prekeys_batch now reserves map capacity before insertion. Regression tests cover persistence, overwrites, and capacity. A Divan benchmark measures allocations for empty and populated stores, while observability documentation explains retained and transient memory.

Changes

Prekey batch storage

Layer / File(s) Summary
Batch reservation and storage tests
wacore/src/store/in_memory.rs
store_prekeys_batch reserves capacity before insertion. Tests validate large-batch persistence, overwrite behavior, duplicate handling, and capacity across empty and populated maps.
Allocation benchmark and observability
wacore/benches/prekey_store_benchmark.rs, wacore/Cargo.toml, agent_docs/observability.md
A non-default Divan benchmark measures 64- and 812-prekey batches, including populated-store uploads. Documentation distinguishes retained table storage from temporary rehash allocations.

Estimated code review effort: 2 (Simple) | ~15 minutes

Possibly related PRs

Suggested labels: performance

Suggested reviewers: cubic-dev-ai, greptile-apps

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Title check ✅ Passed The title clearly summarizes the main change: reserving prekey map capacity before batch insertion.
Description check ✅ Passed The description directly explains the reservation change, benchmarks, tests, memory impact, and validation results.
✨ Finishing Touches 💡 1
🛠️ Fix failing CI checks 💡
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch claude/prekeys-batch-hashmap-reserve-co85yl

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.

@greptile-apps

greptile-apps Bot commented Aug 10, 2026

Copy link
Copy Markdown

Greptile Summary

The PR optimizes in-memory prekey batch insertion by conservatively reserving map capacity without increasing retained capacity.

  • Reserves capacity for the minimum number of necessarily new, distinct prekey IDs.
  • Adds allocation benchmarks for empty and populated maps.
  • Adds regression coverage for overwrites, replayed batches, repeated IDs, and final table capacity.
  • Documents the distinction between transient allocation traffic and retained prekey-map memory.

Confidence Score: 5/5

The PR appears safe to merge.

No blocking failure remains.

Important Files Changed

Filename Overview
wacore/src/store/in_memory.rs Adds a conservative reservation before prekey batch insertion and comprehensive capacity and storage regression tests.
wacore/benches/prekey_store_benchmark.rs Adds allocation benchmarks for fresh and already-populated in-memory prekey maps.
wacore/Cargo.toml Registers the new prekey-store benchmark target.
agent_docs/observability.md Clarifies that the optimization reduces transient allocation work rather than retained map memory.

Reviews (3): Last reviewed commit: "perf(store): reserve for a prekey batch ..." | Re-trigger Greptile

Comment thread wacore/src/store/in_memory.rs Outdated
bencher
.with_inputs(|| (InMemoryBackend::new(), batch(1, count)))
.bench_refs(|(backend, keys)| {
block_on(backend.store_prekeys_batch(keys, false)).unwrap();

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Benchmark uses prohibited unwraps

The new benchmark calls .unwrap() here and again on lines 66 and 71, contrary to the repository-wide prohibition on .unwrap() outside tests. These calls also make benchmark failures panic without contextual error propagation.

Context Used: CLAUDE.md (source)

Prompt To Fix With AI
This is a comment left during a code review.
Path: wacore/benches/prekey_store_benchmark.rs
Line: 54

Comment:
**Benchmark uses prohibited unwraps**

The new benchmark calls `.unwrap()` here and again on lines 66 and 71, contrary to the repository-wide prohibition on `.unwrap()` outside tests. These calls also make benchmark failures panic without contextual error propagation.

**Context Used:** CLAUDE.md ([source](https://github.com/oxidezap/whatsapp-rust/blob/main/CLAUDE.md))

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Fix in Claude Code

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: fa04c479c8

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread wacore/src/store/in_memory.rs Outdated
// This does NOT shrink the table that stays resident: the final
// capacity is the same either way, so it buys allocator traffic and
// in-call headroom, not retained bytes.
state.prekeys.reserve(keys.len());

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Reserve only for prekey IDs absent from the map

When a caller replays or rewrites an existing batch, HashMap::reserve(keys.len()) assumes every entry will increase len, although the public SignalStore::store_prekeys_batch contract permits overwrites and the new idempotence test exercises one. Replaying an 812-key window therefore grows a map from capacity 896 to 1792 before replacing the same rows, permanently retaining roughly another 42 KiB; reserve only for distinct IDs not already present, or otherwise enforce the all-new invariant at the API boundary.

Useful? React with 👍 / 👎.

@github-actions

github-actions Bot commented Aug 10, 2026

Copy link
Copy Markdown

📦 Binary size report

Metric main PR Δ
bin size (stripped) 10.04 MiB 10.04 MiB -128 B (-0.00%) 🔽
bin .text 8.05 MiB 8.05 MiB -128 B (-0.00%) 🔽
bin allocated (text+data+bss) 10.04 MiB 10.04 MiB 0
llvm-lines wacore 533,462 533,670 +208 (+0.04%) 🔺
llvm-lines wacore copies 17,415 17,422 +7 (+0.04%) 🔺
llvm-lines whatsapp-rust lib 761,982 761,885 -97 (-0.01%) 🔽
llvm-lines whatsapp-rust lib copies 23,767 23,762 -5 (-0.02%) 🔽
deps crates (Cargo.lock) 462 462 0
.text per crate
Crate main PR Δ
.text whatsapp_rust 1.84 MiB 1.84 MiB -128 B (-0.01%) 🔽
.text wacore 693.00 KiB 692.69 KiB -312 B (-0.04%) 🔽
.text wacore_binary 81.61 KiB 81.61 KiB 0
.text wacore_libsignal 178.98 KiB 178.98 KiB 0
.text wacore_appstate 22.35 KiB 22.35 KiB 0
.text wacore_noise 20.94 KiB 20.94 KiB 0
.text waproto 1.74 MiB 1.74 MiB 0
.text whatsapp_rust_sqlite_storage 540.56 KiB 540.56 KiB 0
.text whatsapp_rust_tokio_transport 40.49 KiB 40.49 KiB 0
.text whatsapp_rust_ureq_http_client 12.68 KiB 12.68 KiB 0
.text std 995.63 KiB 995.63 KiB 0
.text other deps 1.90 MiB 1.90 MiB +312 B (+0.02%) 🔺

Baseline: 5428d33f1 (latest main run) · Head: caa65e7e3 · Graphs

The reservation took the bare batch length, which assumes every id is
new. The trait permits a batch to overwrite ids, and a table grown for
rows that were only overwritten never shrinks back — so replaying a
stored 812-key window would have taken the map from 896 to 1792 capacity
and kept the extra table forever. That is precisely the residency this
change claims not to touch.

Reserve `keys.len() - prekeys.len()` instead: no batch can overwrite more
rows than exist, so that difference is the floor on how many ids must be
new. It can only under-reserve and fall back to incremental growth, never
inflate the resident table.

The connect path is unaffected — the map is empty there, so the whole
batch is still reserved: 3 allocations / 42.13 KB, dealloc 1 / 64 B, the
same figures as before. A replay now reserves nothing and leaves the
table exactly as it found it, which `replaying_a_stored_batch_does_not_
grow_the_table` pins.

Reported independently by Greptile and Codex on #1270.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A5Vcfvdd5n2PX2Z9M8Ympc

Copy link
Copy Markdown
Collaborator Author

Both review bots landed on the same issue independently. One taken, one declined.

Reservation assumes all-new IDs — valid, fixed in cf70e5e.

The objection is right and it undercut the PR's own thesis: the trait says nothing about ids being new (store_prekeys_batch's doc is just "Store multiple pre-keys in a single batch operation"), a table grown for overwritten rows never shrinks back, and this PR's whole claim is that it does not move retained bytes. Replaying a stored 812-key window would have taken the map 896 → 1792 and kept the extra ~42 KiB forever.

Rather than gate on is_empty() (the put_msg_secrets precedent), the reservation is now the floor on rows that must be added:

let at_least_new = keys.len().saturating_sub(state.prekeys.len());
state.prekeys.reserve(at_least_new);

No batch can overwrite more rows than exist, so keys.len() - len() is a lower bound on the new ids. It can only under-reserve and fall back to incremental growth — never inflate the resident table. This is strictly better than is_empty() gating, which would also have skipped the small-but-non-empty case that the retry-receipt path (store_prekey, one key at a time) can create before an upload pass.

Connect path is unchanged, re-measured after the fix: 3 allocations / 42.13 KB, dealloc 1 / 64 B — identical to the numbers in the PR body. replaying_a_stored_batch_does_not_grow_the_table now pins the overwrite case.

.unwrap() in the benchmark — declined.

The rule in AGENTS.md is "thiserror for typed errors, anyhow where several failure kinds meet. No .unwrap() outside tests", and it sits in a list about library error handling. Benches are harness code, and every existing bench in this crate uses it: send_receive_benchmark.rs 39 times, history_sync_benchmark.rs 11, message_utils_benchmark.rs 11, voip_benchmark.rs 10, sframe_varint_benchmark.rs 5. Changing this one file would make it the outlier, not the rule.

On the substance: propagating instead of panicking would be wrong here. bench_refs has nowhere to return a Result, and a store write that fails mid-measurement must abort loudly — absorbing it would publish allocation figures for a call that did not do the work. A panic is the correct failure mode for a benchmark.

Also for the record, unrelated to review: the red "Semver Checks (informational)" is pre-existing and not from this PR. It is continue-on-error: true by design ("Advisory only. The workspace is pre-1.0 and intentionally breaks API between minors"), and every failure it lists is in mex_operations.rs, the removed simd feature, or BinaryError::UnexpectedFormatByte — nothing this PR touches, which adds no public API surface at all.


Generated by Claude Code

greptile-apps[bot]
greptile-apps Bot previously approved these changes Aug 10, 2026

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: cf70e5e7f7

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread wacore/src/store/in_memory.rs Outdated
Comment on lines +342 to +343
let at_least_new = keys.len().saturating_sub(state.prekeys.len());
state.prekeys.reserve(at_least_new);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Deduplicate batch IDs before reserving

Fresh evidence in the revised code is that keys.len().saturating_sub(state.prekeys.len()) still treats duplicate IDs within the incoming batch as new rows. The trait imposes no uniqueness requirement and its default implementation processes duplicates with last-write-wins semantics, so an empty backend receiving 812 entries for one ID reserves an 812-row table but retains only one row, recreating roughly 42 KiB of permanent over-allocation; reserve for distinct IDs absent from the map instead.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Correct — fixed in 4dfd8b5. Subtracting the stored rows closed the map-side hole and left the batch-side one: keys.len() still counted repeated ids as distinct rows, so 812 entries sharing one id would have reserved a 1024-bucket table to hold one row. Same permanent over-allocation, reached from the other direction.

I did not take the literal suggestion of reserving for distinct absent ids, because the dedup costs more than the optimization saves: a HashSet<u32> of the batch is one allocation plus 812 hashes, against the eight allocations being removed. Instead the reservation is now gated on the batch being strictly ascending, which is a sufficient condition for distinctness:

let ascending = keys.windows(2).all(|pair| pair[0].0 < pair[1].0);
if ascending {
    let at_least_new = keys.len().saturating_sub(state.prekeys.len());
    state.prekeys.reserve(at_least_new);
}

One pass of integer compares, no allocation, no hashing. Both guards are one-sided in the same direction — they can only skip or shrink a reservation and fall back to incremental growth, never inflate the resident table — so the "costs no retained bytes" claim now holds for any caller rather than for well-behaved ones.

The connect path satisfies both conditions (upload_pre_keys_pass emits gen_start + i into an empty map), so the measurements are unchanged: 3 allocations / 42.13 KB, dealloc 1 / 64 B, median 26.0 µs at 812. a_batch_of_repeated_ids_does_not_reserve_for_them pins the case you raised — 812 entries for one id must leave the table sized for one row.


Generated by Claude Code

Subtracting the stored rows still assumed the ids within a batch were
distinct from each other. The trait requires no such thing and inserts
last-write-wins, so 812 entries sharing one id would have reserved a
1024-bucket table to hold a single row — the same permanent
over-allocation the previous commit removed, reached from the other side.

Gate the reservation on the batch being strictly ascending, which proves
its ids distinct. That is one pass of integer compares and no allocation;
deduplicating properly would need a set, whose own allocation and 812
hashes cost more than the eight allocations being saved. Like the
subtraction, the gate is one-sided: it can only skip a reservation and
fall back to incremental growth, never inflate the resident table.

The connect path satisfies it — `upload_pre_keys_pass` emits
`gen_start + i` — so the measurements are untouched: 3 allocations /
42.13 KB, dealloc 1 / 64 B, median 26.0 µs at 812.

Reported by Codex on #1270.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A5Vcfvdd5n2PX2Z9M8Ympc
@greptile-apps
greptile-apps Bot dismissed their stale review August 10, 2026 21:53

Dismissed because a newer commit was pushed; Greptile will re-review the current head.

@jlucaso1
jlucaso1 merged commit b2343e4 into main Aug 10, 2026
25 of 26 checks passed
@jlucaso1
jlucaso1 deleted the claude/prekeys-batch-hashmap-reserve-co85yl branch August 10, 2026 22:56
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants