Skip to content

Build1 publisher3 min readPublished

A per-entity index trades one blocking KEYS scan for five to ten targeted deletes

A dev.to post publishes a TypeScript decorator that records every cache key written for an entity, so invalidation reads a short list instead of walking the keyspace, and it ships the benchmark scripts because every figure in it is a local run.

The Engineer · Build desk

Illustration accompanying A per-entity index trades one blocking KEYS scan for five to ten targeted deletes

What happened

  • A dev.to post publishes a TypeScript per-entity cache index that replaces repeated full-keyspace Redis scans with targeted lookups, and the author states that every performance figure in it comes from local benchmarks.
  • Cache keys are built as service::tenant::entityName::entityId::params, so an invalidation caller holding only an identifier cannot reconstruct the varying service prefix or the arbitrary params tail around it.
  • One entity's cached state usually lives under five or ten different key strings, because parameter tails vary and several services cache the same value independently.
  • Caching is applied by a decorator, @Cache(CacheKey.ACTIVE_SUBSCRIPTION, TTL.MEDIUM, CacheStrategy.ENTITY_INDEX_CACHE), and the service method it wraps never names a key or touches Redis.

Compiled by The EngineerSomething wrong?How this is made

Why it matters

  • constraint Sizing the delete path comes down to how many services cache the same entity under how many parameter tails, not to how many keys the instance holds.
  • exposure On a single-threaded instance, one team's invalidation pattern is a latency dependency for every unrelated read that shares the box, including services that never call the origin being invalidated.
  • decision The strategy is an argument on each method's decorator, so a team can move one hot read path onto the index and leave everything else where it is.
  • cost Anyone budgeting the index has to re-run the published scripts against their own fan-out and network before quoting a saving, because the recorded numbers are the author's local runs.

Discovery is the problem the index solves. The invalidation caller arrives with one identifier and has to remove everything cached about it [8]. That identifier sits in the middle of the key string, the service prefix varies, and the params tail is arbitrary, so the names cannot be computed from what the caller was handed [8]. Reads do not have that problem: every lookup is one exact-match GET [6].

The post is titled "Ten Million Keys, One Missing Index" [16]. Put the title's figure next to the stated fan-out of five to ten keys per entity [7]. A pattern scan then inspects ten million keys to find at most ten [18]. That is a million keys examined for every key removed. The targeted path reads the entity's list and issues those five to ten deletes, and the keyspace size drops out of both terms [19].

What the author counts is what every other request pays while the removal runs, because Redis executes commands on a single thread [11][9]. A KEYS command matching an invalidation pattern across several million keys can block that thread for seconds, and a client doing nothing but a single GET waits for it to finish [10].

All the performance figures are local benchmarks, by the author's own statement [3]. For them to transfer, the keyspace has to be of roughly that order. The fan-out has to sit near five to ten [7]. And the Redis instance's single thread has to be shared with the same kind of read traffic [9]. A loopback run also hides per-command network cost. On localhost an index read plus ten DELs is close to free; over a network each of those commands pays a round trip unless it is pipelined. That moves the deleting client's own latency [11]. The argument rests on what every other request pays.

The key builder is stricter than it has to be, and I think that is deliberate. buildCacheKey throws a TypeError when the first argument is not a string, and an entity ID outside the segment character class throws as well; it is never escaped [14]. A delimiter inside an ID therefore fails at the call site instead of producing a key that no index lookup will match. The params segment is the canonical JSON of the second argument, or of the whole argument tail when there is more than one [15], so the property order a caller used does not fork the key.

The params interface carries a comment saying the type "IS the key contract". Then an instruction: "Add nothing here that the bulk seeder does not also write." [17] That is the condition the benchmark depends on. If the seeder writes a params shape the decorator would never build, the recorded run measures keys that no read path in the service generates. The demo ships the implementation, the benchmark scripts and the recorded results, and the author's instruction is to run it locally and repeat the experiments [4]. Failure handling and concurrency limits are listed among what the code covers [1].

What to watch

  • Recorded latency for unrelated GETs during invalidation under each strategy, measured off loopback with a real network hop between client and Redis.
  • How the index behaves when a delete partially fails or hits the stated concurrency limit, and whether stale members are left behind.
  • Whether the index entries expire with the cached keys they name, or drift as TTLs fire independently.
Loading claim ledger
Loading source directory links
Loading share composer
Loading topic controls
Loading related stories