From 818d9f8b8da820a51696125e41ccdf85a0734f00 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 12 Aug 2026 04:29:30 +0000 Subject: [PATCH 1/3] docs: document Client::device_memo_stats() whatsapp-rust#1292 added a new always-on, no-feature-gate diagnostic: per-term hit/miss counters for the group-devices and SKDM-targets memos on the group-send path, exposed as Client::device_memo_stats() returning DeviceMemoStats (public in whatsapp_rust::client, not re-exported from the crate root). Documents it alongside stats() / memory_report() / resource_report() in the API client reference's Diagnostics section. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01E4eR5JAKYPgnJFNb3rsU8a --- api/client.mdx | 57 ++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 57 insertions(+) diff --git a/api/client.mdx b/api/client.mdx index 9b6de5c0..89c7086e 100644 --- a/api/client.mdx +++ b/api/client.mdx @@ -2523,6 +2523,63 @@ if let Some(alloc) = report.alloc { Storage, transport, and HTTP reports are supplied by the trait implementations behind `Client` — see [`DeviceStore::resource_report`](/api/store#resource_report), [`Transport::resource_report`](/api/transport#resource_report), and [`HttpClient::resource_report`](/api/http-client#resource_report). `AllocSnapshot`, `StorageResourceReport`, `TransportResourceReport`, and `HttpResourceReport` are re-exported from `wacore::stats`; all four are also re-exported from the `whatsapp_rust` crate root. +### device_memo_stats + +```rust +pub fn device_memo_stats(&self) -> DeviceMemoStats +``` + +Per-term hit/miss counts for the two device-list memos the group-send path depends on — the group-devices memo and the SKDM-targets memo — cumulative since the client was built. Always on, no feature gate: recording is one indexed relaxed atomic add per resolver call, and the reporting types are dropped by LTO in a binary that never calls this method. + +The two memos are chained: `resolve_skdm_targets_memoized` compares the `Arc` that `resolve_group_devices_memoized` returned, so a group-memo recompute forces `skdm_targets.miss_devices` regardless of the SKDM memo's own three miss terms. Read `group_devices` first — `skdm_targets` only carries independent information once the group half is hitting. + +**`GroupDevicesMemoStats` fields** (`#[non_exhaustive]`): + +| Field | Type | Description | +|-------|------|-------------| +| `hits` | `u64` | Entry present, `GroupInfo` identity matched, generation unchanged | +| `restamps` | `u64` | Generation moved but every change since provably missed this group; served the same devices a hit would, at the cost of the `unchanged_for` scan | +| `miss_absent` | `u64` | No entry for the group — first send, or a capacity/TTL eviction (the memo holds 64 groups) | +| `miss_group_info` | `u64` | An entry existed but was built from a different `Arc` | +| `miss_topology` | `u64` | The device topology changed in a way that could have touched this group | +| `bypassed` | `u64` | Call didn't consult the memo at all (store-backed registry/mapping caches make its freshness contract unenforceable) | + +`calls()` sums all six fields; `served_rate()` returns `(hits + restamps) / calls()` — the share resolved without a per-member registry fan-out — or `None` before the first call. + +**`SkdmTargetsMemoStats` fields** (`#[non_exhaustive]`): + +| Field | Type | Description | +|-------|------|-------------| +| `hits` | `u64` | Memo hit | +| `miss_absent` | `u64` | No entry — first send for this group, or an eviction | +| `miss_devices` | `u64` | The resolved device-set `Arc` differs from the memoized one — the cascade term a group-memo recompute always forces | +| `miss_map` | `u64` | The sender-key device map was rebuilt (a warm-mark write invalidated it) | +| `miss_map_generation` | `u64` | Same map `Arc`, advanced generation — an in-place cold flip, e.g. a retry receipt's `markForgetSenderKey` | +| `miss_sender` | `u64` | A different sending identity (PN↔LID re-addressing, or a re-pair) | +| `not_stored` | `u64` | A resolved target set that couldn't be memoized, so the *next* call can't hit — but doesn't guarantee that call reports `miss_absent`: a stale entry left in place can never become valid again (the map generation only moves forward) and is reported under whichever term still fails | +| `bypassed` | `u64` | Call didn't consult the memo | +| `resolve_failed` | `u64` | The device resolution this call depends on returned `Err`, so no memo term was evaluated | + +`calls()` sums every field except `not_stored` (which describes the store, not a lookup outcome). `hit_rate()` returns `hits / calls()`, `resolve_failed` included in the denominator on purpose — folding failed resolutions out would let a client whose group sends are failing upstream read a healthy rate — or `None` before the first call. + +`DeviceMemoStats` implements `Display` for a one-line-per-memo summary, and `since(&self, earlier: &Self) -> Self` saturating-subtracts an earlier snapshot to scope a window without resetting the counters (a reset would race a send in flight). + +**Example:** + +```rust +let before = client.device_memo_stats(); +// ... send a batch of group messages ... +let window = client.device_memo_stats().since(&before); +println!("{window}"); +if let Some(rate) = window.skdm_targets.hit_rate() { + println!("SKDM memo hit rate: {:.1}%", rate * 100.0); +} +``` + + + `DeviceMemoStats`, `GroupDevicesMemoStats`, and `SkdmTargetsMemoStats` are public in `whatsapp_rust::client` but, unlike `StatsSnapshot`/`MemoryReport`/`ResourceReport`, not re-exported from the crate root. Added in [#1292](https://github.com/oxidezap/whatsapp-rust/pull/1292) as a characterization tool: measured against that PR's fixtures, both memos hit on every warm send at group sizes 8–512, so the instrumentation shipped without a corresponding fix. + + ## Error Types ```rust From faf87a9590d71ad063fda3a9002e87159d8bba33 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 12 Aug 2026 04:33:28 +0000 Subject: [PATCH 2/3] docs: split the device_memo_stats intro into one idea per sentence Addresses review feedback on #518: the availability/cost/LTO guarantees were bundled into one sentence, against AGENTS.md's one-idea-per-sentence rule. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01E4eR5JAKYPgnJFNb3rsU8a --- api/client.mdx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/api/client.mdx b/api/client.mdx index 89c7086e..e4ac0432 100644 --- a/api/client.mdx +++ b/api/client.mdx @@ -2529,7 +2529,7 @@ if let Some(alloc) = report.alloc { pub fn device_memo_stats(&self) -> DeviceMemoStats ``` -Per-term hit/miss counts for the two device-list memos the group-send path depends on — the group-devices memo and the SKDM-targets memo — cumulative since the client was built. Always on, no feature gate: recording is one indexed relaxed atomic add per resolver call, and the reporting types are dropped by LTO in a binary that never calls this method. +Per-term hit/miss counts for the two device-list memos the group-send path depends on — the group-devices memo and the SKDM-targets memo — cumulative since the client was built. Always on, no feature gate. Recording is one indexed relaxed atomic add per resolver call. The reporting types are dropped by LTO in a binary that never calls this method. The two memos are chained: `resolve_skdm_targets_memoized` compares the `Arc` that `resolve_group_devices_memoized` returned, so a group-memo recompute forces `skdm_targets.miss_devices` regardless of the SKDM memo's own three miss terms. Read `group_devices` first — `skdm_targets` only carries independent information once the group half is hitting. From 6285da0ffce1b314a226f643ebc6a883f8862d26 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 12 Aug 2026 04:40:11 +0000 Subject: [PATCH 3/3] docs: fix device_memo_stats accuracy issues from review - Diagnostics intro now mentions device_memo_stats as the fourth always-on surface, answering a different question than the other three. - The atomic-add cost note now accounts for the extra not_stored add a non-memoizable resolution also pays. - The chained-memo paragraph now qualifies that a group-memo recompute only forces skdm_targets.miss_devices when an SKDM entry already exists; with none yet, that call reports miss_absent instead. - Split the hit_rate()/calls() paragraph into one idea per sentence. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01E4eR5JAKYPgnJFNb3rsU8a --- api/client.mdx | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/api/client.mdx b/api/client.mdx index e4ac0432..3f76969c 100644 --- a/api/client.mdx +++ b/api/client.mdx @@ -2365,6 +2365,8 @@ See [Signal Protocol - Signed pre-key rotation (RotateKeyJob)](/advanced/signal- Three on-`Client` surfaces answer "what does this session cost?" without any feature flag: always-on wire I/O counters via [`stats()`](#stats), an on-demand client-only memory breakdown via [`memory_report()`](#memory_report), and an on-demand unified estimate — client plus storage, transport, and HTTP — via [`resource_report()`](#resource_report). All three are dependency-free and safe to call once per client even when running many clients in one process. For CPU/custom attribution (e.g. per-session allocator tracking), see [`BotBuilder::with_task_instrument`](/api/bot#with_task_instrument) and [`BotBuilder::with_alloc_meter`](/api/bot#with_alloc_meter). +A fourth, always-on surface answers a different question — "are the group-send device-list memos actually being hit?" — via [`device_memo_stats()`](#device_memo_stats). + ### stats ```rust @@ -2529,9 +2531,9 @@ if let Some(alloc) = report.alloc { pub fn device_memo_stats(&self) -> DeviceMemoStats ``` -Per-term hit/miss counts for the two device-list memos the group-send path depends on — the group-devices memo and the SKDM-targets memo — cumulative since the client was built. Always on, no feature gate. Recording is one indexed relaxed atomic add per resolver call. The reporting types are dropped by LTO in a binary that never calls this method. +Per-term hit/miss counts for the two device-list memos the group-send path depends on — the group-devices memo and the SKDM-targets memo — cumulative since the client was built. Always on, no feature gate. Recording is one indexed relaxed atomic add per resolver call, with one exception: a call whose resolved SKDM target set can't be memoized also bumps the separate `not_stored` counter, so that call pays two adds (see `not_stored` below). The reporting types are dropped by LTO in a binary that never calls this method. -The two memos are chained: `resolve_skdm_targets_memoized` compares the `Arc` that `resolve_group_devices_memoized` returned, so a group-memo recompute forces `skdm_targets.miss_devices` regardless of the SKDM memo's own three miss terms. Read `group_devices` first — `skdm_targets` only carries independent information once the group half is hitting. +The two memos are chained: `resolve_skdm_targets_memoized` compares the `Arc` that `resolve_group_devices_memoized` returned, so a group-memo recompute forces `skdm_targets.miss_devices` — but only when an SKDM entry already exists for the group. If none does yet (first send, or an eviction), that call reports `miss_absent` instead, regardless of what the group memo just did. Read `group_devices` first — `skdm_targets` only carries independent information once the group half is hitting. **`GroupDevicesMemoStats` fields** (`#[non_exhaustive]`): @@ -2560,7 +2562,7 @@ The two memos are chained: `resolve_skdm_targets_memoized` compares the `Arc` th | `bypassed` | `u64` | Call didn't consult the memo | | `resolve_failed` | `u64` | The device resolution this call depends on returned `Err`, so no memo term was evaluated | -`calls()` sums every field except `not_stored` (which describes the store, not a lookup outcome). `hit_rate()` returns `hits / calls()`, `resolve_failed` included in the denominator on purpose — folding failed resolutions out would let a client whose group sends are failing upstream read a healthy rate — or `None` before the first call. +`calls()` sums every field except `not_stored`, which describes the store rather than a lookup outcome. `hit_rate()` returns `hits / calls()`. `resolve_failed` is included in that denominator on purpose — folding failed resolutions out would let a client whose group sends are failing upstream read a healthy rate. `hit_rate()` returns `None` before the first call. `DeviceMemoStats` implements `Display` for a one-line-per-memo summary, and `since(&self, earlier: &Self) -> Self` saturating-subtracts an earlier snapshot to scope a window without resetting the counters (a reset would race a send in flight).