The core of Issue #4143 is that Client Side Caching (CSC) deliberately skips immediate cleanup of the server-side reverse index when a client disconnects, which lets the inner tables grow without bound. Below we break it down as mechanism → memory layout → lifecycle → gap.
This is very similar to the client/server watch mechanism in Thesis-ZooKeeper.
Background #
Feature background: Client Side Caching #
CLIENT TRACKING on lets the client cache keys locally.
The server does not maintain a full forward index of “which keys this client holds”; it maintains a reverse index:
Which client IDs may have cached a given key?
When a key is modified, the server uses this table to send invalidations (RESP3 push / redirect pubsub)—so this is active push. The client then drops its local cache.
Data structures (memory view) #
It maintains a reverse index: for each key, which clients may have held it.
TrackingTable (global rax*)
├── key "user:1" → inner rax* (client ID set) ---> this radix tree has no upper bound
│ ├── 8-byte id → NULL
│ ├── 8-byte id → NULL
│ └── ...
├── key "user:2" → inner rax*
└── ...
These are radix trees.
/* The tracking table is constituted by a radix tree of keys, each pointing
* to a radix tree of client IDs, used to track the clients that may have
* certain keys in their local, client side, cache.
*/
rax *TrackingTable = NULL;
rax *PrefixTable = NULL;
uint64_t TrackingTableTotalItems = 0; /* Total number of IDs stored across
the whole tracking table. ... */
Insert path on read:
for (int j = 0; j < numkeys; j++) {
int idx = keys[j].pos;
sds sdskey = objectGetVal(executing->argv[idx]);
void *result;
rax *ids;
if (!raxFind(TrackingTable, (unsigned char *)sdskey, sdslen(sdskey), &result)) {
ids = raxNew();
int inserted = raxTryInsert(TrackingTable, (unsigned char *)sdskey, sdslen(sdskey), ids, NULL);
serverAssert(inserted == 1);
} else {
ids = result;
}
if (raxTryInsert(ids, (unsigned char *)&tracking->id, sizeof(tracking->id), NULL, NULL))
TrackingTableTotalItems++;
}
Key points:
| Layer | Structure | Limit |
|---|---|---|
| Outer | key → inner rax | tracking-table-max-keys (default 1M); over limit, random key eviction |
| Inner | client ID (uint64_t, network byte order as rax key) → NULL |
No upper bound |
| Global counter | TrackingTableTotalItems |
Counts ID entries only; no rate limit |
Client ID lookup goes through server.clients_index (another rax):
client *lookupClientByID(uint64_t id) {
id = htonu64(id);
void *c = NULL;
raxFind(server.clients_index, (unsigned char *)&id, sizeof(id), &c);
return c;
}
There is no “client → keys” reverse edge, so on disconnect you cannot delete in O(tracked_keys); you would have to scan the whole table at O(outer keys × inner ids).
Lifecycle: why disconnect deliberately skips cleanup #
The disableTracking() comment is blunt:
/* Remove the tracking state from the client 'c'. Note that there is not much
* to do for us here, if not to decrement the counter of the clients in
* tracking mode, because we just store the ID of the client in the tracking
* table, so we'll remove the ID reference in a lazy way. Otherwise when a
* client with many entries in the table is removed, it would cost a lot of
* time to do the cleanup. */
void disableTracking(client *c) {
/* BCAST mode: actually remove the client pointer from PrefixTable */
...
/* Ordinary tracking: only clear flag + tracking_clients--, leave TrackingTable alone */
}
freeClient / clearClientConnectionState call disableTracking (networking.c:2041), but dead IDs in TrackingTable stay as-is.
Classic latency vs memory trade-off:
- Disconnect must be fast (the event loop cannot scan a million-scale key table)
- Assumption: “keys will eventually be modified → invalidate throws away the whole inner rax”
The bug is what happens when that assumption fails.
When do dead entries actually disappear? #
Inner IDs are only released when the entire key is invalidated—so removal is lazy (this came up in a ByteDance interview).
void trackingInvalidateKey(...) {
...
while (raxNext(&ri)) {
client *target = lookupClientByID(id);
if (target == NULL || !(target->flag.tracking) || ...) {
continue; /* dead client: skip send, but do NOT remove from ids! */
}
sendTrackingMessage(...);
}
/* then free the whole inner rax */
TrackingTableTotalItems -= raxSize(ids);
raxFree(ids);
raxRemove(TrackingTable, key, keylen, NULL);
}
Paths that trigger whole-key cleanup:
- Key modified/deleted (
signalModifiedKey→trackingInvalidateKey) - Outer-table over-limit eviction (
trackingLimitUsedSlots, random walk by outer key count) - FLUSHDB/FLUSHALL (rebuild the whole table)
Note during invalidate traversal:
lookupClientByID == NULL only skips the send; it does not raxRemove that dead ID alone.
Dead IDs only vanish when the whole key is torn down.
In other words, the design never deletes individual client IDs; it only releases them when the entire key is gone.
So the reproduction path is exactly what the issue describes:
Many clients TRACKING on → read a small set of rarely changing keys → disconnect
→ outer keys remain (unchanged, under eviction limit)
→ inner trees fill with stale client IDs
→ TrackingTableTotalItems / heap memory keep growing
Outer tracking-table-max-keys cannot bound the aggregate inner size:
100 keys × 100k dead clients = 10M entries, while the outer table still has only 100.
What the issue asks for #
| Expectation | Meaning |
|---|---|
| Cap on inner (or aggregate) size | Cannot only limit raxSize(TrackingTable) |
| Dead-entry cleanup (sweeper) | Background incremental scan: delete if lookupClientByID==NULL; or async scan on disconnect |
| Include TrackingTable in defrag | Reuse defragRadixTree for both outer and inner trees |