Skip to content

perf(store): batch session/identity/sender-key flush into one transaction per category - #714

Merged
jlucaso1 merged 2 commits into
mainfrom
perf/signal-flush-batch
Jun 4, 2026
Merged

perf(store): batch session/identity/sender-key flush into one transaction per category#714
jlucaso1 merged 2 commits into
mainfrom
perf/signal-flush-batch

Conversation

@jlucaso1

@jlucaso1 jlucaso1 commented Jun 4, 2026

Copy link
Copy Markdown
Collaborator

Problem

SignalStoreCache::flush() ran at the end of every send and wrote each dirty session, identity, and sender key one at a time via put_session / put_identity / put_sender_key. On the SQLite backend each of those is its own spawn_blocking + db_semaphore acquire (a single-permit write lane) + a one-row insert in its own implicit transaction.

A cold group send that establishes hundreds-to-thousands of fresh Signal sessions turns a single flush into that many spawn_blocking dispatches and that many separate transactions, serialized on the one write lane. The codebase already proves the batched shape is correct and far cheaper (update_device_lists, store_prekeys_batch), but no put_*_batch existed for sessions/identities/sender keys.

Change

Add put_sessions_batch, put_identities_batch, and put_sender_keys_batch to the SignalStore trait, each with a default implementation that loops the singular calls (so in-memory, wasm, and other backends keep working unchanged). The SQLite backend overrides them following the existing store_prekeys_batch template: one with_retry, one conn.transaction looping the upserts, one write-lane acquire.

flush() now collects the dirty puts per category and emits one batched call each, collapsing a cold group send's flush from N spawn_blocking + N transactions down to about three (one per category). Deletes stay singular (not the hotspot).

Addresses are passed as Arc<str> (the cache's own key type) rather than String, so building the batch is a refcount bump per entry instead of a string allocation, and the SQLite impl uses the shared key directly as &str in the query. The now-unused flush_encode_buf was removed; each entry serializes into its own Bytes (moved, not copied).

Benchmark

Wall-clock, in-memory SQLite, best-of-3, writing 500 sessions:

Path Time
500x put_session (per-entry) 6.34 ms
1x put_sessions_batch 1.83 ms

~3.5x for the per-flush transaction reduction. This is single-threaded in-memory, so it captures the spawn_blocking + per-statement transaction overhead but not the write-lane contention under concurrency or on-disk WAL, where the win is larger.

Tests

  • put_signal_batches_persist_and_upsert (new): all three batches persist and read back, re-batching the same addresses upserts (on_conflict do_update), and empty batches short-circuit.
  • Full lib suites green; cargo clippy --all-targets -- -D warnings clean.

@github-actions

github-actions Bot commented Jun 4, 2026

Copy link
Copy Markdown

Benchmark Results

67 unchanged benchmark(s)
Benchmark Current Baseline Change
reporting_token_benchmark::content_extraction_group::bench_content_extraction simple:setup_simple_message() 2,838 2,838 +0.0%
reporting_token_benchmark::content_extraction_group::bench_content_extraction extended:setup_extended_message() 8,272 8,272 +0.0%
reporting_token_benchmark::key_derivation_group::bench_key_derivation 31,317 31,317 +0.0%
reporting_token_benchmark::token_calculation_group::bench_token_calculation 13,827 13,827 +0.0%
reporting_token_benchmark::full_generation_group::bench_full_token_generation simple:setup_full_gen_simple() 49,398 49,398 +0.0%
reporting_token_benchmark::full_generation_group::bench_full_token_generation extended:setup_full_gen_extended() 54,827 54,827 +0.0%
reporting_token_benchmark::message_encoding_group::bench_message_encoding simple:setup_simple_message() 1,592 1,592 +0.0%
reporting_token_benchmark::message_encoding_group::bench_message_encoding extended:setup_extended_message() 4,219 4,219 +0.0%
send_receive_benchmark::dm_send::bench_dm_send text:setup_dm_send() 112,838 113,086 -0.2%
send_receive_benchmark::dm_recv::bench_dm_recv text:setup_dm_recv() 1,656,233 1,656,233 +0.0%
send_receive_benchmark::group_send::bench_group_send group_10:setup_group_send_10() 650,236 650,287 -0.0%
send_receive_benchmark::group_send::bench_group_send group_50:setup_group_send_50() 874,260 874,272 -0.0%
send_receive_benchmark::group_send::bench_group_send group_256:setup_group_send_256() 2,082,004 2,081,916 +0.0%
send_receive_benchmark::group_send_skdm::bench_group_send_skdm skdm_10:setup_group_skdm_10() 747,472 747,469 +0.0%
send_receive_benchmark::group_send_skdm::bench_group_send_skdm skdm_50:setup_group_skdm_50() 1,324,487 1,328,350 -0.3%
send_receive_benchmark::group_send_skdm::bench_group_send_skdm skdm_256:setup_group_skdm_256() 4,375,267 4,382,780 -0.2%
send_receive_benchmark::group_recv::bench_group_recv text:setup_group_recv() 515,587 519,168 -0.7%
binary_benchmark::marshal_group::bench_marshal_allocating 45,401 45,401 +0.0%
binary_benchmark::marshal_group::bench_marshal_auto_allocating 45,451 45,451 +0.0%
binary_benchmark::marshal_group::bench_marshal_exact_allocating 66,354 66,354 +0.0%
binary_benchmark::marshal_group::bench_marshal_reusing_buffer 43,512 43,512 +0.0%
binary_benchmark::marshal_group::bench_marshal_reusing_buffer_vec_writer 45,507 45,507 +0.0%
binary_benchmark::marshal_group::bench_marshal_long_string 4,930 4,930 +0.0%
binary_benchmark::marshal_group::bench_marshal_auto_long_string 4,961 4,961 +0.0%
binary_benchmark::marshal_group::bench_marshal_exact_long_string 6,732 6,732 +0.0%
binary_benchmark::marshal_group::bench_marshal_huge_bytes_allocating 528,529 528,529 +0.0%
binary_benchmark::marshal_group::bench_marshal_auto_huge_bytes_allocating 528,150 528,150 +0.0%
binary_benchmark::marshal_group::bench_marshal_exact_huge_bytes_allocating 529,396 529,396 +0.0%
binary_benchmark::marshal_group::bench_marshal_many_children_allocating 5,417,786 5,417,786 +0.0%
binary_benchmark::marshal_group::bench_marshal_auto_many_children_allocating 5,362,043 5,362,043 +0.0%
binary_benchmark::marshal_group::bench_marshal_exact_many_children_allocating 13,276,336 13,276,336 +0.0%
binary_benchmark::unmarshal_group::bench_unmarshal small:setup_small_marshaled() 1,850 1,850 +0.0%
binary_benchmark::unmarshal_group::bench_unmarshal large:setup_large_marshaled() 29,217 29,217 +0.0%
binary_benchmark::unpack_group::bench_unpack_uncompressed 618 618 +0.0%
binary_benchmark::unpack_group::bench_unpack_compressed 672,890 672,890 +0.0%
binary_benchmark::attr_parser_group::bench_attr_parser attr_lookup:setup_attr_marshaled() 3,736 3,736 +0.0%
binary_benchmark::roundtrip_group::bench_roundtrip small:setup_small_marshaled() 3,840 3,840 +0.0%
binary_benchmark::roundtrip_group::bench_roundtrip large:setup_large_marshaled() 48,283 48,283 +0.0%
binary_benchmark::roundtrip_group::bench_roundtrip_auto small:setup_small_marshaled() 3,866 3,866 +0.0%
binary_benchmark::roundtrip_group::bench_roundtrip_auto large:setup_large_marshaled() 48,344 48,344 +0.0%
binary_benchmark::roundtrip_group::bench_roundtrip_exact small:setup_small_marshaled() 5,206 5,206 +0.0%
binary_benchmark::roundtrip_group::bench_roundtrip_exact large:setup_large_marshaled() 66,668 66,668 +0.0%
binary_benchmark::child_iteration_group::bench_get_children_by_tag 310,312 310,312 +0.0%
binary_benchmark::jid_optimization_group::bench_jid_to_owned_access jid_access:setup_jid_heavy_marshaled() 8,286 8,286 +0.0%
numeric_attr_benchmark::bench_group::bench_baseline_u32 254 254 +0.0%
numeric_attr_benchmark::bench_group::bench_proposed_u32 91 91 +0.0%
numeric_attr_benchmark::bench_group::bench_baseline_u64 292 292 +0.0%
numeric_attr_benchmark::bench_group::bench_proposed_u64 137 137 +0.0%
numeric_attr_benchmark::bench_group::bench_baseline_i64 317 317 +0.0%
numeric_attr_benchmark::bench_group::bench_proposed_i64 145 145 +0.0%
numeric_attr_benchmark::bench_group::bench_baseline_loop_100_u64 27,425 27,425 +0.0%
numeric_attr_benchmark::bench_group::bench_proposed_loop_100_u64 10,725 10,725 +0.0%
libsignal_benchmark::dm_group::bench_dm_session_establishment setup:setup_dm_users() 4,141,909 4,136,708 +0.1%
libsignal_benchmark::dm_group::bench_dm_encrypt_first_message first_msg:setup_dm_session() 100,131 100,133 -0.0%
libsignal_benchmark::dm_group::bench_dm_decrypt_first_message decrypt_prekey:setup_dm_with_first_message() 4,263,702 4,263,702 +0.0%
libsignal_benchmark::dm_group::bench_dm_encrypt_subsequent_message subsequent:setup_established_dm_session() 99,803 99,803 +0.0%
libsignal_benchmark::group_messaging_group::bench_group_create_distribution_message create:setup_group_sender() 210,262 210,249 +0.0%
libsignal_benchmark::group_messaging_group::bench_group_encrypt_message encrypt:setup_group_with_distribution() 496,921 496,908 +0.0%
libsignal_benchmark::group_messaging_group::bench_group_decrypt_message decrypt:setup_group_with_encrypted_message() 509,291 507,835 +0.3%
libsignal_benchmark::conversation_group::bench_full_dm_conversation full:setup_conversation_data() 11,977,751 11,978,957 -0.0%
libsignal_benchmark::signature_group::bench_signature_creation sign:setup_keypair_with_message() 2,466,138 2,466,138 +0.0%
libsignal_benchmark::signature_group::bench_signature_verification verify:setup_keypair_with_message() 4,891,382 4,929,452 -0.8%
libsignal_benchmark::signature_group::bench_key_generation keygen 2,043,397 2,043,397 +0.0%
libsignal_benchmark::session_optimization_group::bench_decrypt_with_previous_session previous_session:setup_with_archived_sessions() 37,950 37,950 +0.0%
libsignal_benchmark::session_optimization_group::bench_out_of_order_decryption out_of_order:setup_out_of_order_messages() 3,616,036 3,616,036 +0.0%
libsignal_benchmark::session_optimization_group::bench_promote_matching_session promote:setup_promote_matching_session() 223,150 223,150 +0.0%
libsignal_benchmark::session_optimization_group::bench_message_key_eviction eviction:setup_message_key_eviction() 9,980,959 9,980,959 +0.0%
No significant changes detected.

@jlucaso1

jlucaso1 commented Jun 4, 2026

Copy link
Copy Markdown
Collaborator Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Jun 4, 2026

Copy link
Copy Markdown
✅ Action performed

Review finished.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@jlucaso1

jlucaso1 commented Jun 4, 2026

Copy link
Copy Markdown
Collaborator Author

@claude review

@claude

claude Bot commented Jun 4, 2026

Copy link
Copy Markdown

Claude finished @jlucaso1's task in 3m 4s —— View job


Claude Code Review

  • Read PR context
  • Read changed files (traits.rs, signal_cache.rs, sqlite_store.rs)
  • Verify batch correctness vs. singular implementations
  • Check for bugs, transaction semantics, concurrency
  • Look at the new test for adequacy
  • Post review

Summary

Clean, well-scoped batching change. The three new put_*_batch methods are mechanical clones of the existing store_prekeys_batch template, defaults preserve non-SQLite backends, and the SQL / on_conflict do_update semantics match the singular per-row implementations exactly. No correctness issues spotted. Approving in spirit — just notes below.

Correctness

  • Dirty/deleted exclusivity holds. Each cache state machine (SessionStoreState, ByteStoreState, SenderKeyStoreState) makes put and delete mutually exclusive at the per-key level — a key is in dirty xor deleted (or, for sender keys, dirty with Some xor dirty with None). So the new "batched put first, then loop singular deletes" order is safe: the deleted addresses are never in the put batch.
  • CheckedOut sessions are correctly excluded from the batch (signal_cache.rs:557) and their dirty flag is preserved (signal_cache.rs:568-575) so the next flush picks them up — same as before.
  • Failure semantics unchanged. If the batch call returns Err, the dirty-clear loop never runs, so the cache stays dirty. Same coherence as the pre-PR per-call loop.
  • SQL matches the singular path. Conflict keys ((address, device_id)) and do_update set record = ... are identical to put_identity_for_device / put_session_for_device / put_sender_key_for_device. No risk of divergent upsert behavior between batch and singular calls.
  • Lock-holding window shrinks. The cache lock is held across the backend write — same as before — but the backend write is now one batched call instead of N, so contention on sessions / identities / sender_keys mutexes drops proportional to the batch size.

Suggestions (non-blocking)

  1. Retry-loop duplication. The four batch methods (now store_prekeys_batch + the three new ones) plus put_identity_for_device all repeat the same "acquire permit → spawn_blocking transaction → match retriable → exponential backoff" shell. The inner conn.transaction(...) body is the only thing that differs. A with_blocking_retry(op_name, |conn| -> diesel::Result<()>) helper would centralize the backoff formula (10u64 * (1u64 << attempt.min(4))), MAX_RETRIES, and the permit/spawn dance. Future-you would change the backoff in one place.
  2. Silent retry on the batch path. put_identity_for_device (sqlite_store.rs:680) emits a warn! per retry attempt. The new batch methods (matching store_prekeys_batch) retry silently. Likely deliberate (flush is hot, don't spam) but you lose visibility into BUSY/LOCKED spikes under load. Consider a warn! on the second retry+ so spikes don't go unobserved.
  3. Double clone of the input vec. Inside put_sessions_batch the input slice is .to_vec()'d into sessions (refcount bumps), then sessions_clone = sessions.clone() inside the retry loop (more refcount bumps). Wrapping as Arc<Vec<(Arc<str>, Bytes)>> would let the closure take an Arc clone instead of a fresh Vec each retry. Negligible at typical batch sizes; only worth doing if a future profile shows it.
  4. Test gap: same address twice in one batch. The cache's dirty HashSet guarantees uniqueness, so the existing call sites can't trigger this. But a direct caller of put_*_batch could pass duplicates — the behavior (last value wins via on_conflict do_update within a single transaction) is well-defined but untested. One extra assertion in put_signal_batches_persist_and_upsert would close the loop.
  5. Per-entry allocation vs. shared serialize buffer. The PR removes flush_encode_buf so each session/sender-key allocates its own Vec and moves it into Bytes::from(vec) (zero-copy). The trade is "N right-sized allocations" vs. "one reused buffer + one final Vec::from(buf.clone()) copy per entry." The benchmark (3.5x for 500 sessions) suggests this is net-positive, so the trade is fine — just worth knowing if you ever profile flush allocations.

Verdict

LGTM. Correctness is solid, the shape mirrors a proven pattern in the codebase, and the win is documented with a real microbench. The suggestions above are quality polish, not blockers.
• Branch: perf/signal-flush-batch

- reuse the existing with_retry helper for the three put_*_batch methods
  instead of duplicating the permit/spawn_blocking/backoff shell (#1)
- warn on sustained busy/locked retries in with_retry (#2)
- capture the batch as Arc<Vec> so retries bump a refcount, not re-clone (#3)
- test duplicate address within one batch (last-write-wins) (#4)
@jlucaso1
jlucaso1 merged commit 658d98c into main Jun 4, 2026
9 of 10 checks passed
@jlucaso1
jlucaso1 deleted the perf/signal-flush-batch branch June 4, 2026 16:16
@coderabbitai

coderabbitai Bot commented Jun 4, 2026

Copy link
Copy Markdown

Review Change Stack

Caution

Review failed

The pull request is closed.

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 771f90e0-5043-4b48-b983-706eec03043a

📥 Commits

Reviewing files that changed from the base of the PR and between d3a08d4 and 8bff457.

📒 Files selected for processing (3)
  • storages/sqlite-storage/src/sqlite_store.rs
  • wacore/src/store/signal_cache.rs
  • wacore/src/store/traits.rs

📝 Walkthrough

Summary by CodeRabbit

  • New Features

    • Batch write APIs for identities, sessions, and sender keys to persist multiple records in one operation; empty batches short-circuit without error.
    • Batched writes perform upserts with last-value-wins behavior for duplicate keys within a batch.
  • Refactor

    • Cache flush now serializes and persists batches once per flush and removes a shared per-flush allocation to reduce hot-path memory reuse.
  • Chore

    • Retry logic now emits a warning on repeated transient database contention attempts.
  • Tests

    • Added tests verifying batched persistence, upserts, re-batching, and empty-batch behavior.

Walkthrough

This PR adds batch write methods to SignalStore, implements transactional SQLite batch upserts in SqliteStore (with retry logging), refactors SignalStoreCache::flush to emit batched writes, and adds a test verifying persistence and upsert semantics.

Changes

Batch Signal Store Write APIs

Layer / File(s) Summary
Batch write trait methods
wacore/src/store/traits.rs
Trait adds put_identities_batch, put_sessions_batch, and put_sender_keys_batch with default implementations that iterate inputs and call single-entry put_* methods.
SQLite batch implementation
storages/sqlite-storage/src/sqlite_store.rs
SqliteStore implements the three batch methods to upsert all records in a single transaction, wrapping the input batch in Arc for reuse across retries.
Batch persistence and upsert test
storages/sqlite-storage/src/sqlite_store.rs
put_signal_batches_persist_and_upsert verifies batched sessions, identities, and sender-keys persist, upsert on re-batch, resolve duplicate addresses last-value-wins, and that empty batches are no-ops.
Retry operation logging
storages/sqlite-storage/src/sqlite_store.rs
with_retry now logs warnings starting at the second retry attempt for transient SQLITE_BUSY/locked errors, including operation name, attempt, max retries, and computed delay.
Cache flush refactored to batch persistence
wacore/src/store/signal_cache.rs
SignalStoreCache::flush collects dirty sessions, identities, and sender-keys into typed batches and persists them via put_*_batch calls; removes shared flush_encode_buf; immediate deletes for None sender-keys remain.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Possibly related PRs

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch perf/signal-flush-batch

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 and usage tips.

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.

1 participant