topology_hiding: keep the dialog-less state in a shared store (fixes long Contact / SUBSCRIBE)#4114
Open
Lt-Flash wants to merge 3 commits into
Open
topology_hiding: keep the dialog-less state in a shared store (fixes long Contact / SUBSCRIBE)#4114Lt-Flash wants to merge 3 commits into
Lt-Flash wants to merge 3 commits into
Conversation
Lt-Flash
force-pushed
the
feature/topo-hiding-stateful-devel
branch
5 times, most recently
from
July 17, 2026 07:23
3e75be8 to
86257b6
Compare
Lt-Flash
marked this pull request as ready for review
July 17, 2026 07:24
Lt-Flash
marked this pull request as draft
July 18, 2026 05:18
Lt-Flash
force-pushed
the
feature/topo-hiding-stateful-devel
branch
6 times, most recently
from
July 18, 2026 10:01
b96df38 to
c4b121c
Compare
Lt-Flash
marked this pull request as ready for review
July 19, 2026 00:12
Author
|
Hi, whenever you have a moment, could I kindly ask for a review of this PR? I'd really appreciate it. I'll hold off on any further commits until the review is complete, so the branch stays stable for you to look at. Thanks very much for your time! |
Without a dialog, the whole encoded topology hiding state travels in the Contact URI parameter. This makes the URI long, and some user agents (e.g. Cisco CP-88xx) truncate it, after which topology_hiding_match() can no longer decode it and the sequential requests are lost. This mainly hurts non-INVITE dialogs, such as SUBSCRIBE, since those can never get a dialog to hold the state - the dialog module only handles INVITEs. Add an alternative mode: when 'th_state_url' points to a cachedb backend, the state is stored there under a short key and only that key travels in the Contact, which shrinks the parameter from 72 to 16 chars regardless of how large the hidden topology is. A shared backend keeps the state readable by any node handling the traffic, as needed when calls are distributed or fail over between nodes. The storage sits behind a small internal interface, so other backends (e.g. clusterer replication) can be added later. The XOR obfuscation and the URI-safe word encoding a state needs to ride inside a Contact serve no purpose once it is held server-side under a key, so the stored copy carries neither: its fields are simply length-prefixed in a printable "<length>:<bytes>" form, which also keeps it readable straight out of the store. Only the state still travelling inline in a Contact is obfuscated and word-encoded, exactly as before; the marker keeps the two apart, an inline encoded state never being able to produce it. Being in the clear, the stored copy exposes the hidden topology to anyone who can read the backend, so the backend is expected to be a trusted, internal store reachable only by the nodes that share it - deploy it on a private or DMZ network and restrict access to it accordingly. A state is only kept on the server side when the message it belongs to tells how long its dialog is going to live, as a state expiring from under a dialog which is still up cannot be matched anymore and takes down whatever was still to be routed through it - the BYE of a call, most notably. A subscription is bounded by its expires, and the messages opening no dialog only live as long as their transaction. A call is not bounded by anything unless the session timers are in use, so the state of a call which has none goes on travelling in its Contact, where it cannot expire, just as it does without this mode. The two are told apart on the way back by the marker a key carries: an encoded state comes out of word64encode() or word32encode(), whose alphabets are "A-Za-z0-9+." and "A-Z2-7" and which both pad with '-', so it can only ever be made of [A-Za-z0-9+.-] - which the marker is picked out of reach of, and stays so whatever the length of a state, where telling the two apart by length would have rested on nothing more than the minimum size of what gets packed. The marker is also legal unescaped in the parameter of a SIP URI, being one of the marks of RFC 3261 25.1. So both may be in use at once. A call which is given a dialog never gets here at all: the dialog bounds its state for exactly as long as the call lives and its Contact only carries the dialog id, which is a better answer than this mode could give it. A state is stored each time a Contact is encoded, and is otherwise only removed when it expires, so its lifetime is taken from the message being encoded rather than from a flat value: - SUBSCRIBE and NOTIFY carry the duration of their subscription. A NOTIFY reports what the notifier really granted in its Subscription-State header (RFC 6665 4.1.3), so it is preferred; otherwise it is taken from an 'expires' Contact parameter, or from the Expires header, the parameter taking precedence (RFC 3261 10.2.1.1). A subscription may outlive the configured th_state_ttl and is no longer cut short by it, while a short lived one no longer lingers. A notifier is free to grant less than was asked for, and since the state facing it is stored while only the asked for value is known, its NOTIFYs are what bring that state back in line with what was really granted; - INVITE and UPDATE may last for any amount of time, but when the session timers are in use they are refreshed every Session-Expires seconds (RFC 4028) and each refresh encodes the Contact anew, so the state only has to survive one interval. Should a refresh not come, the session is torn down anyway. The Expires header is deliberately not used for these: on an INVITE it limits the validity of the invitation, not the duration of the answered call; - OPTIONS, MESSAGE, INFO and PUBLISH neither open a dialog nor refresh the target of one (RFC 3261 12.2.1.2), so their Contact only matters while their transaction runs - they get th_state_ttl_short (60s); - everything else keeps using th_state_ttl. A message which keeps its dialog alive also pushes the expiration of the state it was sent to further: the party which sent it only learns of a new state if the reply it gets carries a Contact of its own, so it may well go on using the one it already knows, which therefore has to outlive its original expiration. Only the messages which carry the lifetime of their dialog may refresh it, so that an in-dialog OPTIONS cannot cut the state of the call it runs into down to a minute. The key is derived from the dialog leg the state belongs to - the call and the tag of the party whose Contact it hides - rather than drawn at random, so that every refresh of that leg lands on the same key and overwrites its state in place, its expiration pushed further. A key minted afresh on each refresh would instead leave the previous one behind in the store, and with no dialog over these to ever reclaim it, it would sit there until it expired. The derivation is keyed with the contact-encoding password, so a key cannot be guessed out of the call-id and tags, which travel in the clear. A SUBSCRIBE or NOTIFY with an expires of 0, or a NOTIFY reporting its subscription as terminated, tears that subscription down, so the state it was sent to is dropped right away instead of lingering for as long as the subscription had asked for. The Contact encoded on the way out still gets a state of its own, so that the final NOTIFY may be routed back. The backend must expire the values it stores, as that is what removes the states, and must support removing them. All the cachedb backends do, except for cachedb_mongodb, which accepts the expire argument of its set() but silently ignores it - it is refused at startup rather than piling up the states unnoticed. Because the state is immutable, storing it again on every sequential request only pushes its expiration further out. Each node keeps a small in-memory (shared-memory) record of the expiry it last wrote per key and skips the backend write while the state still has enough of its lifetime left, turning a busy dialog's stream of identical writes into an occasional refresh. A write that would shorten a state (a teardown) is never skipped, and a stale or evicted record only ever costs one extra write - the worst a cross-node race can do is expire a state slightly early, which the endpoint recovers from by re-establishing. When 'th_state_url' is not set, the encoding is left exactly as it was.
Lt-Flash
force-pushed
the
feature/topo-hiding-stateful-devel
branch
from
July 20, 2026 04:30
c4b121c to
7e807a3
Compare
16 tasks
added 2 commits
July 25, 2026 22:39
A reply does not always carry the lifetime of the dialog it belongs to, but the request it answers does. A 200 OK to a NOTIFY has neither an Expires header nor a Subscription-State, so th_state_msg_ttl() fell through to the generic th_state_ttl and stamped an hour onto a leg whose subscription lasts a couple of minutes. Observed on a live SBC: the two legs of one presence dialog held TTLs of 92 s and 3550 s, the second being the leg last re-encoded by such a reply. The same applies to a 200 OK that does not repeat the Session-Expires of the INVITE it answers. Nothing was routed wrongly - the key is derived, so the leg keeps reusing it and the state is merely kept far longer than the dialog needs. On a busy presence deployment that is most of the cache: state for subscriptions that ended long ago, waiting out a 3600 s timer. The onreply callback already has the request, so it is passed down to the encode as a lifetime fallback, consulted only when the message being encoded does not carry one itself. A reply that does carry it still wins, and the value can only come out longer than before, never shorter, so no live dialog can lose its state to this.
This reverts commit 9974458. The change let a reply with no lifetime of its own take one from the request it answers. For a 200 OK to a NOTIFY the only value on offer is Subscription-State;expires, which per RFC 6665 is the time REMAINING in the subscription and counts down - so late in a cycle it re-stamped a leg's state with a few tens of seconds, and the state expired before the subscriber refreshed. Every NOTIFY arriving in that window then failed to match. Measured on a live SBC before and after: the cache hit rate fell from 99.6% (9816 hits / 43 misses) to 51.9% (4576 / 4248), with 29141 entries expiring against 39005 stores. Each miss is a sequential request whose topology-hiding state could not be decoded. The reasoning was wrong in one specific way: it treated the subscription's remaining lifetime as the state's required lifetime. The state has to survive until the next REFRESH - the subscription interval - which a NOTIFY does not carry. The reverted commit argued the value "can only come out longer, never shorter"; that holds against the 3600 s fallback only when the request knows better, and a counting-down NOTIFY knows worse. Such a reply once again falls back to th_state_ttl - over-long rather than lossy, which is the safe direction. The original complaint, a leg stamped with a blind hour because the message that re-encoded it was uninformative, still stands; the correct fix is to never let a re-encode shorten a live state except on explicit teardown, and will be done separately.
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.
Problem
When topology hiding runs without a dialog, the whole encoded state (route set, original Contact, flags, bind address) rides in the Contact URI parameter — making it long. Some UAs (e.g. Cisco CP-88xx) truncate it, after which
topology_hiding_match()can't decode it and every sequential request of that call is lost. This is unavoidable for non-INVITE dialogs like SUBSCRIBE:dlg_create_dialog()is INVITE-only, so a SUBSCRIBE can never get a dialog to hold its state server-side, and the payload is dominated by the route set.What this adds
An opt-in mode for the dialog-less path: when
th_state_urlpoints at acachedbbackend, the state is stored there under a short key and only the key travels in the Contact:The Contact stays short regardless of topology size, making SUBSCRIBE (and long route sets) workable on constrained endpoints. When
th_state_urlis unset, the encoding path is byte-for-byte unchanged. A call that does get a dialog never reaches this mode — the dialog holds its state and puts only the dialog id in the Contact, soforce_dialog=1is the better answer for calls; this mode serves what a dialog cannot.The full detail — the marker-safety proof, the notifier-granted-expires handling, the measured TTLs, and the per-backend matrix — is in the module docs (admin.xml / README). This is the summary.
Design
cachedb_redis);cachedb_localis fine for a single instance. The storage sits behind a smallth_store.hinterface (backend enum), so a clusterer-replication backend can be added later without touching the encode/decode sites.State lifetime
A state is stored every time a Contact is encoded, but the deterministic key overwrites in place (no pile-up), and each write takes its TTL from the message, not a flat value:
SUBSCRIBE/NOTIFYSubscription-Statewins, else Contact;expires, elseExpires)0/Subscription-State: terminatedth_state_ttl_short(routes the final NOTIFY)OPTIONS/MESSAGE/INFO/PUBLISHth_state_ttl_short(default 60) — transaction-only, no target refreshINVITE/UPDATEwith session timersSession-Expires+ 30 s (theExpiresring timeout is deliberately not used)INVITE/UPDATEwithout session timersth_state_ttl(default 3600)A message that carries its dialog's lifetime also refreshes the state it was sent to, in place — so a long-lived subscription that only ever re-SUBSCRIBEs doesn't lose the state it keeps using. An in-dialog OPTIONS does not refresh (its short TTL would otherwise cut a call to a minute). All of this is measured end-to-end against Redis; the full TTL / refresh / notifier-granted tables and the
_-marker safety proof are in the docs.Security
The stored state is in the clear — not encrypted, and (unlike the in-Contact copy) not obfuscated — so anyone who can read the backend sees the topology this hides (real Contact, route set, receiving socket). This is intentional: off the wire the obfuscation/encoding serve no purpose, and plain text is inspectable. The store is expected to be a trusted, internal backend reachable only by the OpenSIPS nodes that share it — deploy it on a private / DMZ network, bind it to the internal interface, enable the backend's own auth, and do not point
th_state_urlat anything reachable beyond that trust boundary.Backend compatibility
Any
cachedbbackend that honours theexpiresargument ofset()(and providesremove()) works — verified againstcachedb_redisandcachedb_local, and applies to memcached / cassandra / couchbase / dynamodb / sql.cachedb_mongodbsilently ignores the TTL (states would never expire), so it is rejected at startup by a URL-scheme check (the cachedb interface has no "supports expiration" capability — a general gap left out of this PR).Configuration
cachedb_local://(single instance) and a password-protectedredis://:pass@host/dbcluster are also supported.Testing
Against
cachedb_redis(andcachedb_local, confirming backend-agnostic):_marker); a sequential request carrying only the key is matched, decoded, and the R-URI restored to the original endpoint.mongodb://guard refuses startup.Roadmap (not in this PR)
dlg_replication.c), for deployments that don't want an external store in the signalling path; the immutable state makes it simpler (create / read / expire only).CACHEDB_CAP_EXPIREScapability so TTL-ignoring backends are rejected through the interface rather than by a URL-scheme match (touches the core plus every backend).Notes
th_store.c/th_store.h; docs + regenerated README included.th_state_urldeclares acachedb_urldependency so the backend module loads first.Measured cost
The design section above says the state backend is a choice (
cachedb_localfor a single instance, ashared store otherwise). That choice has a concrete price, so here it is measured, together with the
alternatives it competes with.
Test environment
hypervisorflag presentSTATS: On, DISABLE_NAGLE, USE_MCAST, SHM_MMAP, PKG_MALLOC, Q_MALLOC, F_MALLOC, HP_MALLOC, F_PARALLEL_MALLOC, FAST_LOCK-ADAPTIVE_WAIT-m 4096shm,-M 32pkg,udp_workers=8(= vCPU count),tcp_workers=4dispatcher(weighted round-robin)What was exercised
A dual-homed proxy: upstream leg and downstream pool on separate interfaces. Every call is a complete
INVITE -> 100 -> 200 -> ACK -> BYE -> 200carryingSession-Expires, so a storable state is producedon every call — deliberately the heaviest path for this feature.
topology_hiding()runs on theinitial INVITE and
topology_hiding_match()on every sequential request; a call is only counted assuccessful if its BYE was matched and answered, so the decode path is verified, not just the encode.
Two measurement notes for anyone reproducing this: CPU must be sampled over a steady-state mid-run
window (including the call-teardown tail dilutes it badly — 46% vs the real 67% in one run), and a
single SIPp UAC process saturates one core at roughly 4.8k CPS, so the load has to be generated from
several processes or you measure the generator instead of the proxy.
UDP — what the state backend costs
force_dialog=1)cachedb_localcachedb_redis, loopbackcachedb_redis, 3-master LAN cluster* the LAN-cluster case never becomes CPU-bound — it caps at ~65%, see findings.
Findings (UDP)
dialog runs at 10% CPU at 3k CPS; putting it through a loopback Redis costs 48% for identical calls,
and the ceiling falls from ~9k to ~6.9k CPS. The per-call
SET/GETdominates, not the topologyhiding itself.
cachedb_localis the cheapest option at low/medium load but walls early. At 3k CPS it is thelowest of all five (7%), because it avoids both the socket round-trip and dialog bookkeeping. Then CPU
goes 9% -> 77% -> 100% between 4k and 7.1k CPS: the shared cache hash serialises the workers, so it
caps ~2k CPS below the dialog path despite being cheaper per call. A good single-instance choice up
to a few thousand CPS; not the one to pick for peak throughput.
still has ~35% CPU idle. The cachedb call is synchronous, so a worker blocks for the whole round-trip
and stops draining its socket — every extra millisecond of backend RTT comes straight off the
ceiling. Colocate the store where possible; this is also exactly what the coalesced-write path is
worth the most for.
Session-Expireson every call means a state wasstored for every call. Real traffic has far fewer storable messages, so production cost sits between
the no-TH and TH lines — nearer the former the fewer states are actually stored. Note also that
each call used a distinct key, so write coalescing is not what these figures measure.
as application bound; all workers share one UDP socket, which is why even the no-TH line plateaus
around 83-97% rather than pinning all 8 cores.
udp_workers8 -> 32 on the same 8 vCPU made thingsworse: ~1.6x the CPU for equal throughput and 2.5% call loss at only 1000 CPS (shared-socket
contention plus scheduler churn), for a ~13% ceiling gain.
TCP — connection ceiling
Not specific to this PR — transport characterisation of the same box, included for completeness.
Connections were opened at a steady ~76/sec and held:
Findings (TCP)
tcp_max_connections(default 2048). At that point OpenSIPSlogs
maximum number of connections exceededand refuses new connections. The failure is graceful:existing connections and UDP signalling keep working — only new TCP is rejected.
tcp_socket_backlogdefaults to 10. The accept queueoverflows on rapid arrivals (
listen queue of a socket overflowedinnetstat -s) long before 2048 isapproached; reaching the real ceiling at all required raising it. For a public-facing edge proxy a
backlog of 10 is easy to overrun with a reconnect storm or a burst of TLS handshakes — this may be
worth revisiting as a default.
Possible SYN floodingmessages, andtcp_max_syn_backlog(512) is far above the 10-deep accept queue; the constraint is purely theapplication's
listen()backlog. File-descriptor limits were also not a factor (65536 available).Under concurrent load: 50 000 calls held
The figures above ramp the call rate with calls torn down immediately, which measures the cost of
establishing state. This second test measures the cost of holding it: the hold time is set to
50000 / rate, so once the ramp completes there are ~50 000 calls in flight, and the load is thensustained. Concurrency is verified rather than assumed - it is the summed in-flight call count reported
by the load generators, and it lands within 0.3% of 50 000 on every valid rung.
These numbers are not directly comparable to the section above. That test failed any call whose
response took over 8 s; this one allows 120 s, because calls are held deliberately. The proxy therefore
posts higher call rates here at considerably worse latency. They are two different measurements, not a
revision of the same one.
A rung counts only where the proxy actually kept pace: under 5% failures and in-flight calls still
near 50 000. That second condition matters. Judged on failure rate alone the loopback-Redis case appears
to reach 9 829 CPS at 1.7% failures - but its in-flight count had grown to 112 000, meaning calls were
arriving faster than they cleared. That is a growing backlog, not 50 000 calls being held, so it is not
counted.
force_dialog=1)cachedb_redis, loopbackcachedb_local,th=16cachedb_local, defaultcachedb_redis, 3-master LAN clusterFindings (50 000 held calls)
~6 000 for every cachedb variant, and 17% CPU against 45-57% for identical traffic at 4 000 CPS. The
ranking is the same as in the first test, but the gap widens sharply with in-flight state.
RTT, not proxy CPU, sets that ceiling, which is the case the coalesced-write path helps most.
cachedb_local's default collection is undersized for tens of thousands of live states. Thedefault is
1 << HASH_SIZE_DEFAULT= 512 buckets, so ~50 000 entries means ~100-entry chains walkedunder each bucket lock. Setting
modparam("cachedb_local", "cache_collections", "th=16")(65 536buckets) cut CPU from 45% to 29% at 4 000 CPS. It barely moved the ceiling (5 907 -> 5 944), so it
buys headroom below saturation rather than a higher wall - but it is a one-line change and worth
documenting for anyone using
cachedb_localto hold TH state at scale.variants, growing to 2 304 MB by 10 000 CPS. Holding 50 000 calls costs roughly 350 MB of shared
memory even with no topology hiding at all, from the in-flight transactions alone.
configurations post low failure rates at rungs where in-flight calls had doubled or tripled past the
target. Any figure quoted from this kind of test should be paired with the in-flight count.