Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
80 changes: 80 additions & 0 deletions changelog/2026-06-14-drop-moka-portable-cache.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
---
title: "June 14, 2026 — Drop moka: PortableCache is now the sole in-process cache backend"
description: "Removes the moka dependency (-2.55 MiB stripped, -21% .text). PortableCache gains monotonic TTL/TTI, eager init-lock reclamation, and a reliable async clear(). TypedCache::from_moka is renamed to from_local."
---

PR [#860](https://github.com/oxidezap/whatsapp-rust/pull/860) removes the `moka` dependency and makes `PortableCache` the only in-process cache backend on every target, including wasm32.

## Why moka was removed

`moka` was the single largest contributor to the release binary — **1.8 MiB of `.text` (15.8%)** — almost entirely from per-cache-type monomorphization. Its `do_run_pending_tasks` alone was emitted **84 times (710 KiB)** across the ~15 distinct `Cache` types the client instantiates; each new typed cache dragged in moka's full generic machinery (~100 KiB+).

`PortableCache` was already shipping on wasm32 targets and mirrors the full moka `Cache` API (capacity + TTL/TTI eviction, single-flight `get_with`/`get_with_by_ref`). Making it the sole backend required no call-site changes.

## Binary size impact

Real release profile (fat LTO, `codegen-units=1`, `panic=abort`, strip):

| Metric | before (moka) | after (PortableCache) | Δ |
|---|---:|---:|---:|
| Stripped size | 13.35 MiB | 10.81 MiB | **−2.55 MiB (−19.1%)** |
| `.text` | 11.31 MiB | 8.89 MiB | **−2.42 MiB (−21.4%)** |
| LLVM IR lines | 1,275,789 | 664,220 | **−47.9%** |
| `Cargo.lock` crates | 357 | 354 | −3 |

The net delta exceeds moka's own 1.8 MiB line because dropping moka also removes its transitive deps (crossbeam-channel/epoch, quanta, part of uuid) and unlocks further LTO savings. CodSpeed reports no performance change across 172 benchmarks.

## PortableCache hardening

Making PortableCache the sole native backend surfaced a few behavioural gaps that were addressed in this PR:

- **Monotonic TTL/TTI** — expiry now uses `wacore::time::Instant` instead of the wall clock, so a system-clock jump can't expire entries early. This matches moka's timer semantics and prevents `session_recreate_history`'s throttle backstop from being bypassed.
- **Eager single-flight init-lock reclamation** — `get_with`/`get_with_by_ref` now drop a key's init lock once no other caller holds it, instead of waiting for `run_pending_tasks`. Fixes unbounded `init_locks` growth in high-cardinality caches (session locks, chat lanes, message-id dedup) that never call `run_pending_tasks`.
- **Reliable async `clear()`** — new `PortableCache::clear()` awaits the write lock. `cleanup_connection_state` and `TypedCache::clear` now use it instead of the best-effort sync `invalidate_all()`, which could skip the clear under contention and leave a stale `ChatLane` after reconnect.
- **`snapshot_entries()`** — new async method that awaits the read lock for a reliable snapshot; used by `SenderKeyDeviceCache::invalidate_entries_for_device` where a missed entry would silently drop an SKDM fanout.

## Breaking changes

### `moka-cache` feature removed

The `moka-cache` Cargo feature no longer exists. Remove it from your `Cargo.toml`:
Comment thread
jlucaso1 marked this conversation as resolved.

```toml
# Before
whatsapp-rust = { version = "0.6", default-features = false, features = [
"sqlite-storage", "tokio-transport", "tokio-runtime",
"ureq-client", "tokio-native", "signal",
"moka-cache", # ← remove this line
] }

# After
whatsapp-rust = { version = "0.6", default-features = false, features = [
"sqlite-storage", "tokio-transport", "tokio-runtime",
"ureq-client", "tokio-native", "signal",
] }
```

### `TypedCache::from_moka` renamed to `from_local`
Comment thread
jlucaso1 marked this conversation as resolved.

If you construct a `TypedCache` directly in your own code, update the constructor name:

```rust
// Before
let cache = TypedCache::from_moka(my_cache);

// After
let cache = TypedCache::from_local(my_cache);
```

### `portable_cache` module is now always public

`whatsapp_rust::portable_cache` is no longer `cfg`-gated. Any conditional compilation on `#[cfg(any(not(feature = "moka-cache"), target_arch = "wasm32"))]` around imports of that module should be removed.

## Trade-offs

`PortableCache` differs from moka in two ways relevant to very high-throughput deployments:

- **Eviction policy**: FIFO instead of TinyLFU (lower hit rate under heavily skewed access patterns)
- **Concurrency**: one `RwLock` per cache vs moka's sharded, lock-free reads (more contention under heavy concurrent access)

For typical bot/single-account workloads this is unlikely to matter; the integration benchmarks (CodSpeed) confirmed no performance change across all 172 benchmarks.
45 changes: 16 additions & 29 deletions concepts/storage.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -476,7 +476,7 @@ pub struct LidPnCache {

**Defaults match `WAWebLidPnCache`:**
- **Time-based expiry**: none — entries do not idle out
- **Capacity**: effectively unbounded (`u64::MAX`; moka has no `unbounded()` builder)
- **Capacity**: effectively unbounded (`u64::MAX`; no dedicated `unbounded()` builder)

A custom `CacheEntryConfig` can impose a capacity bound if memory pressure requires it. Be aware that capacity-LRU eviction silently downgrades Signal addresses from `@lid` (or `@hosted.lid`) back to `@c.us`, which can cause `SessionNotFound` decryption failures. Notably, this keeps `status@broadcast` participants resolving to `@lid` for the entire session, matching WA Web behavior.

Expand Down Expand Up @@ -1030,39 +1030,26 @@ snapshots/

### Overview

By default, whatsapp-rust uses in-process [moka](https://github.com/moka-rs/moka) caches for group metadata, device lists, device registry, and LID-PN mappings. The pluggable cache store adapter lets you replace any of these with an external backend (Redis, Memcached, etc.) by implementing the `CacheStore` trait.
By default, whatsapp-rust uses in-process `PortableCache` caches for group metadata, device lists, device registry, and LID-PN mappings. The pluggable cache store adapter lets you replace any of these with an external backend (Redis, Memcached, etc.) by implementing the `CacheStore` trait.

### PortableCache (WASM-compatible alternative)
### PortableCache

When the `moka-cache` feature is disabled, whatsapp-rust automatically switches to `PortableCache` — a platform-agnostic, runtime-independent cache implementation suitable for WASM and other non-standard runtimes. It mirrors the moka `Cache` API surface so call-sites can switch transparently.
`PortableCache` is the client's sole in-process cache backend on every target, including wasm32. It is a platform-agnostic, runtime-independent implementation.

`PortableCache` supports:
- **Maximum capacity** with oldest-inserted eviction
- **Maximum capacity** with oldest-inserted (FIFO) eviction
- **Time-to-live (TTL)** — entries expire a fixed duration after insertion
- **Time-to-idle (TTI)** — entries expire after a fixed duration of no access
- **Monotonic expiry** — TTL/TTI use `wacore::time::Instant` (not the wall clock), so system-clock jumps cannot expire entries early
- **Single-flight `get_with`** — concurrent initializations for the same key coalesce into a single call, which is critical for caches storing coordination primitives (mutexes, channels)
- **Eager `iter()` snapshot** — `PortableCache::iter()` mirrors `moka::Cache::iter()` and yields `(Arc<K>, V)` pairs. Unlike moka's lazy iterator, the snapshot is captured up front (so it survives concurrent mutations) and may include entries that are expired but not yet evicted, matching moka's semantics.

All time checks use `wacore::time::now_millis` instead of `std::time::Instant`, making it compatible with environments where monotonic clocks are unavailable.

```toml
# Disable moka to use PortableCache (e.g., for WASM targets)
[dependencies]
whatsapp-rust = { version = "0.6", default-features = false, features = [
"sqlite-storage",
"tokio-transport",
"tokio-runtime",
"ureq-client",
"tokio-native",
"signal",
# "moka-cache" omitted — PortableCache is used instead
] }
```
- **Eager init-lock reclamation** — `get_with`/`get_with_by_ref` drop a key's init lock once no other caller holds it, preventing unbounded `init_locks` growth in high-cardinality caches (session locks, chat lanes, dedup)
- **Reliable async `clear()`** — awaits the write lock; prefer this over the best-effort sync `invalidate_all()` in async contexts
- **`snapshot_entries()`** — reliable awaited snapshot of `(Arc<K>, V)` pairs for invalidation passes; `iter()` is a best-effort sync spin that can yield an empty snapshot under write contention

```mermaid
graph TB
A[Client] --> B[TypedCache]
B -->|Default| C[Moka In-Process]
B -->|Default| C[PortableCache In-Process]
B -->|Custom| D[CacheStore Trait]
D --> E[Redis]
D --> F[Memcached]
Expand All @@ -1088,17 +1075,17 @@ Cache operations are best-effort. The client falls back gracefully when cache re

### TypedCache

`TypedCache<K, V>` is a generic wrapper that dispatches to either moka or a custom `CacheStore` backend. The moka path has zero extra overhead — values are stored in-process without any serialization. The custom-store path serializes values with `serde_json` and keys via `Display`.
`TypedCache<K, V>` is a generic wrapper that dispatches to either the in-process `PortableCache` or a custom `CacheStore` backend. The in-process path has zero extra overhead — values are stored in memory without any serialization. The custom-store path serializes values with `serde_json` and keys via `Display`.

```rust
// Moka path (zero overhead)
let cache = TypedCache::from_moka(moka_cache);
// In-process path (zero overhead)
let cache = TypedCache::from_local(portable_cache);

// Custom store path (serde_json serialization)
let cache = TypedCache::from_store(store, "group", Some(Duration::from_secs(3600)));
```

`CacheEntryConfig` provides a `build_typed_ttl` convenience method that automatically selects the right backend: if a custom `CacheStore` is provided, it creates a `TypedCache` backed by the store; otherwise it falls back to an in-process moka cache.
`CacheEntryConfig` provides a `build_typed_ttl` convenience method that automatically selects the right backend: if a custom `CacheStore` is provided, it creates a `TypedCache` backed by the store; otherwise it falls back to an in-process `PortableCache`.

```rust
let cache: TypedCache<Jid, Arc<GroupInfo>> = config.group_cache.build_typed_ttl(
Expand All @@ -1113,7 +1100,7 @@ The group cache stores `Arc<GroupInfo>` so that warm sends and repeated `query_i

`TypedCache` provides two ways to remove all entries:

- **`invalidate_all()`** — synchronous. For moka backends this works immediately. For custom `CacheStore` backends, it spawns a fire-and-forget task via `tokio::runtime::Handle::try_current()`, which **requires the `tokio-runtime` feature**. Without `tokio-runtime` enabled, the clear is skipped and a warning is logged.
- **`invalidate_all()`** — synchronous. For the in-process backend this is a best-effort spin; under sustained write contention it can silently skip the clear. For custom `CacheStore` backends, it spawns a fire-and-forget task via `tokio::runtime::Handle::try_current()`, which **requires the `tokio-runtime` feature**. Without `tokio-runtime` enabled, the clear is skipped and a warning is logged.
- **`clear()`** — async. Awaits completion for custom backends and is the recommended approach when you need to ensure all entries are removed.

<Warning>
Expand All @@ -1132,7 +1119,7 @@ pub struct CacheStores {
}
```

Fields left as `None` keep the default moka behavior. Use `CacheStores::all(store)` to set the same backend for all pluggable caches at once.
Fields left as `None` keep the default in-process `PortableCache` behaviour. Use `CacheStores::all(store)` to set the same backend for all pluggable caches at once.

<Note>
Coordination caches (`session_locks`, `chat_lanes`), the signal write-behind cache, and `pdo_pending_requests` always stay in-process — they hold live Rust objects (mutexes, channel senders) that cannot be serialized to an external store.
Expand Down
1 change: 1 addition & 0 deletions docs.json
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,7 @@
"group": "Changelog",
"pages": [
"changelog/overview",
"changelog/2026-06-14-drop-moka-portable-cache",
"changelog/2026-06-12-binary-size-ci",
"changelog/2026-06-11-bot-api-overhaul",
"changelog/2026-06-11-dockerfile-share-generics",
Expand Down
4 changes: 2 additions & 2 deletions guides/custom-backends.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -941,7 +941,7 @@ let backend = Arc::new(SqliteStore::new("whatsapp.db").await?);

## Custom cache store

The pluggable cache store adapter lets you replace the default in-process moka caches with an external backend like Redis or Memcached. This is useful for sharing cache state across multiple client instances or for deployments where in-process memory is limited.
The pluggable cache store adapter lets you replace the default in-process caches with an external backend like Redis or Memcached. This is useful for sharing cache state across multiple client instances or for deployments where in-process memory is limited.

### The CacheStore trait

Expand Down Expand Up @@ -1119,7 +1119,7 @@ The following namespaces are used internally by the client:
### Design considerations

- **Error handling is best-effort.** Cache misses and failures are logged as warnings but don't break the client — it falls back to fetching from the authoritative source.
- **Serialization uses `serde_json`.** Values are serialized to JSON bytes on the custom-store path. The moka path has zero serialization overhead.
- **Serialization uses `serde_json`.** Values are serialized to JSON bytes on the custom-store path. The in-process path has zero serialization overhead.
- **TTL is forwarded from `CacheEntryConfig`.** Your implementation receives the same TTL configured in `CacheConfig`.
- **Coordination caches cannot be externalized.** Session locks, message queues, and enqueue locks hold live Rust objects (mutexes, channels) and always stay in-process.
- **`invalidate_all()` requires `tokio-runtime`.** The synchronous `invalidate_all()` method on `TypedCache` spawns a fire-and-forget task via Tokio for custom backends. Without the `tokio-runtime` feature, the clear is skipped with a warning. Use the async `clear()` method instead if you disable `tokio-runtime`.
Expand Down
3 changes: 0 additions & 3 deletions installation.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,6 @@ whatsapp-rust = { version = "0.6", default-features = false, features = [
"ureq-client",
"tokio-native",
"signal",
"moka-cache",
] }
wacore = { version = "0.6", default-features = false }
tokio = { version = "1.48", features = ["macros", "rt-multi-thread"] }
Expand All @@ -82,7 +81,6 @@ whatsapp-rust supports several optional features:
| `tokio-transport` | Tokio WebSocket transport | ✅ Yes |
| `ureq-client` | Ureq HTTP client | ✅ Yes |
| `sqlite-storage` | SQLite storage backend | ✅ Yes |
| `moka-cache` | Moka in-memory cache | ✅ Yes |
| `simd` | SIMD-optimized binary protocol encoding/decoding (**requires nightly Rust**) | ✅ Yes |
| `signal` | Unix signal handling (graceful shutdown on SIGTERM/Ctrl+C) | ✅ Yes |
| `tracing` | Emit `tracing` spans/events across connect, send, receive, IQ, app state, pairing, media, and session flows. See [Observability](/advanced/observability) | ❌ No |
Expand Down Expand Up @@ -156,7 +154,6 @@ whatsapp-rust = { version = "0.6", default-features = false, features = [
"ureq-client",
"tokio-native",
"signal",
"moka-cache",
] }
# wacore also needs default-features = false to prevent feature unification
# from re-enabling simd
Expand Down