Skip to content

Commit a73d970

Browse files
committed
cachedb_perf: cross-node pull - ask the cluster before calling a miss
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.
1 parent fea6b19 commit a73d970

22 files changed

Lines changed: 3799 additions & 72 deletions

‎modules/cachedb_perf/Makefile‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,4 +6,13 @@ include ../../Makefile.defs
66
auto_gen=
77
NAME=cachedb_perf.so
88

9+
# The clusterer_controller pull transport is compiled only when that module is
10+
# part of this build - the top-level Makefile exports CLUSTERER_CTRL_SUPPORT=1
11+
# in that case (the clusterer module keys off the same hook). Without it,
12+
# pulls and syncs use the clusterer module's bin links only, and an explicit
13+
# pull_transport=clctr warns and degrades to bin.
14+
ifeq ($(CLUSTERER_CTRL_SUPPORT),1)
15+
DEFS+= -DCLUSTERER_CTRL_SUPPORT
16+
endif
17+
918
include ../../Makefile.modules

‎modules/cachedb_perf/README‎

Lines changed: 164 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,12 @@ cachedb_perf Module
2727
1.3.12. db_mode (integer)
2828
1.3.13. persist_collections (string)
2929
1.3.14. sync_cluster_id (integer)
30+
1.3.15. sync_shtag (string)
31+
1.3.16. replicate_collections (string)
32+
1.3.17. pull_transport (string)
33+
1.3.18. pull_timeout_ms (integer)
34+
1.3.19. pull_negative_ms (integer)
35+
1.3.20. pull_on_miss (integer)
3036

3137
1.4. DB persistence
3238
1.5. Cluster sync
@@ -36,8 +42,8 @@ cachedb_perf Module
3642
1.7.1. perf_stats
3743
1.7.2. perf_stats_reset
3844
1.7.3. Key introspection: perf_keys / perf_scan /
39-
perf_dump / perf_get / perf_set / perf_ttl /
40-
perf_del
45+
perf_dump / perf_get / perf_probe /
46+
perf_pull / perf_set / perf_ttl / perf_del
4147

4248
1.7.4. perf_save / perf_load
4349
1.7.5. perf_sync
@@ -74,15 +80,17 @@ cachedb_perf Module
7480
1.11. Set db_mode parameter
7581
1.12. Set persist_collections parameter
7682
1.13. Set sync_cluster_id parameter
77-
1.14. Cluster-sync setup (one authority, two read replicas)
78-
1.15. perf_stats usage
79-
1.16. perf_stats_reset usage
80-
1.17. introspection usage
81-
1.18. perf_save / perf_load usage
82-
1.19. perf_sync usage
83-
1.20. perf_del() usage
84-
1.21. perf_mget() usage
85-
1.22. perf_mget_json() usage
83+
1.14. Setting sync_shtag
84+
1.15. Setting replicate_collections
85+
1.16. Cluster-sync setup (one authority, two read replicas)
86+
1.17. perf_stats usage
87+
1.18. perf_stats_reset usage
88+
1.19. introspection usage
89+
1.20. perf_save / perf_load usage
90+
1.21. perf_sync usage
91+
1.22. perf_del() usage
92+
1.23. perf_mget() usage
93+
1.24. perf_mget_json() usage
8694

8795
Chapter 1. Admin Guide
8896

@@ -339,6 +347,124 @@ loadmodule "cachedb_perf.so"
339347
modparam("cachedb_perf", "sync_cluster_id", 1)
340348
...
341349

350+
1.3.15. sync_shtag (string)
351+
352+
A clusterer sharing tag, as “name/cluster_id”, that arms the
353+
failover sync. A node whose tag turns active warms every
354+
declared collection from the DB snapshot before the redirected
355+
traffic arrives; a node gracefully demoted to backup saves its
356+
state and signals the peers to reload it - so a failover moves
357+
the cache as one snapshot instead of a storm of misses.
358+
359+
Requires db_url. The tag only schedules these bulk operations -
360+
lookups are never gated on its state. On a crash failover the
361+
last saved snapshot is the only source, so pair this with
362+
periodic perf_save (or db_mode 2) on the active node.
363+
364+
Default value is unset (failover sync off).
365+
366+
Example 1.14. Setting sync_shtag
367+
modparam("clusterer", "sharing_tag", "vip1/1=backup")
368+
modparam("cachedb_perf", "sync_shtag", "vip1/1")
369+
370+
1.3.16. replicate_collections (string)
371+
372+
Comma-separated collections whose keys may be fetched from
373+
another node when this one misses (“pull on miss”). Nothing is
374+
pulled unless it is listed here, and the default is to list
375+
nothing.
376+
377+
The opt-in is deliberate and cannot be inferred: a key is only
378+
worth asking the cluster about if it means the same thing on
379+
every node. That holds for keys derived from the call - the
380+
topology hiding state, for instance - and fails for anything a
381+
script names after something local, where a peer's answer would
382+
be wrong rather than merely useless. Values must be portable
383+
too: a blob that embeds a node's own address is not.
384+
385+
Requires sync_cluster_id and the clusterer.
386+
387+
A pulled key is kept: the next request for it is answered
388+
locally and the cluster is never asked again, which is what
389+
makes this a repair rather than a relay. The copy keeps the
390+
expiry the owner had - never a fresh lifetime - so it dies when
391+
the original does instead of outliving it. Note the consequence
392+
for sizing: as traffic spreads, every node tends toward holding
393+
every key, so size the arena for the whole keyspace rather than
394+
its share of it.
395+
396+
Native counters (created with cache_add) are never served to a
397+
peer. A counter records what happened on the node holding it,
398+
so handing it over would import one node's tally into another;
399+
the requester is told the key is not there, which from its side
400+
is true.
401+
402+
Default value is unset (no collection is pulled).
403+
404+
Example 1.15. Setting replicate_collections
405+
modparam("cachedb_perf", "sync_cluster_id", 1)
406+
modparam("cachedb_perf", "replicate_collections", "th")
407+
408+
1.3.17. pull_transport (string)
409+
410+
How cross-node pulls travel: bin (default) uses the clusterer's
411+
BIN links. clctr is reserved for the clusterer_controller
412+
multicast plane and is not implemented yet - configuring it
413+
fails at startup rather than quietly falling back, since the
414+
choice is deliberate.
415+
416+
Default value is “bin”.
417+
418+
1.3.18. pull_timeout_ms (integer)
419+
420+
How long a pull waits for the cluster, in milliseconds
421+
(1..5000). It is a backstop, not the normal cost: with every
422+
peer answering either way, a pull finishes as soon as the last
423+
one has spoken - on a LAN, in a couple of milliseconds. The
424+
timeout only decides how long an unanswered request lingers.
425+
426+
Default value is “50”.
427+
428+
1.3.19. pull_negative_ms (integer)
429+
430+
How long to remember that the whole cluster answered “not here”
431+
for a key, in milliseconds (0..2000; 0 disables it). A SIP
432+
retransmit asks the same question a few hundred milliseconds
433+
later, and without this every retransmit repeats the round of
434+
questions.
435+
436+
Keep it short. A key may legitimately be created on another
437+
node a moment from now, and a negative that outlives that turns
438+
a transient miss into a hard failure - which is why the
439+
parameter is capped rather than left open. Only a verdict the
440+
whole cluster gave is remembered: a timeout is not absence, and
441+
neither is an answer from a set of nodes that has since
442+
changed. A local write to the key clears it at once.
443+
444+
Negatives are held outside the cache, so they never appear in
445+
perf_keys or perf_dump and never count as entries.
446+
447+
Default value is “300”.
448+
449+
1.3.20. pull_on_miss (integer)
450+
451+
Repair a miss on the ordinary read path: when a lookup finds
452+
nothing locally, ask the cluster and return whatever comes back
453+
as though it had been here all along. A consumer needs no
454+
changes - cross-node lookups simply start working for the
455+
collections listed in replicate_collections.
456+
457+
Off by default, and it should stay off on a SIP path for now.
458+
The lookup blocks until the cluster answers or pull_timeout_ms
459+
elapses, and a blocked lookup means a process serving nothing
460+
else in the meantime. A LAN pull takes a couple of milliseconds
461+
and the negative cache absorbs retransmits, but that is a
462+
statement about the common case, not a guarantee under load.
463+
Enable it for maintenance, migration or test paths; a startup
464+
warning repeats this when it is on.
465+
466+
Default value is “0” (disabled).
467+
342468
1.4. DB persistence
343469

344470
With db_url set, a whole collection can be saved to and loaded
@@ -416,7 +542,7 @@ Warning
416542
persistence): it is an occasional refresh, not something to
417543
fire on a timer or per request.
418544

419-
Example 1.14. Cluster-sync setup (one authority, two read
545+
Example 1.16. Cluster-sync setup (one authority, two read
420546
replicas)
421547
# on every node - clusterer must load before cachedb_perf (the module
422548
# declares a soft dependency, so init order is handled either way):
@@ -491,7 +617,7 @@ node)\n");
491617

492618
Name: perf_stats. Parameters: collection (optional).
493619

494-
Example 1.15. perf_stats usage
620+
Example 1.17. perf_stats usage
495621
opensips-cli -x mi cachedb_perf:perf_stats
496622
opensips-cli -x mi cachedb_perf:perf_stats th
497623

@@ -519,12 +645,12 @@ opensips-cli -x mi cachedb_perf:perf_stats th
519645

520646
Name: perf_stats_reset. Parameters: collection (optional).
521647

522-
Example 1.16. perf_stats_reset usage
648+
Example 1.18. perf_stats_reset usage
523649
opensips-cli -x mi cachedb_perf:perf_stats_reset
524650
opensips-cli -x mi cachedb_perf:perf_stats_reset th
525651

526652
1.7.3. Key introspection: perf_keys / perf_scan / perf_dump /
527-
perf_get / perf_set / perf_ttl / perf_del
653+
perf_get / perf_probe / perf_pull / perf_set / perf_ttl / perf_del
528654

529655
These give an operator the visibility that cachedb_local lacks.
530656
All are lock-free: the walkers take no bucket locks (seqlock
@@ -548,6 +674,23 @@ perf_get / perf_set / perf_ttl / perf_del
548674
includes the values; values are opt-in, never the default.
549675
* perf_get <key> [collection] - one key: its value, remaining
550676
TTL (-1 = never) and size.
677+
* perf_pull <key> [collection] - fetch one key from the
678+
cluster, for a collection listed in replicate_collections.
679+
Reports where the answer came from: local (this node had it
680+
after all), cluster (a peer had it, with its remaining
681+
TTL), absent (every peer answered, none has it) or
682+
no-answer (nobody answered in time, or there was nobody to
683+
ask). The last two are deliberately different: absence is a
684+
fact only when the whole cluster has said so.
685+
* perf_probe <key> [collection] - is the key here: its size
686+
and remaining TTL, but never the value. Not merely a
687+
cheaper perf_get - it shares the whole read path (same
688+
optimistic loop, lock fallback and expiry rules) and stops
689+
before the copy-out, so it cannot disagree with a read
690+
about whether a key is present; it allocates nothing and
691+
never touches the record's payload. Use it to answer “do
692+
you have this key?”, where a read would pay for bytes
693+
nobody wants.
551694
* perf_set <key> <value> [ttl] [collection] - write one key;
552695
ttl is seconds (0 or omitted = never expires).
553696
* perf_ttl <glob> <ttl> [collection] - re-arm the TTL of
@@ -560,7 +703,7 @@ perf_get / perf_set / perf_ttl / perf_del
560703
the glob; returns the count. The MI face of the perf_del()
561704
script function.
562705

563-
Example 1.17. introspection usage
706+
Example 1.19. introspection usage
564707
opensips-cli -x mi cachedb_perf:perf_keys "session-*"
565708
opensips-cli -x mi cachedb_perf:perf_keys "session-*" th 50
566709
opensips-cli -x mi cachedb_perf:perf_scan 0
@@ -579,7 +722,7 @@ opensips-cli -x mi cachedb_perf:perf_del "session-abc*"
579722
reply reports how many collections and entries were written or
580723
read.
581724

582-
Example 1.18. perf_save / perf_load usage
725+
Example 1.20. perf_save / perf_load usage
583726
opensips-cli -x mi cachedb_perf:perf_save
584727
opensips-cli -x mi cachedb_perf:perf_save sessions
585728
opensips-cli -x mi cachedb_perf:perf_load sessions
@@ -590,7 +733,7 @@ opensips-cli -x mi cachedb_perf:perf_load sessions
590733
(see Cluster sync); all declared collections if none is named.
591734
Also available as a script function, perf_sync([collection]).
592735

593-
Example 1.19. perf_sync usage
736+
Example 1.21. perf_sync usage
594737
opensips-cli -x mi cachedb_perf:perf_sync sessions
595738

596739
1.8. Exported Events
@@ -656,7 +799,7 @@ opensips-cli -x mi cachedb_perf:perf_sync sessions
656799
Returns the number of keys removed, or -1 (false) if none
657800
matched.
658801

659-
Example 1.20. perf_del() usage
802+
Example 1.22. perf_del() usage
660803
...
661804
perf_del("session-*");
662805
perf_del("th-*", "th");
@@ -671,7 +814,7 @@ perf_del("th-*", "th");
671814
0 = unbounded. Returns the match count, or -1 (false) if none
672815
matched.
673816

674-
Example 1.21. perf_mget() usage
817+
Example 1.23. perf_mget() usage
675818
...
676819
if (perf_mget("user-*", $avp(k), $avp(v))) {
677820
xlog("first match: $(avp(k)[0]) = $(avp(v)[0])\n");
@@ -687,7 +830,7 @@ if (perf_mget("user-*", $avp(k), $avp(v))) {
687830
through unescaped - strict JSON consumers therefore need UTF-8
688831
values. Returns the match count, or -1 (false) if none matched.
689832

690-
Example 1.22. perf_mget_json() usage
833+
Example 1.24. perf_mget_json() usage
691834
...
692835
if (perf_mget_json("user-*", $var(blob), , 100))
693836
xlog("users: $var(blob)\n");
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
# Blocking versus suspending, end to end
2+
3+
Two nodes, four workers each, and a burst of requests to the node that
4+
does **not** have the state - so every one of them costs a round trip to
5+
the other node. The same workload runs two ways:
6+
7+
```bash
8+
MODE=sync sh e2e_rig.sh && python3 e2e_burst.py sync
9+
MODE=async sh e2e_rig.sh && python3 e2e_burst.py async
10+
```
11+
12+
`sync` sets `pull_on_miss` and calls `topology_hiding_match()`, so the
13+
lookup blocks its worker until the cluster answers. `async` clears
14+
`pull_on_miss` and calls `async(topology_hiding_match(), resume)`, so the
15+
transaction suspends and the worker goes back to work. Both must be
16+
configured, or the comparison is meaningless: with `pull_on_miss` set,
17+
the ordinary lookup pulls before the asynchronous path ever sees a miss.
18+
19+
## What it shows
20+
21+
On an idle network, **nothing**: 120 requests took 0.044 s blocking and
22+
0.046 s suspending. A pull takes well under a millisecond there, and
23+
holding a worker for that long costs nothing measurable. This is worth
24+
knowing - it is why `pull_on_miss` is not dangerous on a quiet LAN, and
25+
why the asynchronous path is not a general speed-up.
26+
27+
Add 100 ms to the peer's replies (`tc qdisc add dev y1 root netem delay
28+
100ms` inside the peer's namespace) and the difference is the whole
29+
point:
30+
31+
| | 60 requests, 4 workers, peer 100 ms away |
32+
|---|---|
33+
| blocking | 1.52 s |
34+
| suspending | 0.31 s |
35+
36+
The blocking figure is arithmetic, not noise: sixty round trips of a
37+
tenth of a second, four at a time, is a second and a half. The other
38+
path finishes in about one round trip however many requests there are,
39+
because none of them is holding anything while it waits.
40+
41+
## Traps
42+
43+
The key must be exactly `TH_KEY_LEN` characters or the store rejects it
44+
by length, which looks exactly like a lookup that found nothing - an
45+
earlier version of this test generated keys of 15, 16 and 17 characters
46+
and "failed" only for the ones that were not 16.
47+
48+
Clear both caches before each run. A pull stores what it fetched, so a
49+
second run measures local hits and reports a number that means nothing.

0 commit comments

Comments
 (0)