Skip to main content

Client-side caching: server reverse-index leak after disconnect (unbounded growth)

Author
quanye
Systems software: OS, networking, distributed systems.
Table of Contents

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:

  1. Key modified/deleted (signalModifiedKeytrackingInvalidateKey)
  2. Outer-table over-limit eviction (trackingLimitUsedSlots, random walk by outer key count)
  3. 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