perf(sqlite): let sibling devices share one connection pool - #1274
Conversation
Every SqliteStore constructor builds its own r2d2 pool, so a process holding N sessions against the same database file ends up with N SQLite connections. Measured with the harness added here (resident-set delta, 50 idle sessions that have each done a couple of point reads), that is ~123 KiB per session before any real work: a 48,000 B lookaside slab (SQLITE_DEFAULT_LOOKASIDE 1200,40, which this build does not override) plus page cache. Nothing else about the store is per-session — every query already takes a device_id — so `share_for_device` hands a sibling device the same pool, semaphore and reader connections and swaps only that field. The per-session cost goes to zero: a fleet of 200 handles holds the same one connection as a fleet of 1. Lowering `cache_size_kib` was measured first, since a better default would beat new API: it caps growth but not the floor. 512 -> 64 changed nothing for an idle session (123.4 -> 122.6 KiB) and even 512 -> 1 only reached ~92 KiB, so it captures at most a quarter of the cost. The lookaside half cannot be touched at all without unsafe FFI — SQLITE_DBCONFIG_LOOKASIDE is C-API only and diesel keeps the sqlite3* private — so `connection_init` cannot reach it. The trade is real and stays opt-in: siblings share the single write permit, which costs ~2.5x aggregate write throughput on a burst where every session writes continuously, in exchange for a FIFO-fair queue (separate connections leave fairness to SQLite's busy handler, measured at ~2x spread between fastest and slowest session). `read_pool_size` does not change that picture — a pure-write burst never touches the reader connections. The defaults are unchanged. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NRxG7mynLodse41nb4h5gC
📝 WalkthroughSummary by CodeRabbit
WalkthroughAdds ChangesSQLite store sharing and benchmarks
Estimated code review effort: 4 (Complex) | ~45 minutes Sequence Diagram(s)sequenceDiagram
participant Benchmark
participant SqliteStore
participant SQLite
participant ProcRSS
Benchmark->>SqliteStore: Create shared or independent stores
SqliteStore->>SQLite: Seed and touch database connections
Benchmark->>SQLite: Run concurrent write burst
Benchmark->>ProcRSS: Read resident memory
Benchmark-->>Benchmark: Report memory and timing results
Possibly related PRs
Suggested labels: Suggested reviewers: 🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✨ Finishing Touches🧪 Generate unit tests (beta)
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. Comment |
|
| Filename | Overview |
|---|---|
| storages/sqlite-storage/src/sqlite_store.rs | Adds the shared-device store constructor, supporting documentation, and tests validating pool reuse, device isolation, and serialized writes. |
| storages/sqlite-storage/examples/per_connection_memory.rs | Adds a Linux-only RSS and write-contention benchmark using an exclusively created owner-only scratch directory. |
Flowchart
%%{init: {'theme': 'neutral'}}%%
flowchart LR
Base["Base SqliteStore<br/>device 1"] --> Pool["Shared SQLite pool"]
Base --> Permit["Shared write semaphore"]
Base --> Readers["Shared reader pool"]
Sibling["share_for_device(2)<br/>device 2"] --> Pool
Sibling --> Permit
Sibling --> Readers
Base --> Rows1["Queries scoped to device_id = 1"]
Sibling --> Rows2["Queries scoped to device_id = 2"]
Reviews (3): Last reviewed commit: "fix(sqlite): report the marginal per-ses..." | Re-trigger Greptile
The example opened a PID-derived path in the shared temp directory and deleted it first, so anyone able to write there could pre-place a symlink and redirect its database, WAL and shm writes. `create_dir` fails outright if anything already occupies the path, symlink included, so creating a directory of our own and working inside it removes the race. A Drop guard does the cleanup because two of the modes return early. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NRxG7mynLodse41nb4h5gC
There was a problem hiding this comment.
Actionable comments posted: 6
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@storages/sqlite-storage/examples/per_connection_memory.rs`:
- Around line 198-202: Validate the parsed sessions value in the argument setup
before mode dispatch, rejecting zero and requiring sessions to be at least 1.
Preserve the existing default of 50 and ensure invalid input cannot reach the
mode-specific execution paths.
- Around line 147-168: Use distinct fresh TempDb instances for the
shared-handles and separate-pools write bursts in the example, rather than
reusing the same database URL. Ensure each mode opens its stores against its own
newly created database before calling write_burst, matching the comparison
pattern in SqliteStore’s TempDb usage.
- Around line 213-215: Update the compile-options branch in the example’s main
flow so it does not return before the shared temporary SQLite database cleanup.
Ensure the database is cleaned up after compile_options(&url) completes, either
by using a cleanup guard or by falling through to the existing cleanup block.
- Around line 49-60: Update rss_bytes to use the runtime system page size when
converting the resident page count from /proc/self/statm, replacing the
hard-coded 4096 multiplier while preserving the existing parsing and error
behavior.
- Line 53: The synchronous filesystem operations in per_connection_memory.rs
must be moved off the current-thread Tokio runtime: at line 53, run the
/proc/self/statm read via tokio::task::spawn_blocking; at line 209, run
scratch-directory creation via an awaited blocking task; and at line 277,
replace synchronous remove_dir_all cleanup with an awaited blocking task,
restructuring the owning flow as needed because Drop cannot await it.
In `@storages/sqlite-storage/src/sqlite_store.rs`:
- Around line 650-661: Update the write-serialization documentation near
share_for_device and its related sections to state that siblings share the
configured db_semaphore write limit, with serialization applying when pool_size
is 1 (the default), rather than unconditionally. Rename the affected test or
configure it explicitly with pool_size = 1 so its name and assertions document
that scope.
🪄 Autofix
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Repository UI (base), Organization UI (inherited)
Review profile: ASSERTIVE
Plan: Pro Plus
Run ID: 0694e382-8499-4947-9cfd-08fccb047a0d
📒 Files selected for processing (2)
storages/sqlite-storage/examples/per_connection_memory.rsstorages/sqlite-storage/src/sqlite_store.rs
| // Field 2 of /proc/self/statm is resident pages. 4 KiB is the page size on | ||
| // every platform this harness is meant to run on; a wrong constant would | ||
| // scale every number here equally, so comparisons stay valid regardless. | ||
| let statm = std::fs::read_to_string("/proc/self/statm").expect("/proc/self/statm"); |
There was a problem hiding this comment.
🩺 Stability & Availability | 🟠 Major | ⚡ Quick win
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash
set -euo pipefail
rg -n -C 3 'tokio::main|std::fs::(read_to_string|remove_file)' \
storages/sqlite-storage/examples/per_connection_memory.rs
fd -a '^Cargo.toml$' . -x rg -n -C 2 'tokio'Repository: oxidezap/whatsapp-rust
Length of output: 7071
🏁 Script executed:
#!/bin/bash
set -euo pipefail
printf '%s\n' '--- example structure and relevant call sites ---'
ast-grep outline storages/sqlite-storage/examples/per_connection_memory.rs
sed -n '1,75p;180,225p;250,290p' storages/sqlite-storage/examples/per_connection_memory.rs
printf '%s\n' '--- manifest and call context ---'
fd -a '^Cargo.toml$' storages/sqlite-storage -x sh -c 'echo "--- $1"; cat -n "$1"' sh {}Repository: oxidezap/whatsapp-rust
Length of output: 8744
🏁 Script executed:
#!/bin/bash
set -euo pipefail
nl -ba storages/sqlite-storage/examples/per_connection_memory.rs | sed -n '185,285p'
printf '%s\n' '--- all filesystem operations in the example ---'
rg -n -C 2 'std::fs|remove_file|remove_dir_all|create_dir' storages/sqlite-storage/examples/per_connection_memory.rsRepository: oxidezap/whatsapp-rust
Length of output: 200
🏁 Script executed:
#!/bin/bash
set -euo pipefail
cat -n storages/sqlite-storage/examples/per_connection_memory.rs | sed -n '185,285p'
printf '%s\n' '--- all filesystem operations in the example ---'
rg -n -C 2 'std::fs|remove_file|remove_dir_all|create_dir' storages/sqlite-storage/examples/per_connection_memory.rsRepository: oxidezap/whatsapp-rust
Length of output: 5417
🏁 Script executed:
#!/bin/bash
set -euo pipefail
python3 - <<'PY'
from pathlib import Path
p = Path("storages/sqlite-storage/examples/per_connection_memory.rs")
lines = p.read_text().splitlines()
checks = {
"current_thread runtime": any("#[tokio::main(flavor = \"current_thread\")]" in line for line in lines),
"statm read": any('std::fs::read_to_string("/proc/self/statm")' in line for line in lines),
"scratch directory creation": any("std::fs::create_dir(&dir)" in line for line in lines),
"synchronous cleanup": any("std::fs::remove_dir_all(&self.0)" in line for line in lines),
"rss before measurement": any("let before = rss_bytes();" in line for line in lines),
"rss after measurement": any("let after = rss_bytes();" in line for line in lines),
"early return cleanup path": any("compile_options(&url).await;" in line for line in lines)
and any("writes(&url, sessions, cache_kib).await;" in line for line in lines),
}
for name, present in checks.items():
print(f"{name}: {'present' if present else 'missing'}")
for i, line in enumerate(lines, 1):
if any(token in line for token in (
"read_to_string(\"/proc/self/statm\")",
"std::fs::create_dir(&dir)",
"std::fs::remove_dir_all(&self.0)",
"rss_bytes()",
)):
print(f"{i}: {line.strip()}")
PYRepository: oxidezap/whatsapp-rust
Length of output: 669
Move synchronous filesystem I/O off the current-thread Tokio runtime.
Run the /proc/self/statm read and scratch-directory creation with tokio::task::spawn_blocking. Replace synchronous remove_dir_all cleanup with an awaited blocking task; Drop cannot await it.
📍 Affects 1 file
storages/sqlite-storage/examples/per_connection_memory.rs#L53-L53(this comment)storages/sqlite-storage/examples/per_connection_memory.rs#L209-L209storages/sqlite-storage/examples/per_connection_memory.rs#L277-L277
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@storages/sqlite-storage/examples/per_connection_memory.rs` at line 53, The
synchronous filesystem operations in per_connection_memory.rs must be moved off
the current-thread Tokio runtime: at line 53, run the /proc/self/statm read via
tokio::task::spawn_blocking; at line 209, run scratch-directory creation via an
awaited blocking task; and at line 277, replace synchronous remove_dir_all
cleanup with an awaited blocking task, restructuring the owning flow as needed
because Drop cannot await it.
Source: Coding guidelines
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 36556ea5f8
ℹ️ 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".
| pub fn share_for_device(&self, device_id: i32) -> Self { | ||
| Self { | ||
| pool: self.pool.clone(), | ||
| db_semaphore: Arc::clone(&self.db_semaphore), | ||
| reads: self.reads.clone(), |
There was a problem hiding this comment.
Report the shared connection pool only once
When sibling handles back separate clients, each handle's resource_report() inspects this same cloned r2d2 pool and reports the full cache estimate from all of its open connections. Consequently, polling and summing per-client Client::resource_report() values counts one shared SQLite cache once per sibling (and concurrent polls can instead return None for siblings that lose the single try_get() race), making the documented per-session resource attribution inaccurate. The shared pool therefore needs shared-aware attribution or a way to report the process-owned storage bytes only once.
Useful? React with 👍 / 👎.
| .expect("open"), | ||
| ); | ||
| } | ||
| let (total, fastest, slowest) = write_burst(separate).await; |
There was a problem hiding this comment.
Benchmark both write modes against fresh data
When the example runs in writes mode, the handles burst first inserts every (address, device_id) pair into this same database, and this second burst then uses the identical peer.{n}.{i}:0 keys and device IDs. Because put_session is an upsert, the reported pools timing measures conflict updates against an already populated and warmed database while handles measures initial inserts, so the advertised throughput comparison cannot be attributed to connection sharing. Run each mode against a fresh database (and release the first pool) before comparing them.
Useful? React with 👍 / 👎.
| .nth(1) | ||
| .and_then(|f| f.parse().ok()) | ||
| .expect("resident pages"); | ||
| pages * 4096 |
There was a problem hiding this comment.
Use the host page size when converting RSS pages
On Linux targets configured with a page size other than 4 KiB, such as 64 KiB arm64 systems, /proc/self/statm still reports a count of native pages, so multiplying by 4096 underreports every displayed RSS and per-session KiB value (by 16× in that example). This harness is documented only as Linux-specific rather than 4-KiB-specific and presents absolute memory costs, so it should query the page size or read a kB-valued RSS field instead of hard-coding it.
Useful? React with 👍 / 👎.
| return; | ||
| } | ||
|
|
||
| seed(&url).await; |
There was a problem hiding this comment.
Avoid seeding through the allocator being measured
In either RSS mode, seed() first opens and heavily uses a SQLite connection and then drops it before the baseline. SQLite/glibc can retain those freed connection, lookaside, and page-cache allocations in the process RSS, after which the measured stores reuse them; this is especially damaging to handles, where the sole new connection can appear to cost almost nothing because its memory was already included in before. Seed in a separate process or otherwise establish the database without allocating and freeing the same connection resources before taking this baseline.
Useful? React with 👍 / 👎.
📦 Binary size report
.text per crate
Baseline: |
…aring docs Review found three things worth fixing in the measurement and one in the docs. The harness measured total RSS growth divided by session count, which charges every session for whatever the seeding connection left behind in the allocator for the first one to reuse. It now also reports the *marginal* cost — the growth from the first session to the last, over the sessions in between — which is the number that describes the batch and the one that survives a warm allocator. It moves the conclusion not at all and sharpens it: 122.6 KiB per additional session with a pool each, 0.0 KiB with shared handles. The `writes` mode ran both arms against one database, so the second measured upserts over the first's rows and warmed pages. Each arm now gets its own. RSS comes from smaps_rollup, already in kB, instead of multiplying statm's page count by an assumed 4 KiB page size. And zero sessions is rejected rather than dividing by it. On the docs: `db_semaphore` carries `pool_size` permits, so siblings serialize only at the default of 1 — a base store with a wider pool passes that width on. The test now sets `pool_size` explicitly and says so in its name. Also documents that `resource_report()` describes the pool, which siblings share, so a fleet's reports must be counted once per pool rather than summed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NRxG7mynLodse41nb4h5gC
|
Review round: six of the findings were valid and are fixed in b2fa5ea; two are declined with reasons. The PR body now carries the corrected numbers. Fixed
Declined
Generated by Claude Code |
There was a problem hiding this comment.
Actionable comments posted: 1
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@storages/sqlite-storage/src/sqlite_store.rs`:
- Around line 650-655: Guard all SQLite mutation paths with the shared
db_semaphore and lock-retry behavior, including delete_identity_for_device,
delete_session_for_device, put_sender_key_for_device,
delete_sender_key_for_device, and set_app_state_sync_key_for_device. Route these
and any other writes through with_retry or the existing shared mutation helper,
then add a sibling-store concurrency test using pool_size: 2 that races an
unguarded mutation path.
🪄 Autofix
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Repository UI (base), Organization UI (inherited)
Review profile: ASSERTIVE
Plan: Pro Plus
Run ID: 1853e1a9-383a-4a23-bbff-9fa566e7ef10
📒 Files selected for processing (2)
storages/sqlite-storage/examples/per_connection_memory.rsstorages/sqlite-storage/src/sqlite_store.rs
| /// - **Isolate writes.** Siblings share the write permits, of which | ||
| /// [`SqliteStoreConfig::pool_size`] decides the number — so at its | ||
| /// default of 1 their writes serialize against each other, and a base | ||
| /// store built with a wider pool passes that width on instead. That is | ||
| /// the trade, and at the default it is not free: on a burst where every | ||
| /// session writes continuously, sharing |
There was a problem hiding this comment.
🩺 Stability & Availability | 🟠 Major | 🏗️ Heavy lift
Guard every mutation path with the shared write limiter.
Line 650 says sibling stores share the configured write permits. Several mutation paths do not acquire db_semaphore or retry lock errors. Examples include delete_identity_for_device at Line 1273, delete_session_for_device at Line 1405, put_sender_key_for_device at Line 1428, delete_sender_key_for_device at Line 1478, and set_app_state_sync_key_for_device at Line 1545.
If pool_size > 1, sibling handles can check out separate connections and these writes can contend for SQLite's single writer lock. Route every mutation through with_retry or one shared mutation helper. Add a sibling-store test with pool_size: 2 that races one currently unguarded mutation path.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@storages/sqlite-storage/src/sqlite_store.rs` around lines 650 - 655, Guard
all SQLite mutation paths with the shared db_semaphore and lock-retry behavior,
including delete_identity_for_device, delete_session_for_device,
put_sender_key_for_device, delete_sender_key_for_device, and
set_app_state_sync_key_for_device. Route these and any other writes through
with_retry or the existing shared mutation helper, then add a sibling-store
concurrency test using pool_size: 2 that races an unguarded mutation path.
|
On the unguarded mutation paths ( It is unchanged by this PR. At the default it is not a bug. With At
That is why Adding the suggested Happy to open that as a follow-up issue if you'd like it tracked. Generated by Claude Code |
Summary
Every
SqliteStoreconstructor (new,new_for_device,with_config,with_config_for_device) builds its own r2d2 pool. A process holding N sessions against the same database file calls one of them per session and ends up with N SQLite connections.A connection costs memory before it reads a row:
SQLITE_DEFAULT_LOOKASIDEis1200,40= 48,000 B (46.9 KiB). Confirmed for this build rather than quoted from upstream:PRAGMA compile_optionson the bundled SQLite 3.51.3 does not listDEFAULT_LOOKASIDE, and in the amalgamation thectime.ctable (L22738) is compiled beforesqliteLimit.hdefines it (L23636) — so its absence proves no-Doverride, and the header default applies.SqliteStoreConfig::cache_size_kib, 512 by default. A cap on growth, not a reservation.shared.rsalready names this problem for sibling crates ("two pools mean two WAL writers fighting over the file lock, two page caches, and two busy queues"). Sibling devices in one process had no equivalent. Everything else in the store is already device-scoped — every method is*_for_device(&self, …, device_id)— so only that field ever needed to vary.Changes
SqliteStore::share_for_device(&self, device_id) -> SqliteStore— clonespool,db_semaphore,reads,snapshot_safeanddatabase_path, swaps onlydevice_id. The returned store owns the pool handles, so it cannot outlive them: dropping the store it came from closes nothing. The doc says why (per-connection cost) and what it does not do (does not create the device row; does not isolate writes, with the measured throughput cost; does not split the resource report).examples/per_connection_memory.rs— the harness that produced every number below. RSS-based, because SQLite's page cache and lookaside aresqlite3_mallocallocations from the bundled C library that a RustGlobalAllocwrapper never sees.cache_size_kibnow records that it is a cap and not the floor;SqliteStoreConfigpoints at the new method.No default changed. No new dependency, no
unsafe, no global state, nothing outsidestorages/sqlite-storage/.Cost
Bytes per connection vs per handle. Resident-set delta, release build, 50 sessions that have each done two point reads (an idle session's steady state). The marginal column is the growth from the first session to the last divided by the sessions in between — it is the number that describes the batch, and unlike a total-over-N average it cannot be flattered by allocator memory the seeding connection left behind for the first session to reuse:
cache_size_kibScaling at the default cap — the handle side stays at one connection's worth of RSS in total (163,840 B) no matter how many sessions hang off it:
N=1 does not regress: the base store is session 1, so no spare connection is opened ahead of the fleet.
The pragma result, reported even though the API went ahead. Lowering
cache_size_kibwas measured first, because a better default would have been a better outcome than new API. It caps growth but not the floor: 512 → 64 changed nothing for an idle session (122.6 → 122.0 KiB marginal), and even 512 → 1 only reached 90.5 KiB. So the whole knob is worth ~25% of the per-connection cost at its most extreme setting, against ~100% for sharing. Under a worst case that does fill the cap (every session full-scans a 4 MB table) it matters more — 631.3 → 150.9 KiB/session marginal for 512 → 64 — but that is the cap doing its job and buying hit rate, so the default stays where it is.How this squares with #1235. That audit put the SQLite profile at "~530 KiB + ~512 KiB" marginal RSS per session, which is four times the 122.6 KiB here — and both are right, because they measure sessions in different states. #1235's sessions are connected and doing real work, so each one's page cache fills toward the 512 KiB cap; these are idle, two point reads in. The warm mode of this harness closes the gap and corroborates it: make every session scan a 4 MB table and the figure goes to 631.3 KiB/session, just past #1235's. Which cuts the same way — a session that actually uses its cache saves more by sharing a connection, not less. #1235's other finding also explains why this harness reads RSS rather than a Rust allocator hook: "SQLite's page cache allocates through the amalgamation's own
malloc, not the Rust global allocator. My counting allocator saw exactly 0 bytes."Concurrent write latency, both modes. Release, file-backed WAL, every session writing 200 records continuously, each arm against its own fresh database:
So sharing costs ~2.5x aggregate write throughput (~7.5k vs ~20k writes/s, flat in N). The cause is not lock contention but pipelining: a private connection lets one session's queueing and
spawn_blockingdispatch overlap another's SQLite work, which is the same effectread_erased's comment already records. In exchange the shared queue is FIFO-fair — every session finishes within 5 ms of every other — where separate connections leave fairness to SQLite's busy handler and its random backoff, measured at ~2x spread.read_pool_sizedoes not change this: 0 vs 4 gave 190/193 ms shared and 98/89 ms separate at 8 sessions. A pure-write burst never touches reader connections, so the read side cannot buy back write serialization.That is why this is opt-in and not a default. It is for mostly-idle fleets, which is the shape sessions actually have; a continuously-writing fleet should keep a store per session.
Caveat carried over from the external sweep that motivated this batch (harness not in this repo, not reproduced here): its −31.0% figure came from idle sessions and does not exercise concurrent writes at all. That comparison was also not ABBA-paired — two sweeps at different times, with an unmodified client interleaved through both reading 0.334 → 0.324 MiB/session, i.e. −2.9% drift against a −31.0% effect. Enough for an effect that size, not enough for one of a few percent. The numbers in this PR are in-process and independent of it.
Checked and not changed
cache_size_kibreaches at most ~25% of the per-connection cost, and only by setting a cap so low it defeats the cache. Left at 512.SQLITE_DBCONFIG_LOOKASIDEis C-API only (no pragma), andconnection_initreceives a&mut SqliteConnectionwhoseinternal_connection: NonNull<ffi::sqlite3>ispub(super)in diesel, so the handle cannot be reached at all — let alone withoutunsafe.sqlite3_config(SQLITE_CONFIG_LOOKASIDE, …)is process-global and start-time only, which the constraints rule out twice over. The 46.9 KiB is fixed.SharedSqlite— solves the adjacent problem (sibling crates sharing the file) and is the precedent for the reasoning, but it hands out a raw connection runner, not a store: it carries nodevice_idand implements none of the storage traits, so a second session cannot be built on it.sqlstore.Container— one container, one pool, N devices, which is the shape this converges on. Not copied as API: Go'sdatabase/sqlpools freely because its callers do not need the write serialization this store's single permit provides, so the write-throughput trade above is ours to state, not theirs.pool_size— untouched. It is 1 for the documented reason, and sharing does not change that reason. Note the docs are scoped accordingly:db_semaphorecarriespool_sizepermits, so siblings serialize at the default of 1 and inherit a wider pool's width otherwise.resource_report()— left as is, and documented. It describes the pool, which siblings share, so each handle reports the same whole-pool estimate; a fleet's reports must be counted once per pool rather than summed. Attributing shared bytes to one arbitrary handle would be a worse answer than saying they belong to the pool.*_for_devicemethod — untouched; the new method only produces a store with a differentdevice_id.delete_identity_for_device,delete_session_for_device,put_sender_key_for_device,delete_sender_key_for_device,set_app_state_sync_key_for_device) — real, unchanged by this PR, and already documented where the decision is made. Atpool_size = 1they serialize on the pool checkout instead of the permit; atpool_size > 1they are exactly the hazard theSqliteStore::readscomment describes and the reasonread_pool_sizeis a second pool rather than a wider first one. Routing them throughwith_retryis what would makepool_size > 1safe, and it deserves its own PR rather than riding along on a memory change.tests/e2e/tests/process_footprint.rs(from test(e2e): measure the fixed process cost against the per-session cost #1238) — the neighbouring harness, kept and not duplicated. It measures whole-session marginal RSS with anRssAnon/RssFilesplit and needs the mock server; this one isolates the per-connection cost and runs without it. Different question, no overlap to collapse.Validation
cargo fmt --allcargo clippy --workspace --all-targets -- -D warnings(excludingwhatsapp-rust-voip-cli, whosealsa-sysbuild script needs ALSA headers absent from this container — unrelated to this change)cargo test --workspace(excluding the same crate ande2e-tests, which needs the mock server): 60 test binaries, 0 failures, including the crate's 78 tests and both doctests.share_for_device_tests:siblings_share_the_database_but_not_each_other_s_rows— writes through one handle, reads through the other; proves both see one database and thatdevice_idisolates their rows in both directions.a_fleet_of_handles_opens_one_connection— counts connections viapool.state().connections: 8 handles → 1, 8 separate stores → 8. Also asserts the shared write permit viaArc::ptr_eq, so the trade-off is asserted, not assumed.concurrent_writes_serialize_across_siblings_at_the_default_pool_size— runs the burst both ways, printing timings, and asserts the two properties that must hold on any machine: no write fails, and no sibling starves on the shared queue.pool_sizeis set explicitly because the serialization claim holds for that value. Wall-clock thresholds are deliberately not asserted; the release numbers above come from the example instead.Known-failing check:
Semver Checks (informational)is red, and was already red before this branch. It runs against-p wacore -p wacore-binary -p waprotoonly — none of which this PR touches (the diff is two files understorages/sqlite-storage/). Its findings are pre-existing drift versus the published 0.7.0: regeneratedwacore::iq::mex_operationsitems, a removedsimdfeature, andBinaryError::UnexpectedFormatByte. The job is informational by design and its own summary says so.