Redis keys need owners

Blueprint showing four Redis coordination primitives routed through explicit owners, paging rules, and bounded telemetry.

The first useful Redis inventory I ran was not sophisticated. It listed keys, their types, and their remaining TTL. The output made the architecture problem obvious.

Some keys expired in seconds. Some had hours left. Some returned -1, which meant no expiry at all. Names hinted at locks, counters, one-time claims, feature flags, and old experiments. A few prefixes belonged to code nobody on call recognized.

Redis was healthy. The design around it was not.

Adding memory or tuning a connection pool would not have fixed this. Every team had used the same client as a bag of commands, so the application had no shared answer to basic questions:

  • Who owned this key?
  • Could it outlive the process that wrote it?
  • What happened if Redis was unavailable?
  • Was the value disposable, durable, or part of a coordination protocol?
  • Which team should receive an alert?

I stopped treating the problem as a Redis wrapper. The missing piece was an ownership model.

Redis had three different jobs

Many Redis designs become confusing because "stored in Redis" gets treated as a data classification. It is only an implementation detail. The consequence of losing or delaying the value matters more.

I split the use cases into three groups.

Cache data could be recomputed from another source. A miss might cost time, but it should not change the business result. This belonged behind the application's cache interface, with an eviction policy chosen for cache behavior.

Durable data had to survive restarts and operational mistakes. Redis can be configured with snapshots, append-only files, replication, and other durability controls, but those choices still need to match the loss the application can tolerate.1 Data that already had a durable relational model stayed in the database.

Coordination data helped multiple processes agree for a bounded period. Locks, claims, rate counters, and short-lived signals belonged here. Expiry was usually part of correctness, not cleanup.

That classification prevented the new layer from becoming a second application database with nicer method names. It also made the failure policy easier to discuss. A cache can often fail as a miss. A claim guarding duplicate work may need to fail closed. A notification can be dropped only when consumers have another way to reconcile.

The registry came before the API

A normal wrapper hides a client:

redis.set(key, value, ex: ttl)

That still leaves every caller free to invent a key, skip the TTL, and choose new failure behavior. The code looks tidier while the operational problem stays intact.

I wanted every coordination use case declared once:

Coordination.register(
  :invoice_export_claim,
  primitive: :claim,
  owner: :billing,
  ttl: 15.minutes,
  failure: :closed,
  description: "Only one worker may export an invoice batch"
)

Callers then used the registered name and supplied only the dynamic identifier:

Coordination.claim(:invoice_export_claim, batch_id) do
  InvoiceExporter.run(batch_id)
end

The declaration carried information that raw Redis commands never could:

  • primitive selected the command sequence and return contract.
  • owner routed dashboards and pages.
  • ttl made the lifetime reviewable.
  • failure documented what the application should do when Redis could not answer.
  • description gave an operator enough context to recognize the key without opening the codebase.

New primitives still required design review. New uses of an existing primitive needed a declaration, not another hand-written command sequence.

A small primitive set was enough

The temptation was to model every Redis data type. That would have recreated the client library and given direct access a new name.

The application needed a smaller vocabulary.

Lock

A lock meant mutual exclusion for a bounded window. Acquisition used one atomic SET with NX and an expiry, not SETNX followed by EXPIRE.2

SET coordination:email_batch_lock:42 <owner-token> NX EX 30

The owner token mattered during release. Imagine worker A acquired a 30-second lock, paused for 35 seconds, and woke after worker B had acquired the same key. A plain DEL from worker A would delete worker B's lock.

Release had to compare ownership before deleting:

if redis.call("GET", KEYS[1]) == ARGV[1] then
  return redis.call("DEL", KEYS[1])
end

return 0

Redis documents the same token check for a safe single-instance lock.3 Newer Redis versions also provide conditional delete operations, but the Lua form remains useful across older fleets.

A TTL still did not make the protected operation magically safe. It only bounded the lock's validity window. Any operation that could run longer needed a realistic TTL, renewal with ownership checks, or an idempotent design that tolerated overlap.

Claim

A claim answered a narrower question: had anyone already accepted this piece of work?

SET coordination:webhook_claim:evt_123 <worker-token> NX EX 86400

Unlike a lock, a successful claim usually stayed until expiry. There was no release at the end of the block. Deleting it would allow the same work to be accepted again.

I kept claim as a first-class name even though its command resembled lock acquisition. The application contract was different, and that difference mattered during review. A method called lock invites cleanup. A method called claim makes the one-time decision visible.

Claims were not a substitute for durable idempotency when duplicate work could move money, send an irreversible message, or update an external system. Redis could reduce duplicates. A durable business key still had to enforce the result when the consequence demanded it.

Counter

Counters looked simple until expiry entered the picture. INCR followed by EXPIRE creates a crash window where the increment succeeds and the TTL never gets set.

For a fixed window, the transition belonged in one script:

local count = redis.call("INCR", KEYS[1])

if count == 1 then
  redis.call("EXPIRE", KEYS[1], ARGV[1])
end

return count

The registry supplied the window. The caller supplied the subject being counted. Nobody on the request path got to decide whether expiry was optional.

Signal

Pub/Sub worked for a wake-up signal, not a durable event. Redis describes Pub/Sub delivery as at most once. A disconnected subscriber misses the message permanently.4

That was acceptable only when the message meant "go reread the source of truth." The payload carried a small invalidation marker, while the durable value stayed in the database. A subscriber that reconnected could rebuild its snapshot without replaying every missed notification.

If the event itself had to survive a disconnect, Pub/Sub was the wrong primitive. A stream, queue, or database-backed outbox fit that contract better.

TTL was a policy decision

Before the registry, expiry had been whatever the caller remembered to add. Afterward, every primitive had one of two explicit states:

  • a required finite TTL
  • a reviewed reason for no expiry

Coordination keys almost always fell into the first group. A missing expiry on a lock could block work forever. A missing expiry on a rate counter could turn a temporary window into a permanent denial. A missing expiry on a claim could prevent a legitimate retry months later.

Redis exposes TTL for this audit. A result of -1 means the key exists without an expiry.5 During the migration, I tracked registered coordination keys with no TTL as its own metric. The first target was not a p99 improvement. It was getting that count to zero.

That target changed reviews. "What is the TTL?" became part of adding the use case, not a cleanup ticket after an incident.

Telemetry needed names, not raw keys

The old debugging path ended at the Redis instance. An alert said connections were failing or memory was growing. Someone opened a shell, inspected prefixes, and tried to work out which application path owned the activity.

The registry already knew the missing dimensions. Every operation could emit:

coordination_operation_total{
  primitive="claim",
  use_case="invoice_export_claim",
  owner="billing",
  outcome="acquired"
}

I kept the label set bounded:

  • primitive
  • registered use case
  • owner
  • outcome
  • broad error class

Raw Redis keys, customer identifiers, batch IDs, and queue names stayed out of metric labels. Prometheus warns that every unique label combination creates another time series, so unbounded values can make the monitoring system the next incident.6

Dynamic identifiers belonged in sampled logs or traces. Metrics answered which use case was failing and which team owned it. Logs answered which specific operation failed.

Once those dimensions existed, paging could follow ownership. A problem in one registered counter no longer woke every team that happened to share the Redis cluster. Dashboards grouped latency, failures, and volume by the same names engineers saw in code.

The telemetry also exposed architectural drift:

  • operations against unregistered use cases
  • keys with no expiry
  • lock releases rejected because the owner token no longer matched
  • claim collisions
  • connection errors by primitive
  • unexpected changes in key count per use case

That was a much better control surface than logging every command.

Failure behavior belonged in the declaration

Rescuing a Redis exception at random call sites made the system impossible to reason about. Some callers retried. Some skipped the feature. Some allowed the work through. Others crashed the request.

The registry forced one decision per use case.

Fail closed when proceeding could violate exclusivity or accept duplicate work. The caller received a coordination-unavailable error and decided whether to retry later.

Fail open only when skipping coordination caused less harm than rejecting the request. A best-effort suppression flag might fit. A lock around an irreversible external call usually did not.

Use a fallback value only when the fallback was part of the product contract and safe to cache locally.

These policies could not be chosen correctly at the Redis client layer. The client knew the command failed. The registered use case knew what that failure meant.

The migration did not need a rewrite

Moving every Redis call at once would have mixed correctness changes with a large mechanical diff. I used a narrower sequence.

  1. Inventory direct calls and group them by behavior, not command name.
  2. Classify each use case as cache, durable data, or coordination.
  3. Move cache-shaped data behind the cache interface.
  4. Move durable state to its real source of truth.
  5. Register the remaining locks, claims, counters, and signals.
  6. Add telemetry before changing behavior, so old and new paths could be compared.
  7. Block new direct client access with a static check.
  8. Migrate one use case at a time and remove the old helper after its final caller moved.

The static check mattered. Without it, the migration list grew whenever someone needed a quick key. A narrow escape hatch handled infrastructure code and tests, with a comment explaining why the direct call belonged there.

Compatibility also mattered during mixed-version deploys. A new lock representation could not replace an integer counter if older processes still ran INCR on that key. When semantics changed, the rollout needed a new key version, a companion key, or an atomic script that both versions understood.

What the abstraction refused to do

The most useful API boundary was the list of operations it rejected.

It did not expose GET, SET, or EVAL as generic escape hatches. It did not accept an arbitrary TTL from every caller. It did not let application code invent metric labels. It did not pretend Pub/Sub had replay. It did not turn Redis records into application models.

Those refusals kept the layer small enough to review. They also made the remaining direct calls visible, which was the point.

Where I landed

Redis had not become safer because the commands changed. The improvement came from making the decisions around those commands explicit.

A key gained an owner. A primitive named its contract. TTL became part of correctness. Failure behavior stopped hiding in rescue blocks. Metrics identified the use case without copying raw keys into labels. Alerts reached the team that could act on them. The count of coordination keys without an expiry reached zero.

That is the design I would start with now. Not a universal Redis model and not a clever client wrapper. A small registry, a few hard-to-misuse primitives, and enough operational context to answer who owns the key before the next incident asks the question for you.

Footnotes

  1. Redis documentation, Persistence. Redis supports snapshotting, append-only files, both together, or no persistence. Each option carries a different loss and recovery boundary.

  2. Redis command reference, SET. NX and EX or PX can be applied in the same command, avoiding the gap between acquiring a key and setting its expiry.

  3. Redis documentation, Distributed locks with Redis. The single-instance pattern stores a unique value and checks that value before deleting the lock.

  4. Redis documentation, Pub/Sub delivery semantics. Pub/Sub delivers a message once if the subscriber is connected and does not replay it after a disconnect.

  5. Redis command reference, TTL. The command reports the remaining lifetime and returns -1 when a key exists without an expiry.

  6. Prometheus documentation, Metric and label naming. Each distinct label set becomes a time series, so unbounded identifiers should not be used as label values.

Comments