Redis operations and reliability

JavaScript
import { connect } from "runtime:db";
import { redisCluster } from "@opentf/esrun-redis";

const cluster = await connect("redis://10.0.0.1:7001", {
  driver: redisCluster,
  seeds: ["redis://10.0.0.2:7001"],
});
await cluster.set("user:1", "ada");

A different driver rather than a flag, because it is a different client: it holds a pool per node and routes by slot. One seed is enough — the topology is read from the cluster itself — but naming several means it can still start when one of them is down, which is the situation a cluster exists for.

Routing is an optimization; correctness comes from following redirects. Keys are hashed to one of the 16384 slots and sent straight to the owning node, but a cluster corrects a client that guessed wrong — so a bad guess is slow, not wrong. MOVED updates the map; ASK is preceded by ASKING on the same connection and does not, because treating one as the other during a resharding would point every later key at a node that does not own it yet.

One command touching keys in different slots has no node that owns both:

JavaScript
await cluster.mget("foo", "bar");                          // CROSSSLOT
await cluster.mget("{cart:9}:items", "{cart:9}:total");    // same slot, fine

A transaction must be single-slot and is refused before being sent, naming hash tags as the fix. A pipeline may span nodes: it is split per node, each group stays one round trip, and the groups run at the same time. Everything goes to primaries — replicas are read from the topology and ignored, because a replica may be behind and nothing here knows which reads could tolerate that.

Sentinel

JavaScript
import { connect } from "runtime:db";
import { redisSentinel } from "@opentf/esrun-redis";

const r = await connect("redis://10.0.0.1:26379", {
  driver: redisSentinel,
  sentinels: ["redis://10.0.0.2:26379"],
  masterName: "mymaster",
  reconnect: true,
});

The URL is the first sentinel to ask and sentinels names the rest. What comes back is an ordinary connection pointed at the master, so nothing downstream has to know it was found this way.

Each sentinel is tried in turn, and the one that answered moves to the front. The address is verified with ROLE before use: a sentinel mid-failover will hand out a server that has just become a replica, and writing to a replica loses the writes silently.

A failover does not close your connection

The old master is demoted, not killed — it stays up and starts refusing writes with READONLY. That makes a failover invisible to every ordinary recovery path: the socket is fine and nothing is lost. This treats a READONLY reply on a Sentinel-backed connection as the master moved, re-resolves and retries. pool: true survives one by doing what a pool does anyway: every replacement connection resolves again.

Pooling

JavaScript
const pool = await connect("redis://localhost", { driver, pool: { max: 10 } });
await pool.set("k", "v");
await pool.withConnection((c) => …);   // for anything stateful across commands

The same driver and the same call — pooling is an option rather than a different object reached a different way, and the pool answers everything a single connection does.

A connection returns to the pool only if the driver vouches for it: alive, on the database it was opened for, and not inside an open MULTI. A connection left on another database by a stray SELECT is destroyed rather than handed to the next borrower.

Reconnecting

Off by default. { reconnect: true } turns it on. Off because turning it on changes what a thrown error means, and a pool does not need it — replacing a dead connection is reconnection with none of the state questions.

Restored: the handshake, the selected database, the client name, and every subscription. Not restored, deliberately:

The command in flightIt was written, and whether the server ran it is not knowable. Replaying INCR would double-count.
WATCHThe server forgot it. The next EXEC fails with ERR_DB_SERIALIZATION_FAILURE rather than succeeding on a guarantee nobody is making.
An open MULTIIts queued commands went with the connection.
Messages published during the gapPub/sub has no queue and no delivery guarantee.

There is one retry, and it is precise: a command whose write failed never reached the server, so running it again cannot repeat it.

Timeouts

JavaScript
const r = await connect(url, { driver, commandTimeout: 5000, reconnect: true });

A timeout destroys the connection. Redis cannot cancel a command in flight: the server finishes it and sends the reply whenever it is ready, and a client that gave up but kept the connection would read that reply as the next command's answer — every value after it one behind, silently. So the only safe way to stop waiting is to stop using the connection. Set it generously, or not at all.

Errors

Redis's leading word — WRONGTYPE, NOAUTH, LOADING — is mapped onto DbErrorCode, with the original always on e.backendCode.

JavaScript
import { DbErrorCode } from "runtime:db";

try { await r.set("k", "v"); }
catch (e) {
  if (e.code === DbErrorCode.AuthFailed) …   // NOAUTH, WRONGPASS, NOPERM
  if (e.backendCode === "WRONGTYPE") …       // needs Redis-specific handling
}

WRONGTYPE is deliberately not mapped: no portable code means "you ran a list command against a hash", and the nearest one would tell an application something false.

Last updated on
Edit this page