cachedb_perf: high-performance local memory cache built on modern kernel features - #4118
cachedb_perf: high-performance local memory cache built on modern kernel features#4118Lt-Flash wants to merge 7 commits into
Conversation
The isolated-cache, 50k end-to-end and 100k three-way benchmarks are all published in PR OpenSIPS#4118; only the two-socket huge-page-arena end-to-end number is still outstanding.
cf2d1e4 to
642c828
Compare
CP-15 preview: cross-node state sharing for cachedb_perf, measured on a 3-node containerized cluster
The answer is pull-on-miss read repair. On a local miss in an opted-in collection, the node asks the cluster, stores the answer locally with its remaining TTL, and serves it — so state migrates towards wherever it is actually requested, one key at a time, and a second request for the same key is an ordinary local hit. Nothing is pushed eagerly and nothing participates unless named in Two transports are selectable via To see how it behaves under sustained load — not just in a unit rig — I ran a 3-node cluster of Alpine containers (containerd/nerdctl on one 16-core host, dedicated bridge network, IGMP snooping off), built from the development branch at 1. Warm cluster: the feature costs nothing when it isn't neededAll 30,000 keys on every node, 45 s of load:
This is the steady state a production cluster with call-id affinity lives in, and it is indistinguishable from the node-local module. 2. Each node holds one third — how fast does everyone hold everything?Fresh seed: node 1 gets keys 0–9,999, node 2 gets 10,000–19,999, node 3 gets 20,000–29,999. Same uniform load against all three nodes, so two thirds of initial reads are misses: Half of each node's 20,000-key gap closes in about one second; 99% by 7 s; every node holds all 30,000 keys at 11.5 s — while the cluster keeps serving 87,000 req/s throughout. The exponential shape is inherent to read repair: hot keys arrive almost immediately, and the tail is just rarely-requested keys waiting to be asked for. (For a planned mass hand-over — node loss, re-hash — the 3. What a pull costs the request that triggers itA pull is a 200 µs – 1.7 ms event (multicast ask + first positive reply), visible on ~3% of convergence-phase requests. Cluster-wide p99 rose from 317 µs to 576 µs during the heaviest miss phase and was back to baseline within seconds. Nothing approached the 100 ms budget; across 10 M+ requests in all scenarios there were zero timeouts and zero failures. Caveat for calibration: this is a single-host bridge network, so the wire is sub-millisecond — on a real LAN the pull cost is roughly one RTT plus these numbers, which is exactly the case where the eventfd/async form is worth using instead of the blocking one. 4. Wire accounting, to the packettcpdump on the bridge for a full thirds-seeded convergence:
So the cost model for an N-node cluster is simply 1 multicast + (N−1) unicast replies per miss, independent of cluster size on the request side, and ~2 pkt/s of beacons when idle. Where this landsCP-15 lives on a development branch as module-scoped commits on top of this PR's branch (cachedb_perf: probe / pull / negative cache / async face / membership / stats; the consumer side on the topology_hiding branch; the messaging API on the clusterer_controller branch). It will be proposed once this PR merges, since it builds on the collection and stats layers introduced here. The defaults follow from the measurements above: everything opt-in, |
Follow-up: the same bench with
|
clctr — encrypted multicast |
bin — clusterer TCP mesh |
|
|---|---|---|
| Warm steady state | 90.5 k req/s, zero cluster traffic | 89.8 k req/s, zero cluster traffic — identical |
| Slowest node fully converged | 13.2 s | 17.6 s |
| Convergence-phase p99 | 585 µs | 1,319 µs |
| Throughput during the miss storm | ~50 k req/s | ~20 k req/s |
| Wire, same 60,000 misses | 180,114 UDP packets (3 per miss) | 367,540 TCP segments (≈6.8 per miss) |
Same exponential shape — read repair fetches hot keys first regardless of transport — but BIN takes 4.4 s longer to finish, because each pull's slower round trip compounds through every blocked worker.
The medians are indistinguishable in both runs, which is the useful control: the transports genuinely differ only on the miss path. There, BIN's p99 starts near 2 ms against clctr's 1 ms and stays elevated roughly twice as long.
The operationally interesting one. With blocking pulls a slower transport bites twice — the request waits longer and the worker is unavailable longer — so BIN's throughput during the heaviest miss phase drops to ~20 k req/s against clctr's ~50 k. (The asynchronous form softens this for both transports; it does not change their ordering.)
The structural difference, counted rather than argued: BIN cannot broadcast, so every miss asks each peer separately over TCP and pays ACKs — twice the packets for identical work in a 3-node cluster. And the ratio is size-dependent in BIN's disfavour: the multicast ask stays one packet at any cluster size, while BIN's ask side grows as N−1.
Two honesty notes. First, this is a single-host bridge network — BIN's TCP overhead is at its kindest here; on a real network with loss and latency the gap widens. Second, BIN remains the right fallback where multicast is unavailable (some cloud fabrics), which is exactly why it exists as an option rather than being removed.
The full harness — image build, cluster bring-up, loaders, collector, capture wrapper, distilled per-run data and both chart generators — is committed on the development branch under modules/cachedb_perf/bench/containers/, so the whole comparison reproduces in a few minutes on any host with containerd.
083b8aa to
573aedb
Compare
|
Rebased onto current master (clean, no conflicts - this module wasn't touched by the topology_hiding refactor happening elsewhere) and added one more small fix, found from a real production reading. Bug: Caught live on a production node: a debug collection ( |
c52f711 to
fea6b19
Compare
New commit: cross-node pull — ask the cluster before calling a missThis is the follow-up trailed in the July benchmarks above, now landed as one The shape of it. Nodes behind a load balancer each run their own Blocking is explicit and bounded. Transports, and what this PR does not depend on. The default transport Sizing is measured, not guessed. A pull slot embeds its key/value bounds: Failover hook. Observability. Module stats Running in production on our gateway fleet; the |
Added: degraded-operation documentation, with captured outputsFollow-up commit documenting exactly what each MI command, event and cachedb Mode 1 — clusterer loaded, Mode 2 — neither module available. Pull and sync off, cache runs purely Unaffected in both modes: the whole local surface — What changes, surface by surface:
The one thing that still fails startup is a genuinely invalid config — an |
New commit: make a pull that never left the node visibleA cross-node pull datagram that the transport refused to send was logged at That is the wrong level for this event. A send that fails produces no packet, so the far end never learns there was a question and the requester simply times out. This line is the only direct evidence of why, and it is the actual cause behind a class of "cross-node pull is slow / does not converge" reports. It cannot become an unconditional Details worth noting:
|
New commit: store a pull answer that arrives after its caller gave upA pull that times out can still be answered — the reply was merely late. Previously that answer was worthless: the waiter freed the request slot on its way out, a reply arriving after that failed the slot lookup and was dropped, and since the wire reply carries neither collection nor key, once the slot was gone the answer was uninterpretable. Net effect: the cache permanently re-asked for exactly the keys that are slowest to fetch — the worst possible convergence profile — and no counter recorded any of it. Now a waiter that leaves empty-handed orphans the slot instead of freeing it. The slot keeps the collection, key and deadline, so a late reply still matches, and the reply path completes the store the waiter would have done. Final outcomes (a stored value, an oversize holder, a settled absence) still free the slot. Design notes:
Verified fail-then-pass on two hostsTwo nodes on this branch over plain clusterer/BIN (no controller — the degraded-op path),
Waiter latency is unchanged in both cases; the difference is purely that the answer now lands. |
get() must hand back a value the caller owns and pkg_free()s, so every
lookup on a hot path costs a pkg_malloc and a pkg_free that exist only to
satisfy that contract. Profiling the cachedb_perf read path put the
allocator at a fixed ~70 ns/op - 15% of a 470 ns lookup over 50 000
entries, 30% of a 236 ns one when the working set is cache resident -
while memcpy never appeared at all.
Add an optional endpoint that reads into a buffer the caller already
owns: no allocation, and one copy instead of two. It is advertised by
CACHEDB_CAP_GET_BUF, so a caller uses it only where a backend offers it
and keeps get() as the fallback; nothing about the existing endpoint
changes, and no backend is obliged to implement the new one.
The contract is written for the failure cases, because those are what a
caller gets wrong: the buffer must be private to the calling process, it
may be written speculatively and abandoned so its contents are undefined
on any non-hit, *vlen and *needed are zeroed before anything else so an
ignored return code yields a zero length rather than an uninitialised
one, and a value that does not fit reports the size it would need in
*needed while leaving *vlen at 0 - {buf, *vlen} is always a valid str.
A hit is 0 here, unlike get(), which is called out in the comment.
CACHEDB_CAP_GET_BUF answers a runtime question - whether the backend a
caller happens to be configured against implements the endpoint. It
cannot answer the one a module has to settle first: whether the core it
is being compiled against has the endpoint at all. get_buf is a member of
cachedb_funcs, so referencing it against an older core is a compile error
rather than a failed test, and the capability is an enum constant,
invisible to the preprocessor. So there is a plain define beside it, and
a module can compile against either core and pick the faster path up on
its own. The runtime check is still required and the comment says so - a
core may carry the endpoint while the backend in use does not implement
it.
The first implementation is cachedb_perf, in the following commit.
A from-scratch cachedb backend for large, high-churn local caches,
selected by URL scheme (perf://), as a drop-in alternative to
cachedb_local. It implements the same cachedb_funcs vtable, so any module
taking a cachedb_url works unchanged.
Motivation: cachedb_local sizes its hash table once from cache_collections
and never resizes, so a large cache degrades to long lock-held chain walks
(measured while benchmarking topology_hiding's th_store backend). This
module keeps lookups flat as the cache grows.
Core design:
- 64-byte, one-cache-line buckets with 1-byte per-slot tags (SWAR scan)
and a per-bucket seqlock: readers take no locks, snapshot optimistically
and re-check, with a bounded retry and a lock fallback. Legal because shm
is mapped once before fork and never unmapped, so a stale pointer read is
bounded garbage the version re-check discards.
- Slab arena: entries live in fixed-size cells in class-bound chunks that
are never returned to shm; per-process bump allocation with no atomics on
the fast path (byte 0 of every cell is the immutable class id).
- Runtime growth: a segmented directory + linear hashing splits one bucket
at a time from the single maintenance timer - buckets never move, the
routing word is re-checked on a miss, and the load-factor cliff cannot
form (growth_load_factor).
- Native int64 counters (add/sub) accumulated under the bucket lock; a
versionless TTL bump that refreshes expiry with one atomic store; an
overflow side table for full buckets.
- No allocator call ever happens under a bucket lock.
This is also the first backend to implement the allocation-free read added
in the previous commit. The two entry points share one implementation, so
they cannot diverge over which record they return: the optimistic loop,
the lock fallback, the re-route retry, the overflow leg and the expiry
check all live in one function, which copies into the caller's buffer, and
the allocating entry point is a thin wrapper that passes the per-process
scratch and then copies into pkg. get() and get_counter() keep the
documented cachedb behaviour byte for byte - in particular get_counter()
still returns only {-2,-1,0} and never the new too-small code. Measured
with the cachedb-vtable benchmark, 50 000 entries, 200-byte values, 100%
reads, one worker, alternating runs: 397.7 ns/op via get() against
290.1 ns/op via get_buf() at the median, the new path faster in every pair.
Operability and durability:
- Per-collection expiry sweep (min-expires hints), per-process sharded
statistics, and a huge-page arena (hugetlb -> THP -> MADV_COLLAPSE -> 4K,
detected by trying; arena_hugepage_mb), measured +7-13%.
- Introspection MI (all lock-free): perf_keys, perf_scan (cursored, Redis
SCAN semantics), perf_dump, perf_get, perf_set, perf_ttl, perf_del, plus
perf_stats and perf_stats_reset; glob script functions perf_del/
perf_mget/perf_mget_json. perf_stats reports hit rate, expired and
destroyed counts, and when each collection last synced.
- Observability events: E_CACHEDB_PERF_EXPIRED / NOMEM / GROWN /
MEM_DEGRADED, each gated by evi_probe_event so a subscriber-less cluster
pays nothing, none on the hot path.
- DB persistence: whole-collection save/load to any db_* backend
(perf_save/perf_load, db_mode startup-load / shutdown-save), TTLs kept as
absolute wall-clock time so they survive a restart. A save/load is a full
blocking snapshot written in one transaction - a maintenance/bootstrap
operation, not per-request - and rows that no longer exist in memory are
dropped at load rather than resurrected.
- Cluster sync: perf_sync saves a collection then signals peers over
clusterer to reload it from the shared DB (one message per sync, not per
operation), raising E_CACHEDB_PERF_SYNCED. A pull-from-DB refresh for
single-writer/read-replica topologies - deliberately not per-operation
replication, which would tax the lock-free path this module exists for.
v1 is single-node in-memory with optional db_* persistence and the DB-
backed cluster refresh; per-operation replication is out of scope.
Nodes behind a load balancer each run their own cachedb_perf, so a key written on one node is a miss on every other until something moves it. perf_sync (the existing mechanism) moves whole collections through a shared DB - right for bootstrap and failover, wrong for one key: a full snapshot per convergence event, and only when someone asks for one. This adds the per-key path: on a miss, ask the cluster, and store what comes back. Opt-in per collection via replicate_collections - a key is only worth asking the cluster about if it means the same thing on every node, which only the operator knows. Protocol. A miss allocates a slot in a small shm pool, broadcasts a request carrying (collection, key, id), and peers that hold the key answer with the value and its remaining TTL; the reply is stored locally with that TTL and the slot is released. Slots have a deadline (pull_timeout_ms, default 50); a utimer reaper fires twice per timeout window and releases every slot whose deadline passed - without it, a request no peer answers would leak its slot AND leave a blocked worker hanging forever, which is exactly what happened in an early deployment when fewer peers than expected were up. Answers arriving after the deadline are stored anyway (the data is still good - only the waiter is gone). A miss the whole cluster confirmed is remembered in a small negative cache for pull_negative_ms, so a key that does not exist anywhere does not re-interrogate the cluster on every lookup. Blocking is explicit and bounded. pull_on_miss=1 makes a cache_fetch miss wait (poll + eventfd) up to pull_timeout_ms for the cluster's answer - that is a worker parked per miss, fine for a maintenance path, priced accordingly on a SIP path, and the module says so at startup. With it off, the pull still happens asynchronously and benefits the NEXT lookup of that key. Transports. Default "bin" rides the clusterer module's BIN links: one unicast per peer, no extra dependency. "clctr" rides the clusterer_controller module's encrypted multicast plane instead - one datagram reaches every peer, encrypted, which the BIN links are not. The controller is optional at build time (CLUSTERER_CTRL_SUPPORT, exported by the top-level Makefile only when clusterer_controller is part of the build) and at run time: a clctr choice the deployment cannot honour warns and degrades to bin, and if the clusterer module is unavailable too, pull and sync are disabled and the cache runs purely node-local. Missing cluster infrastructure never stops the module from starting. Sizing is measured, not guessed. A pull slot embeds its key and value bounds; pull_max_key (default 128) and pull_max_value (default 512) size them, so the 64-slot pool costs 50 KB of shm at the defaults instead of the 550 KB a worst-case constant would take. The defaults come from measuring live collections (values of 1-20 bytes under keys up to 33; sql_cacher rows ~70 bytes); dns_cache-style users with multi-KB records raise pull_max_value and accept fewer slots per KB. A value over the cap - or, on the clctr plane, over what one datagram can carry - is answered as held-but-unsendable rather than absent: the requester must not conclude a key is missing from a node that demonstrably holds it. It falls back to the DB path untouched. Failover hook. sync_shtag="name/cluster_id" arms a sharing-tag callback: the node becoming active reloads the collections from the DB snapshot, so a standby that just took over does not serve week-old state. Like everything else here it is soft - no DB or no clusterer means it logs and disarms. Observability. Module stats pull_requested / pull_answered / pull_served / pull_timeouts / pull_negative_hits, plus per-collection pulled_in / served_out (the module-wide counters cannot say WHICH collection is converging). perf_stats gains a cluster.topology object naming this node's id, resolved IP and each peer's reachability; perf_cluster_probe actively asks every peer to answer for a collection and reports who did - the two questions (who is configured / who is actually reachable) fail differently. The pullsoak rig under bench/ drives all of it against a real two-node cluster: the e2e burst, the async slot-leak regression (the reaper bug above, reproduced first, then fixed), the oversize leg, and the max-key boundary.
Two degraded modes exist and neither was documented beyond the startup warnings: clusterer loaded but clusterer_controller absent (the clctr transport falls back to bin, everything else keeps working), and neither module available (pull and sync off, the cache runs purely node-local). Add a "Degraded operation" section walking every surface through both modes - the local MI/script/event set that is unaffected, perf_pull, perf_cluster_probe, perf_sync, the perf_stats cluster object, cache_fetch under pull_on_miss, and E_CACHEDB_PERF_SYNCED - with the actual outputs captured from live instances in each mode, not paraphrased: the three startup warnings (both wordings of the clctr fallback, since the build-time and run-time reasons read differently), perf_sync's broadcast count and its "saved to the DB only" note, the pull refusal codes, and the presence/absence of the cluster object. The Dependencies section said "None", which is true but undersells the point: name both optional modules and what each one unlocks. README regenerated.
The arm32 CI jobs fail to build pcache_htable.c with six -Werror errors, while arm64 and every native job pass. The difference is not the architecture but the data model: `unsigned long` is 64 bits on LP64 and 32 on ILP32, and two places in this file genuinely need 64. The routing word packs the linear-hashing state as (level << 32) | split. Declared `unsigned long`, so on arm32 or i386 every shift is undefined and the packing collapses into itself - which is exactly what "left/right shift count >= width of type" reports at :105, :1451, :1507, :1509 and :1599. The SWAR tag scan is the same mistake with worse consequences. tag_matches() memcpy()s the eight tag bytes into an `unsigned long` and masks with 0x0101010101010101. On ILP32 the destination is four bytes - clang says "'memcpy' will always overflow; destination buffer has size 4, but size argument is 8" - and every constant is silently truncated, so the filter that is supposed to reject 255 of 256 slots would return garbage instead of failing loudly. Both become uint64_t, which is what the code always meant. Every other `unsigned long` here is a size or a pointer-range bound, correct under both models, and is deliberately left alone. No change on LP64, where the two types are the same width - the fleet binaries differ only by the name of a type. The routing word was already being read with __atomic_load_n; ARMv7 has LDREXD/STREXD so an 8-byte atomic stays lock-free there, and where it is not the compiler emits a libatomic call, which is still correct. Verified with `gcc -m32`, which reproduces the identical six errors before the change and none after, with the native 64-bit build clean throughout. The other cachedb_perf sources were swept the same way and are unaffected.
A pull datagram the transport refused to send was logged at LM_DBG, which means it was unreachable on every deployed node: log_level 3 is INFO and L_DBG is 4. That is the wrong level for it. A send that fails produces no packet, so the far end never learns there was a question and the requester just times out - this line is the only direct evidence of why, and it is the actual cause behind a class of "cross-node pull is slow / does not converge" reports. It cannot become an unconditional LM_WARN either: a partitioned or overloaded peer fails every send, and an unbounded warn is its own incident. So warn on the first failure and then at most once every 30s, folding in the count it stands for. The rate-limit state lives in shm, not in a static. A pull reply goes out from whichever worker happened to receive the request, so a per-process limiter would let all ~30 SIP workers warn once per interval each. Two workers can still pass the interval check simultaneously and both warn; that costs an occasional duplicate line and saves taking a lock on a failure path. Adds pulls_send_failed as a statistic and an MI field, so the exact total is available even while the log is suppressing. It is deliberately distinct from pulls_timeout: a timeout means a peer was asked and stayed silent, this means no peer was ever asked. Applied to all four send sites - both reply paths (clusterer_controller unicast and BIN) and both request paths - since a request that does not go out fails exactly the same way. (cherry picked from commit 5ce7e1cbeff4cc6d1c17a147bca4bd7402ca2388)
A pull that timed out could still be answered - the reply was just late. The old code made that answer worthless: pcache_pull_finish() released the slot on the way out (sl->id = 0), a reply arriving after that failed the slot lookup and was dropped, and since the reply carries neither collection nor key, once the slot was gone the answer was uninterpretable. The result was the worst possible convergence profile: the cache permanently re-asked for exactly the keys that are slowest to fetch, one timeout at a time. Now a waiter that leaves empty-handed ORPHANS the slot instead of freeing it: the slot keeps the collection, key and deadline, so a late reply still matches, and pcache_pull_do_reply() completes the store the waiter would have done. Final outcomes - a stored value, an oversize holder, a settled absence - still free the slot; they want no late answer. The store happens after pull_lock is released, on copies taken under it. That is not style: storing under pull_lock would serialise every node-wide pull behind one table write and nest pull_lock outside the bucket locks, deadlocking any future caller that pulls while holding a bucket. The present-key check is pcache_ht_probe() == 0 - allocation-free, and exactly 0 for a live key. Testing != -2 instead would read a pkg-exhaustion -1 as 'present' and silently stop repairing under the very memory pressure that matters. A late answer never overwrites a live entry: this is read repair, fill-what-is-missing, and a local write in the intervening window is by definition fresher than a peer's copy of what we asked for. No in-flight TTL correction, deliberately: the peer computes ttl_left immediately before sending, so a peer that was busy for seconds still reports a current remaining TTL. Charging the requester's elapsed time would subtract the peer's own delay from a figure that never included it. An optional pull_linger_ms (default 0 = off) bounds how late is too late, for deployments that DELETE keys and cannot risk a peer's copy resurrecting one; write-and-expire deployments need no bound because the value carries its own expiry. Orphans are bounded three ways: the reaper frees them at deadline + PCACHE_PULL_ABANDON_US (counted as pulls_orphan_expired, distinct from pulls_abandoned - that warning means 'a caller never collected', which an orphan's caller did); a completed orphan frees itself the moment it stores; and when the pool runs dry, allocation steals the orphan whose deadline passed longest ago, never a live pull. The pulls_in_flight gauge excludes orphans - it is documented as the stat that reads 0 when nothing is being asked, and counting orphans would fire that leak alarm on the ordinary outcome of a timeout. Six new statistics tell the story: pulls_orphaned, pulls_late_stored, pulls_orphan_evicted, pulls_late_superseded, pulls_late_expired, pulls_orphan_expired.
d51c274 to
2fc31a9
Compare








Summary
This PR introduces
cachedb_perf— a new, from-scratchcachedbbackend for large, high-churn local caches, selected by URL scheme (perf://). I'm trying to build a much faster local cache module by combining a cache-conscious, lock-free-read design (CLHT / MemC3 lineage) with what recent Linux kernels make possible: overcommit hugetlb pools, shmem THP,MADV_COLLAPSE,MADV_POPULATE_WRITEand swap pinning.It implements the same
cachedb_funcsvtable as every other backend, so any module taking acachedb_urlworks unchanged, and core script usage (cache_store("perf", ...)) only changes the backend name. v1 is a single-node in-memory cache with optionaldb_*persistence — whole collections can be saved to and loaded from an SQL backend, so state survives a restart (see the DB persistence section below). What it deliberately does not do iscachedb_local-style per-operation replication (cluster_idwrite-through); cross-node sharing is instead a shared-DB refresh model (theperf_synccluster-sync below), not a streamed op log.The module is functionally complete (data ops, expiry, runtime growth, statistics, huge-page arena, a full introspection MI, observability events,
db_*persistence, and aperf_synccluster refresh), validated by built-in selftests, a script-level end-to-end suite, and a multi-process correctness soak.Motivation
Found while benchmarking
topology_hiding's cacheDB state backend (#4114): settingcache_collections "th=16"cut the load balancer's CPU from 45% to 29% at 4000 CPS. The root cause is structural:cachedb_local's hash table is sized once fromcache_collectionsand never resized. The default is 512 buckets, most deployments never set the parameter, and at 50 000 entries that is a load factor of ~98 — roughly 50 string compares and 50 dependent cache misses per lookup. The module also exports zero statistics, so the cliff is invisible in production.Rather than progressively rewriting a module every deployment depends on, this is a clean backend: operators opt in per collection by changing a URL.
Configuration
Every parameter is optional — with none set you get a single
defaultcollection at 16384 buckets, plain shm, growth and the expiry sweep on. In practice you declare the collection(s) you use and point consumers at them.cache_collectionsdefault(14→ 16384 buckets)nameorname=size,;-separated.sizeis the log2 of the initial bucket count, clamped to[4, 24]; the table grows past it at runtime, so it is a starting point, not a ceiling. Thecachedb_local/rreplication marker is rejected — this cache is single-node.cachedb_urlperf://(thedefaultcollection)perf:///th) or, equivalently, its host part (perf://th);perf://alone selectsdefault. Prefix a group (perf:grp:///th) to address a specific URL from the script. Naming an undeclared collection is a startup error. Repeatable.expiry_sweep_period10disables reclamation (expired entries hold their memory until overwritten).growth_load_factor20disables growth (fixed-size table, i.e.cachedb_localbehaviour).growth_budget4096arena_hugepage_mb0(off)MADV_COLLAPSE→ 4K ladder;0uses plain demand-faulted shm. WantsLimitMEMLOCK=infinity; warns and continues unpinned otherwise.arena_selftest0htable_selftest0db_urldb_*(SQL) backend to persist collections to (the matchingdb_*module must be loaded). Unset = no persistence.db_tablecachedb_perfdb_mode0(off)persist_collections:1= load at startup,2= load at startup + save on graceful shutdown.0= MI-only.persist_collectionsdb_modeauto-loads/saves.perf_save/perf_loadstill work on any collection on demand.sync_cluster_id0(off)perf_sync(needsclustererloaded + adb_url). Soft: with either missing,perf_syncdegrades to a DB save.The motivating setup — topology_hiding state backend:
Generic script cache + glob operations:
MI commands
The full management interface — the operator visibility
cachedb_localnever had. Every command is lock-free (seqlock reads), so a key scan or dump never stalls SIP traffic; every name carries theperf_prefix to match the script functions and stay clear of the core's bareget/set. Each is invoked module-namespaced ascachedb_perf:<command>(OpenSIPS 4.0+). Arguments in<>are required,[]optional; an omittedcollectionmeans the grouplesscachedb_url's collection (wherecache_store("perf", …)writes).perf_stats[collection]perf_stats_reset[collection]perf_keys<glob> [collection] [limit]KEYSequivalentperf_scan<cursor> [glob] [count]SCAN) over the default collection: start at cursor0, repeat with the returned cursor until it comes back0.countbounds the buckets visited per call. The answer for a large cache, whereperf_keyswould truncateperf_dump<glob> [collection] [limit]perf_keysbut includes values — opt-in, never the defaultperf_get<key> [collection]-1= never) and sizeperf_set<key> <value> [ttl] [collection]ttlin seconds (0or omitted = never expires)perf_ttl<glob> <ttl> [collection]expiresstore, readers undisturbed);ttlin seconds (0= never). Returns the count updated. A literal key matches exactly oneperf_del<glob> [collection]perf_del()script functionperf_save[collection]db_urlbackend (all declared collections if none named)perf_load[collection]db_urlbackendperf_sync[collection]MI parameters are named, so any sensible subset resolves — e.g.
perf_keys <glob> limit=Nwithout a collection, orperf_set <key> <value> collection=Cwithout a ttl.Note the argument order: the glob-taking commands (
perf_keys,perf_dump,perf_del,perf_ttl) take the glob first and the collection second, soperf_dump mycolllooks for keys namedmycollrather than dumping that collection — useperf_dump "*" mycoll. Onlyperf_get/perf_setlead with a key. Quote globs, or the shell expands them before opensips-cli sees them.perf_statscounters are cumulative since startup (or the lastperf_stats_reset), so a hit rate read straight after a restart is dominated by sequential requests for dialogs older than the cache and recovers only as those age out. Either reset once the cache has warmed, or — better for monitoring — poll twice and difference the counters.Events
Four EVI events let a script or monitor react to the cache. Each is gated by
evi_probe_event(), so with no subscriber it costs one shared read, and none sit on the lock-free get/set path.E_CACHEDB_PERF_EXPIREDevent_expired_collections)collection,keyE_CACHEDB_PERF_NOMEMcollection,key,sizeE_CACHEDB_PERF_GROWNcollection,prev_buckets,buckets,splits,entriesE_CACHEDB_PERF_MEM_DEGRADEDrequested_mb,tier,backing,overcommit_pagesE_CACHEDB_PERF_SYNCEDperf_synccollection,source_nodeDB persistence
With
db_urlset, a whole collection can be persisted to anydb_*(SQL) backend. The DB is a shared, durable store; the cache is an in-memory view over it. A save is a full snapshot — the collection's rows are deleted and every live entry re-inserted; a load restores them. TTLs are stored as absolute wall-clock time so they survive a restart (the cache's own expiry is monotonic ticks, which reset on reboot); already-expired rows are skipped on both save and load, and native counters round-trip as their decimal value. This is single-node durability; cross-node sharing over the same DB isperf_sync(below), still not per-operation replication.The table (default
cachedb_perf) has four columns:collection(string),pkey(string),pvalue(BLOB, binary-safe),expires(int, absolute unix time,0= never). The startup load runs before the workers fork, so every worker starts with a warm cache.The snapshot is one transaction — measured
A save is one statement per row, so on a backend that commits each one separately it is not merely slow, it does not finish. Before this was addressed, 30 000 entries to
db_sqliteran at ~140 rows/s, blew the 60 sSHUTDOWN_TIMEOUT, and the core aborted the process with 7 918 of 30 000 rows written — and since a save deletes the collection first, what remained was a partially written table where a complete snapshot had been, with nothing in the log or the reply to say so.The whole snapshot now runs inside one transaction, which fixes both halves:
db_sqlitedb_redisThe ~600× on SQLite is the per-row
fsyncdisappearing; it was never slow at inserting.db_redishas noraw_query, so it takes no transaction — it has no per-row commit to amortise, but it does pay a network round trip per row, which is why it is now the slower of the two.More important than the speed: the delete and the inserts are atomic. Uncommitted work is rolled back when the connection closes, so an interrupted or failed save now leaves the previous snapshot in place rather than destroying it. The error paths return without committing deliberately.
No single spelling starts a transaction everywhere, so both are tried — SQLite and PostgreSQL take
BEGIN TRANSACTION, MySQL takesSTART TRANSACTION. The bareBEGINall three accept is unusable:db_sqlite'sraw_queryonly reaches its exec path for statements at least as long asselect, and mis-parses anything shorter as a SELECT.A save also logs its duration and rate, and warns past 10 s that a shutdown save has a 60 s budget — noting when the backend took no transaction, since that is the case where the budget is at risk.
With db_redis
db_redisneeds a schema declared for the table before first use — it fails at load without one, and none ships forcachedb_perf:Its primary key is a single column while a row here is identified by (collection, pkey), so persist one collection: with two, identical key names would collide and the later save would overwrite the earlier. The
/0database component is required by the core URL parser even though Redis-cluster mode ignores it.Cluster sync
perf_sync [collection](MI and script function) builds on the same DB: it saves the collection, then signals the cluster overclustererto reload it — one message per sync, not per operation, so the hot path is untouched. A reload overwrites a peer's copy from the DB, so it's for single-writer / read-replica topologies (one authority updates the DB, the others refresh); a node reloads and raisesE_CACHEDB_PERF_SYNCED. With no clusterer /sync_cluster_id0 it degrades to a DB save. Same blocking cost asperf_save, so: an occasional refresh, not a live primitive.The capability registers with the clusterer, so it shows in its
clusterer_list_capMI — verified on a single-node cluster (withcachedb_perfloaded beforeclusterer, confirming the soft dependency reorders init):The allocation-free read —
get_buf(new optional cachedb endpoint)Profiling the read path (production
F_MALLOCallocator) showed the biggest removable cost is not the lookup — it is the vtable contract.get()must return a value the caller owns andpkg_free()s, so every hit pays apkg_malloc+pkg_free+ secondmemcpythat exist only to satisfy ownership: a fixed ~70 ns/op (15% of a 470 ns lookup over 50 000 entries; 30% of a 236 ns cache-resident one).memcpyitself never even appears in the profile.So this PR adds a small, optional core endpoint —
get_buf(), advertised byCACHEDB_CAP_GET_BUF— that reads into a buffer the caller already owns: no allocation, one copy instead of two. Any backend may implement it; every caller keepsget()as the fallback, and nothing about the existing endpoints changes (in particularget_counter()keeps its documented{-2,-1,0}return set).get()ns/opget_buf()ns/opRuns alternate get/get_buf in one binary to cancel warm-up bias; run-to-run variance on this box is real (one pair shows only −6%), so the honest claim is 20–27%.
The contract is written for the failure cases, since those are what callers get wrong: the buffer must be private to the calling process; it may be written speculatively and abandoned, so its contents are undefined on any non-hit;
*vlen/*neededare zeroed before anything else; and a value that does not fit reports its size via*neededwhile*vlenstays 0 —{buf, *vlen}is always a validstr. Inside cachedb_perf both entry points share one implementation of the seqlock read (optimistic loop, lock fallback, re-route retry, overflow leg, expiry), so they cannot disagree about which record they return.The first consumer is
topology_hiding'sth_state_urlpath (#4114): the conversion is written and tested (both the in-place path and the too-small→allocated fallback), and will be pushed to that PR once this one merges, since it needs this core endpoint to compile.Enabling the kernel memory backing
The huge-page arena (
arena_hugepage_mb) climbs a detect-by-trying ladder atmod_init: it attempts each tier in turn and keeps the best one the running kernel actually grants — you do not pick a tier, you enable what you can and the module reports what it got. The tiers, fastest to slowest (the cost is the isolated 2 MB pointer-chase from §5 of the study):MAP_HUGETLBsysctlmlockneededMADV_HUGEPAGEsysfswritemlock(see below)MADV_COLLAPSEafter fillmlock(see below)mlock(still reserved+pinned)Tier 1 — overcommit hugetlb (the one to prefer: on-demand, nothing held while the cache is small, and no memlock grant needed). Allow enough on-demand 2 MB pages for the arena (
arena_hugepage_mb / 2, plus a small margin):Tier 2 — shmem THP (used if tier 1 is unavailable). Put shmem THP in
adviseso it honours the module'sMADV_HUGEPAGE:Tier 3 —
MADV_COLLAPSEneeds no sysctl (kernel ≥ 6.1); on some 6.12 builds it also wants tier 2'sshmem_enabled=advise. Tier 4 is the default and needs nothing.Swap-pinning (
mlock) — tiers 2–4 only. When tier 1 is unavailable the arena is a regular shared mapping, which the modulemlock-pins pre-fork so it can't be swapped out from under the lock-free readers. systemd's defaultLimitMEMLOCK=65536(64 KB) makes thatmlockfail on any real arena — the module then warns and runs unpinned (swappable); the huge pages still form, they are just not pinned. Tier 1 (MAP_HUGETLB) is exempt and needs none of this. To pin tiers 2–4, grant it once:Turn it on and confirm what landed:
opensips-cli -x mi cachedb_perf:perf_stats # -> memory_tier (1 hugetlb .. 4 plain 4K) + memory_backingmod_initalso logs the achieved tier and, when it falls short of tier 1, the exactsysctlto reach it and the measured cost of running without it.The study
Everything below was measured, not assumed — the benchmark rig ships in-tree (
modules/cachedb_perf/bench/,make run, no OpenSIPS build needed) and every figure is reproducible. Hosts: Xeon E5-2699 v4, kernels 5.4 / 6.8 / 6.12; the NUMA numbers come from a vNUMA-pinned two-socket guest on the same silicon. The rig models structures and cache behaviour (single process, threads); it ranks designs rather than predicting server throughput.1. The index structure
strncmp(cachedb_localtoday)Load factor alone is a 20× spread. The chosen design is within 8% of the fastest structure measured, and the fastest one (flat open addressing) is impossible to resize across processes in shm. Also checked:
core_hash()is not at fault (chi²/df 0.65–1.18 vs FNV-1a on thids, dialog ids, AoRs and call-ids — statistically indistinguishable), so the module keeps it.2. Concurrency — an honest negative result
The hypothesis was that
cachedb_local's write-lock-on-every-read destroys scaling. It does not: with a well-sized table workers rarely collide on a bucket lock, and it scales 8.4× on 8 threads. The 4× gap is a per-operation constant factor (no atomic RMW on reads, one cache line per bucket, tag filtering) — not a scaling win. Against the shipped 512-bucket default the gap is ~90×.3. The read protocol — measured before being believed
Readers take no locks: a per-bucket seqlock with bounded retries and a sleeping-lock fallback. What makes this legal in OpenSIPS specifically: shm is mapped once before fork and never unmapped, so a stale pointer read is garbage-but-not-a-fault, and the version re-check discards it — the value is always copied out inside the optimistic section, with every length clamped and every pointer extent-checked before use.
The one credible alternative (QSBR / pointer-publication, no version check at all) was implemented in the rig and rejected on the numbers: identical at 100% reads (on x86/TSO the version loads hit the already-loaded bucket line — the seqlock is free) and ahead only under single-hot-bucket write contention that SIP traffic doesn't exhibit (seqlock retries measured at 1.2 per 1000 reads on a uniform 95/5 mix). The useful piece survived without any grace-period machinery: a byte-identical
set()that only refreshes the TTL — the dominant write in the motivating workload — takes the bucket lock but skips the version bumps and the memcpy entirely. One atomicexpiresstore; concurrent readers of the bucket are undisturbed.4. What was rejected: write staging and queueing
A shared staging buffer loses throughput as threads are added — one atomic append offset is a hotter point of coordination than thousands of bucket locks. Queued writes do less than half the work of writing directly, and break read-your-writes semantics. The rule this established shapes the whole module: per-process regions win for allocation (the arena uses them — zero atomics on the alloc fast path), but never for staging live entries.
5. Modern-kernel memory backing
OpenSIPS shm today is demand-faulted 4K pages — no
MAP_POPULATE, no hugepages, nomadvise, nomlockanywhere inmem/. A multi-hundred-MB cache pays for that in TLB misses. Four routes to 2M pages, ranked as a runtime fallback ladder:vm.nr_overcommit_hugepages+MAP_HUGETLBshmem_enabled=advise+MADV_HUGEPAGEMADV_COLLAPSEafter fillshmem_enabled=neveradviseKey findings:
MADV_COLLAPSEdivergence proves version checks lie, and they lie in both directions: a later 6.12 (6.12.96, Debian 13) collapses fine withshmem_enabled=neveragain — same major version, opposite behaviour, and the probe silently got the better tier. The module already does this inmod_init: it reports the achieved tier and, when hugetlb is unavailable, logs the exact sysctl and the measured cost of running without it.THPeligible: 0, collapseEINVAL; the fix is reservePROT_NONE, thenMAP_FIXEDthe shmem at a 2M boundary), and a shmemMADV_COLLAPSEcreates the huge folio without PMD-mapping the caller — verify via theShmemHugePagesmeminfo delta, not smaps.mlockinmod_initworks across fork (locks are not inherited, but the pages are shared — one pre-fork lock pins the arena for every worker). Found a production blocker on our own SBCs while checking: systemd's defaultLimitMEMLOCK=65536meansmlockof any real arena fails — the unit needs a one-line drop-in.6. Expiry
min_expires, unlocked skipEven the full sweep is 0.13% of a core — expiry is a memory reclamation problem (entries squatting up to
cache_clean_period), not a CPU problem. Themin_expireshint gets 30× for zero hot-path cost; the wheel's further 84× buys nothing and costs 74 ns/insert plus 16 B/entry.7. NUMA — measured on a pinned two-socket testbed
Measured in-guest on a Proxmox VM with vNUMA bound per host socket (
numaN: ...,hostnodes=N,policy=bind, dual E5-2699 v4 host — plainnuma: 1withouthostnodesfabricates topology over one memory domain and measures nothing). Two consequences, both already reflected in the design rather than motivating changes:Where NUMA does matter for the roadmap: page walks against remote memory amplify TLB-miss cost, so the huge-page backing is expected to be worth more on two sockets than the 1.42× measured on one — to be quantified in the end-to-end benchmark. One refinement from the same testbed: with
pdpe1gbexposed, 1 GB pages are allocatable at runtime on a fresh boot (2 granted right after boot) — the earlier "unobtainable" holds only once uptime fragments memory. The ruling against them stands on arithmetic: 2 M pages already give a ≤1 GB arena full TLB coverage on this hardware.Measured: cachedb_perf vs cachedb_local, in-process
The bench rig above ranks designs in a single process. This measures the real modules — real OpenSIPS 4.1-dev, real shared memory, N real worker processes — driving the
th_storeaccess pattern (16-byte thid keys, 200-byte values, 95% get / 5% set). Both backends did byte-for-byte identical work (same 22.8 M hit count). Release build (-O3),Q_MALLOC, pinned to one 8-core socket.Same conditions — both collections sized to 65536 buckets (
th=16):Two effects drive the gap. Scaling: cachedb_perf's lock-free reads scale 10.0× from 1→8 workers vs cachedb_local's 6.6× (it takes a bucket lock on every read). The default: most deployments never set
cache_collections, so cachedb_local runs at its 512-bucket default — at 50k entries that is a load factor of ~98, 3529 ns/op, and cachedb_perf is 7.8× faster than cachedb_local as typically shipped.Honest notes: numbers include a real
pkg_malloc+freeof the 200-byte value on every get (the th_store copy-out), so this is per-operation cost, not a bare lookup. This deliberately isolates the cache from the SIP layer. In a full LB the per-call cost is SIP parsing, header manipulation and transaction state plus the th_store put/get — there is no per-call encryption (th_store values are stored in the clear; the only crypto is one cheap MD5 to derive the key), so the cache is a direct share of that cost. An end-to-end run under 50 000 held calls confirms the direction below.Is it only fast at reads? The mix swept, with
cachedb_redisfor scaleThe numbers above use the
th_storeaccess pattern (95% get / 5% set), which invites a fair question: is this just a read cache that gives the win back on writes? It is not. Sweeping the read/write mix withcdbbenchdriving thecachedb_funcsvtable directly — no consumer module in the path — gives:cachedb_perf is 5.6–9.9× faster than cachedb_local on pure writes, and the margin widens with concurrency — cachedb_perf stays flat (562 → 639 → 632 ns as workers go 1 → 4 → 8) while cachedb_local degrades (3123 → 4239 → 6226). Writers in both take a bucket lock; the difference is what happens inside it.
cachedb_local'sadd/setpath parses the stored value, reformats it and reallocs the entry under the lock, so the critical section grows with contention; cachedb_perf writes fixed-width fields in place under a seqlock bracket.cachedb_redisover loopback is 250–580× slower here. That is not a criticism of Redis — it is the cost of a synchronous round trip per operation, and it is exactly why a local cache exists. Use Redis when state genuinely must be shared between nodes; useperf://when it must not leave the box.End-to-end: TH under 50 000 held calls
Same LB, topology_hiding with
th_state_urlpointing at each backend (65536 buckets), ramped while holding ~50 000 concurrent calls. "Sustained" means <5% failures and peak concurrency ≤75k (actually still holding 50k, not backlogging):cachedb_perf sustains the 6000-CPS rung where cachedb_local breaks — a sustained-ceiling lift from ~3941 to ~5775 CPS (~1.5×) at 50k live th_store states. The end-to-end gain is smaller than the isolated-cache 2.3–7.8× because SIP processing is the larger share of per-call cost, but it lands exactly where the cache matters: the high-concurrency point where cachedb_local's lock-on-every-read serializes the workers.
At 100 000 concurrent calls: cachedb_perf-TH vs dialog-TH vs no-TH
Pushing to 100 000 held calls, comparing three topology-hiding strategies on the same LB (huge pages / THP enabled): cachedb_perf-backed th_store, the in-memory dialog module (
force_dialog), and plain record-routing with no topology hiding at all.At 100k concurrency cachedb_perf-TH is nearly as cheap as doing no topology hiding at all — it tracks the no-TH curve and holds 4000 CPS at 93% CPU. dialog-TH is the loser here: it saturates CPU by 3000 CPS, breaks at 4000, and carries ~2.5× the resident memory (a full per-dialog state machine + timers vs one compact th_store entry). This is a crossover from lower concurrency, where dialog leads — cachedb_perf's flat per-entry cost wins as the live-state count climbs.
Caveats: the single load generator is unstable at 100k (some cachedb_perf mid-rungs showed generator-side failures at low LB CPU — discarded); and the huge pages here are whole-shm THP that benefits all three equally — the module's own huge-page arena is a separate mechanism, measured on its own in the next section.
Re-verified end to end, with the backend's own counters as proof
The runs above were re-done with one addition: every rung asserts, over MI, that the backend under test actually did the work —
cachedb_perf:perf_statsmust show stores in thethcollection and zero dialogs; the dialog arm must showdialog:processed_dialogsand zero cache stores; the no-TH arm neither. A rung that fails its assertion is reported invalid and discarded rather than silently contributing a number. All twelve rungs passed.At 4000 CPS
cachedb_perf-backed topology hiding costs 4 CPU points and 4% more memory than doing no topology hiding at all, while holding concurrency at exactly 50k. Dialog-backed hiding costs 15 CPU points, 1.8× the memory, and is dropping 10.3% of calls — its rising "concurrency" is a backlog, not held calls. This is the same result as the 100k run above, now with the cache proven to have been exercised rather than assumed.Huge-page arena (CP-20)
The arena can now back its chunks with 2 MB huge pages instead of 4 K (modparam
arena_hugepage_mb; the reservation is 2M-aligned, mlock-pinned, created pre-fork and shared by all workers). Measured on the real module (−O3, 8 workers, medians of repeated runs):The gain is larger at 50k, where the working set spreads across enough memory to thrash the 4K TLB — exactly the case huge pages relieve. It's below the 1.19–1.43× the pointer-chase showed in isolation because each operation also pays for the hash, the tag scan and a copy of the value. Detection is by trying each tier (hugetlb → THP → collapse → 4K), never by kernel version;
mlockwantsLimitMEMLOCK=infinityand warns-and-continues otherwise.Design in brief
cachedb_localnests the shm allocator inside bucket locks in five places); records are pre-built beforelock_get, frees happen strictly after release.add/substore an int64 and accumulate fixed-width under the bucket lock; every user-facing read formats them as decimal. No parse/format/realloc in the critical section.Script interface
Single-key operations go through the core cache functions unchanged. The module's own multi-key operations are
perf_-prefixed with Redis verbs — deliberately not thecachedb_localparity names (cache_remove_chunk/fetch_chunk), so migrating those two calls requires a script change; everything else is drop-in:All three ride one lock-free walker (Redis SCAN-class guarantee) with binary-safe JSON escaping;
iter_keysuses the same walker. Two startup selftest modparams (arena_selftest,htable_selftest) ship as permanent diagnostics and fail startup on any mismatch.The same walker backs the introspection MI (full command table in the MI commands section above) — the operator visibility
cachedb_localnever had, and lock-free so a key scan never stalls SIP traffic.perf_scanis the answer for a large cache whereperf_keyswould truncate: its cursor is an ascending bucket index, so it stays valid across a concurrent resize and returns every entry present throughout at least once — without Redis's reverse-binary cursor masking, because the table only grows (buckets never move).Status
Module shell, URL/collection parsing (size clamped to [4,24] —
1 << sizeon an unbounded unsigned is UB), memory-tier probe with actionable sysctl guidanceSlab arena (size classes, per-process allocation, donation/refill pools)
Table core: 64B buckets, SWAR tag scan, seqlock reads with full copy-out validation, versionless TTL bump, overflow
cachedb vtable: get/set/remove/add/sub/get_counter + native counters;
iter_keysperf_del/perf_mget/perf_mget_jsonSelftests + script-level end-to-end suite
Expiry sweep — hint-routed (per-bucket min-expires hints in sweep-friendly parallel arrays, 16 per cache line; the hot TTL-bump path never writes them), timer-driven via
expiry_sweep_period(default 1 s), reclamation through the global pool strictly after lock releaseStatistics — per-process sharded counters (one 64-byte line per process, summed only at read time; a shared
update_statcounter would recreate the 0.72× collapse measured above), exported as tencachedb_perf:core stats and a per-collectionperf_statsMI (load factor, overflow, seqlock retries/1k, backing tier,expired/destroyed, and a hit rate whose accompanying note follows the measured value rather than asserting a verdict).perf_stats_resetre-baselines the cumulative counters for a fresh measurement interval without a restart, leaving live gauges aloneLinear-hash growth + maintenance timer — the table now resizes itself (the thing
cachedb_localfundamentally cannot do): one-bucket-at-a-time splits driven from the single-process maintenance timer, no rehash, overflow left findable;growth_load_factorkeeps the bucket shape as entries scale. Verified: 1000 entries → 484 splits → 500 buckets, all keys intactIntrospection MI —
perf_keys/perf_scan/perf_dump/perf_get/perf_set/perf_ttl/perf_delas MI commands, all lock-free (a key scan never stalls writers, unlikecachedb_local's).perf_scanis cursor-based (Redis SCAN): an ascending bucket cursor, stable across a concurrent resize, every entry returned at least once. Verified over a datagram MIObservability events (EVI) —
E_CACHEDB_PERF_EXPIRED(per reaped key, opt-in per collection),E_CACHEDB_PERF_NOMEM(a write dropped because the arena is full),E_CACHEDB_PERF_GROWN(a table resized, with the before/after span),E_CACHEDB_PERF_MEM_DEGRADED(huge pages requested but the arena landed below hugetlb). Eachevi_probe_event()-gated (free with no subscriber) and off the hot path; verified end-to-end overevent_routesHuge-page arena backing — 2M-aligned mlock-pinned reservation via the detect-by-trying ladder (
arena_hugepage_mb), lock-free bump from it, shm_malloc fallback; measured +7–13% (see above)Multi-process correctness soak — forked worker processes hammer one live backend (get/set/remove/add) while the maintenance timer splits buckets underneath them, checking four invariants: no torn read, no lost update, no lost key across splits, no crash/UAF. Found and fixed a real fork-safety bug (see below). Post-fix: 8 processes, 24M ops, 3093 concurrent splits, 0 crashes,
torn_reads=0, counter sum == adds, all immortals intact; clean under theQ_MALLOC_DBGredzone allocator and under all three core allocators (F_MALLOC/Q_MALLOC/HP_MALLOC, driving both pkg and the arena's shm chunk backing)Portability — built and exercised outside the usual glibc/x86 dev box, in containers: Alpine 3.24 (musl 1.2, gcc 15.2) and RHEL 9 / UBI9 (glibc 2.34, gcc 11.5). Zero warnings on either, with the arena/table selftests and the multi-process soak passing on both. The module needed no conditional compilation for musl; the one portability defect the exercise turned up was in the core rather than here (
lib/url.cundefining_GNU_SOURCEbefore the headers that need it, which hidesclock_gettime/ctime_ron musl), submitted separately as core: fix build on musl libc (Alpine) - stray #undef _GNU_SOURCE in lib/url.c #4119End-to-end
th_state_urlbenchmark againstcachedb_local(50k held calls) and against dialog-based topology hiding (100k held calls) — both sections aboveDB persistence — whole-collection save/load to any
db_*backend (perf_save/perf_loadMI, plusdb_modestartup-load / shutdown-save), TTLs kept as absolute wall-clock time so they survive a restart. The snapshot runs in a single transaction where the backend supports one, which makes it both fast and atomic — 30 000 entries save in 0.35 s ondb_sqlite(against ~60 s and an aborted process before), and an interrupted save now rolls back rather than replacing a good snapshot with a partial one. Verified end to end withdb_sqlite(save → shutdown-save → startup-load, values intact, TTL decremented across the cycle) and withdb_redis, which takes no transaction and measures 5.05 s for the same rows. Rows whose wall-clock expiry has passed are dropped at load rather than merely skipped — otherwise, withdb_mode=1or after any shutdown that was not graceful, dead rows accumulate in the table indefinitely. Single-node durability, not replicationCluster sync (
perf_sync) — MI command + script function that saves this node's collection to the DB, then signals peers over theclustererAPI to reload it (one message per sync, not per operation); each peer reloads from the DB and raisesE_CACHEDB_PERF_SYNCED. Soft dependency — degrades to a DB save with no broadcast if clusterer/sync_cluster_idis absent. A pull-from-DB refresh model for single-writer/read-replica topologies — deliberately not/r-style per-operation replication. Verified on a single-node cluster: the capability registers and lists in the clusterer'sclusterer_list_capMI (cachedb-perf-sync, state Ok), a softDEP_SILENTclusterer dependency reorders init so it works regardless of load order, andperf_syncdegrades cleanly with no clusterer — no crashes. Each collection also reportslast_sync_out/last_sync_in/last_sync_sourceinperf_stats(shown only whensync_cluster_idis set): the clusterer's ownclusterer_list_capstate ofOkfor this capability only means registered and enabled — the module registers withstartup_sync=0and takes no part in the clusterer's startup data-sync — so the honest convergence signal lives in the module's own stats. The multi-node broadcast→reload fan-out follows theratelimitclusterer pattern and is verified on a two-node cluster: with both nodes pointed at one shareddb_sqlitefile and linked overproto_bin,cachedb-perf-syncregisters on both,perf_syncon node 1 returns{collections:1, saved:3, broadcast:1}, and node 2 logscluster sync: reloading <sync> from DB (issued by node 1), reloads every key with its TTL intact and raisesE_CACHEDB_PERF_SYNCEDcarryingsource_node=1. Verified in both directions (node 2 → node 1 likewise,source_node=2)Read/write mix swept — 5.6–9.9× over
cachedb_localon pure writes (flat vs degrading as workers rise), and an assertion-verified end-to-end three-way where perf-TH lands within 4% of no-TH on CPU and memoryAllocation-free read — the optional
get_buf()cachedb endpoint (CACHEDB_CAP_GET_BUF) and its cachedb_perf implementation; one shared read path for both entry points; measured 397.7 → 290.1 ns/op at the median (table above)Correctness: what the multi-process soak caught
A lock-free read path plus a table that resizes itself under live traffic is exactly the kind of code where a single-process selftest passes and production still corrupts memory. So the soak (
bench/cdbstress.c) runs the real thing: 8 worker processes on one shared backend, a get/set/remove/add mix, with the maintenance timer splitting buckets the whole time. Every value is written all-bytes-equal so a torn read is visible; counters are hammered withadd(+1)so a lost update shows as a shortfall; a set of keys is inserted once and never removed so a split that drops one shows as a miss.It failed inside a second — a segfault on an impossible size class (88) read out of a cell's class byte. Root cause: after
fork()every child holds a copy-on-write copy of the parent's private allocator hoard (same bump pointer, same free-list cell addresses), andpcache_arena_child_inithad each child donate that hoard to the global pool. The identical physical cells were enqueued once per child, popped by several processes at once, and written through concurrently — one process's value byte landed on another's class id. The fix: a child drops its inherited copy and carves its own chunk on first use, never donating cells it doesn't own. After it, the full soak is clean — 24M ops, 3093 concurrent splits, no torn reads, counter sum equals total adds, every immortal key intact — and equally clean under theQ_MALLOC_DBGredzone allocator and under each of OpenSIPS' three core allocators (F_MALLOC,Q_MALLOC,HP_MALLOC), which back both the per-process state and the arena's shm chunk allocation.And what production caught that the soak did not
The soak runs a single collection with generous TTLs, so it never combined
overflow chaining with expiry reclamation — and that combination was the one
that mattered. On a live SBC the module crashed repeatedly inside the arena's
free path, on an impossible size class, with topology-hiding keys going
missing.
struct povf, the overflow node, had itsnextpointer at offset 0 — butbyte 0 of every arena cell is the size class, read by both free paths. Linking
a node wrote the pointer's low byte over the class id (0x40/0x80/0xC0 →
"class" 64/128/192), indexing past the 21-entry class table and corrupting the
pool, so the crash surfaced far from the cause. It needs overflow and the
expiry sweep together to trigger, which is why a table with room to spare never
showed it.
The fix reserves byte 0 in
struct povfand hardens both free paths to log andleak on an invalid class rather than corrupt. Reproduced deliberately
afterwards — a churn set with a 2 s TTL, a 1 s sweep and growth enabled catches
it in under a minute — and the soak was extended to cover the same shape.
And what an adversarial design review caught before it shipped
Designing
get_bufbegan with a red-team pass over the read/write protocol, which found three latent defects in the existing code — each now a separate commit:seq++; smp_wmb()); x86-64 was never affected, which is exactly why no soak had caught it.rflagssurvived an in-place overwrite. Storing an 8-byte string over a key that had been a native counter leftPCACHE_F_INTset, and the read path then formatted the ASCII as an int64:cache_store("…","12345678")read back as4050765991979987505. The flag is now cleared inside the version bracket.PCACHE_REC_HDR + klen + vlen > boundon unsigned values wraps for a tornvlen, skipping the clamp on the one path that needs it; it is now subtractive and cannot wrap.The standalone rig behind every figure above lives in
modules/cachedb_perf/bench/(make run, no OpenSIPS build needed) — full measurement history, every rejected alternative and why — so the numbers here are reproducible rather than asserted.