cachedb_perf: high-performance local memory cache built on modern kernel features#4118
Open
Lt-Flash wants to merge 11 commits into
Open
cachedb_perf: high-performance local memory cache built on modern kernel features#4118Lt-Flash wants to merge 11 commits into
Lt-Flash wants to merge 11 commits into
Conversation
Lt-Flash
pushed a commit
to Lt-Flash/opensips
that referenced
this pull request
Jul 24, 2026
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.
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. 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; glob script functions perf_del/perf_mget/perf_mget_json. - 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 - a maintenance/bootstrap operation, not per-request. - 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.
Lt-Flash
force-pushed
the
feature/cachedb-perf-devel
branch
from
July 25, 2026 00:05
cf2d1e4 to
642c828
Compare
added 7 commits
July 25, 2026 16:39
…rors
A passing startup selftest printed two ERROR lines:
ERROR: pcache_ht_add: value of <nan> is not an integer
ERROR: pcache_ht_store: key 7 + value 65536 bytes exceed the 65536
byte record limit
Both are the selftest deliberately driving a rejection path - a
non-numeric add and an oversize store - to prove each is refused. The
refusals were correct; only the logging was wrong, and it made a clean
run look like a broken one.
Adds a file-local st_expect_reject flag, set around just those two
calls, that downgrades exactly those two messages to DBG. It is only
ever set pre-fork and single-threaded from the selftest, so there is no
concurrency concern, and it is never set in normal operation - a genuine
non-numeric add or oversize store still logs at ERROR, which is
verified from a script.
The obvious alternative, wrapping the calls in the core's
set_proc_log_level(), is unusable here and worth recording: it writes
pt[process_no], and the process table has not been allocated yet when
mod_init runs the selftest, so it segfaults on a NULL pt.
Entries whose wall-clock expiry has passed were skipped at load but left in the table. The snapshot is only ever rewritten by a save, so with db_mode=1, or after any shutdown that was not graceful, dead rows stayed there indefinitely - reread and discarded on every start, and cluttering the table with what looks like live data. The load walk already identifies them, so it now counts them and issues one ranged delete per collection once the result set is released: collection = X AND expires > 0 AND expires <= now. The expires > 0 term is load-bearing: 0 means "never expires" and would otherwise match the <= comparison and destroy exactly the entries meant to be kept. A failure to delete is logged as a warning and does not fail the load - the entries are ignored either way, and the next save clears them. Verified against a table holding three expired rows, one live row, one never-expiring row and an expired row in a collection that is not loaded: the three expired rows are removed, the live and never-expiring rows survive, the other collection is untouched, and the load reports two entries.
Every arena cell carries its size class in byte 0, and both pcache_cell_free() and pcache_cell_free_global() trust that byte to pick the free list. Records honour this (pcache_rec_t.cls is offset 0, set once at carve time and never rewritten), but the overflow chain node did not: struct povf started with its link pointer, so linking a node into a chain overwrote the class byte with the pointer low byte (0x40/0x80/0xC0 for 64-byte-aligned cells). When the expiry sweep or a remove later freed such a node, the free indexed class 64/128/192 into the 21-entry class array - out of bounds - corrupting the private pool state and free lists. The damage then surfaced far from the cause: crashes in the pcache_cell_free donate walk (dereferencing a clobbered next link), lookups missing keys whose cell had been handed out twice, and shutdown segfaults in the attendant while destroying the corrupted table. Reproduced under an 8-process soak with a 1s expiry sweep and deep overflow chains; caught red-handed as a free of "class 192". Fix: reserve byte 0 of struct povf for the class id, moving the link pointer off offset 0, mirroring the pcache_rec_t contract. Also harden both free paths to validate the class byte and leak the cell with an LM_CRIT instead of corrupting the pools should it ever be invalid again.
The removes counter only tracks explicit remove()/perf_del calls, so a
TTL-heavy deployment shows a tiny removes figure while hundreds of
thousands of records quietly age out - with no counter saying where they
went. The sweep already tallied reclaimed records internally
(destroyed), but nothing user-visible carried the number.
Add two read-only statistics, both per-collection in perf_stats and
aggregated in the statistics interface:
* expired - records reclaimed by the TTL expiry sweep, kept separate
from removes;
* destroyed - total records whose cells were freed back to the arena,
from any cause (equals removes + expired; a same-key
overwrite reuses the cell in place and does not count).
With these, entries = created - destroyed holds, and a large
stores-vs-entries gap can be attributed at a glance to overwrites versus
expiry versus deletes.
Reading the health of the cache from raw hits/misses takes mental math, and the ratio is the quickest signal that cached state is being lost or aged out too early. Add hit_rate_pct (hits / (hits + misses), one decimal) to each collection in the perf_stats MI output, alongside a hit_rate_note that states the healthy expectation inline - the large majority of lookups should hit (upwards of 80% under steady dialog traffic), and a low or falling rate points at lost or too-short-lived state.
…he truth The hit_rate_note was a constant string that always read "healthy: the large majority of lookups hit (>80% ...)" whatever the measured rate. It was written as guidance on how to read the number but phrased as a verdict, so at 29.7% on a live SBC it asserted the opposite of the truth - worse than no note, since it trains the reader to skip the figure. It now follows the rate: no lookups yet, healthy at 80% or above, fair at 40% or above with the note that this is normal while the cache refills after a restart, and low below that. That restart caveat is the point. The counters are cumulative since startup, so every rate is a lifetime average: sequential requests arriving for dialogs older than the cache all miss, and keep dragging the average down long after the cache has recovered. perf_stats_reset re-baselines them so the next reading covers a fresh interval, without restarting OpenSIPS. With no parameter it resets every collection, or one by name. The counters are not rewound to do it. Each process owns its own counter cache line and must never have it written from another process, which is why they are sharded in the first place, so the reset only reads them and records a baseline that pcache_ht_totals() subtracts. A worker incrementing concurrently simply lands in the next interval. entries is deliberately excluded from that subtraction: it is a live gauge computed as created minus destroyed, so rebasing those two would report an empty cache while it still held keys. Verified - across a reset the counters go to zero while entries stays put.
A save is one statement per row, and on a backend that commits each one separately that is not merely slow, it does not finish: 30 000 entries to db_sqlite ran at ~140 rows/s and blew the 60 s SHUTDOWN_TIMEOUT, so the core aborted the process with 7 918 of 30 000 rows written. Since a save begins by deleting the collection, what was left was a partially written table where a complete snapshot used to be, with nothing in the log or the reply to say so. Wrapping the whole snapshot in one transaction fixes both halves. The same 30 000 entries now take 0.35 s (86 000 rows/s, a ~600x change), and the delete plus the inserts become atomic - uncommitted work is rolled back when the connection closes, so an interrupted or failed save leaves the previous snapshot in place instead of destroying it. The error paths deliberately return without committing for that reason. No single spelling starts a transaction everywhere, so both are tried: SQLite and PostgreSQL take "BEGIN TRANSACTION", MySQL takes "START TRANSACTION". The bare "BEGIN" that all three accept is unusable, because db_sqlite's raw_query only reaches its exec path for statements at least as long as "select" and mis-parses anything shorter as a SELECT. Backends with no raw_query at all - db_redis - simply save without a transaction, as before. The save now 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. For reference, db_redis measured 5.05 s for the same 30 000 rows: it has no per-row commit to amortise, but does pay a round trip per row.
Lt-Flash
marked this pull request as ready for review
July 26, 2026 07:34
The clusterer reports the cachedb-perf-sync capability as "Ok" in clusterer_list_cap, which is easy to read as "this node is in sync with its peers". It does not mean that: the module registers with startup_sync 0 and takes no part in the clusterer's startup data-sync, so the field only says the capability is registered and enabled, and would read "not synced" solely if it were disabled administratively. Cache convergence here is on demand - a perf_sync saves a snapshot and asks peers to reload it - so between syncs the nodes are expected to differ, and nothing reported when that last happened. Add three per-collection fields to perf_stats, shown only when sync_cluster_id is set: last_sync_out and last_sync_in, seconds since this node last pushed a snapshot and last reloaded one at a peer's request (-1 for never), and last_sync_source, the node that asked for that reload. They live in the shm collection so the MI sees them whichever process ran the sync. Also spells the distinction out in the documentation, next to the clusterer_list_cap note.
This was referenced Jul 26, 2026
topology_hiding: keep the dialog-less state in a shared store (fixes long Contact / SUBSCRIBE)
#4114
Open
added 2 commits
July 26, 2026 22:16
Every seqlock entry bump turned the bucket version odd with a RELEASE RMW. Release is one-way: it orders the accesses that precede it and permits stores that follow to be observed first. Nothing therefore kept the payload writes of an in-place update, a slot replacement, an insert, a removal, a sweep reap or a split from becoming visible before the version had turned odd. A reader could then load an even version, scan a record that was already being rewritten, re-read the same even version and accept the result: the version re-check cannot reject a tear it never saw begin. It needs a weakly-ordered CPU - aarch64 or ppc64le - which is why neither the multi-process soak nor production on x86-64 could ever show it; there the RMW is a full barrier and the ordering holds by accident of the architecture. Make every bump ACQ_REL. The acquire half is what stops the following stores from hoisting above the odd version, which is the same reason Linux writes seq++ followed by smp_wmb() in write_seqcount_begin() rather than a release store. Exit bumps need only the release half, but are converted too so that no site has to be classified by hand and a later edit cannot silently add an entry bump with the wrong ordering. Writes are not the profiled hot path, and the cost on x86-64 is nil. The two remaining RELEASE stores, publishing a segment and the routing word, are plain publications where release is exactly right and are left alone.
A store whose new value fits the cell it already occupies rewrites the
length, the payload and the expiry in place, but left rflags alone. A key
that had been a native counter therefore kept PCACHE_F_INT after a plain
string was written over it, and since the read path formats a record as
an int64 whenever the flag is set and the value is exactly eight bytes,
an eight byte string came back as a number:
cache_add("perf", "ck", 1, 0, $var(n));
cache_store("perf", "ck", "12345678");
cache_fetch("perf", "ck", $var(v)); -> 4050765991979987505
which is the ASCII of the stored string read as a little endian integer.
Silent, and confined to eight byte values, so nothing in the selftests or
the soak was ever going to show it. Clear rflags inside the version
bracket, next to the other field updates.
While here, make the copy clamp in scan_bucket subtractive. It read
if (PCACHE_REC_HDR + klen + vlen > bound)
vlen = bound - PCACHE_REC_HDR - klen;
on unsigned values, so a torn vlen near UINT_MAX wraps the sum, the test
reads false and the clamp that exists to bound a doomed copy is skipped
on the one path that needs it. Compare against the remaining space
instead, which cannot wrap: bound >= PCACHE_REC_HDR + klen is already
established a few lines above.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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.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.
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)Allocation-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.