Transactions
Run commands as one MULTI and EXEC block, with optional WATCH for optimistic locking.
A transaction queues commands with MULTI and runs them together with EXEC. No other client's command runs between them. redis.transaction takes Commands.* values and returns a TransactionOutcome.
Example (Reading and Incrementing in One Transaction)
import { } from "effect";
import { Commands, } from "@effect-kit/redis";
const = .(function* () {
const = yield* ;
const = [Commands.("{account:42}:name"), Commands.("{account:42}:visits")] as ;
const outcome = yield* .();
if (. === "Conflict") return "conflict";
return .;
});TransactionOutcome is { _tag: "Committed", results } or { _tag: "Conflict" }. results has one Result per command, typed like a pipeline. Conflict means a watched key changed, so Redis dropped the block. It is a success value, not an error.
No rollback
If one command fails at run time, the others stay applied. EXEC does not undo them. Only the
queueing step is all or nothing: a rejected command at queue time fails the transaction with
reason: "queue-rejected".
Pipeline or transaction
redis.pipeline | redis.transaction | |
|---|---|---|
| Round trips | One | One |
| Other clients can interleave | Yes | No |
| Result per command | Result | Result, inside Committed |
| WATCH conflict | Not applicable | Conflict |
| Run-time error in one command | Others still apply | Others still apply |
Submission on standalone connections
On a standalone native connection, a profile of RedisProfiles.redis() or RedisProfiles.valkey() lets the client send the queued commands and EXEC together once MULTI succeeds. Other profiles send them one step at a time. Results and errors are the same either way. See Server engines.
Handling transaction errors
A transaction that cannot run fails with a TransactionError. Its reason is "queue-rejected" or "exec-rejected", and execution is "not-executed" or "unknown". Failures of the batch itself, such as a lost connection, arrive as BatchError with per-command evidence.
Example (Telling a Rejected Transaction From a Lost One)
import { } from "effect";
import { Commands, } from "@effect-kit/redis";
const = .(function* () {
const = yield* ;
return yield*
.([Commands.("orders:count")])
.(
.("TransactionError", () =>
.(
. === "not-executed" ? "safe to retry" : "check the key first",
),
),
);
});"not-executed" means the block never ran. "unknown" means it may have.
Optimistic locking with WATCH
WATCH marks keys before the transaction. If another client changes one before EXEC, the outcome is Conflict. WATCH belongs to a connection, so it lives on RedisSessions: sessions.watch(keys, body) leases a session, watches the keys, runs body and cleans up, even on early return or interruption. keys must have at least one key.
Example (Retrying a Read-Modify-Write on Conflict)
import { , } from "effect";
import { Commands, } from "@effect-kit/redis";
const = "{account:42}:balance";
const = (: number) =>
.(function* () {
const = yield* ;
const = .([], () =>
.(function* () {
const = yield* .();
const = (.(, () => "0")) + ;
return yield* .([Commands.(, ())]);
}),
);
// Another client changed the key between WATCH and EXEC: try again
let = yield* ;
while (. === "Conflict") = yield* ;
return ;
});Inside the body, session runs commands on the leased connection, so reads are under the watch. A session can also call session.watch(keys) and session.unwatch() by hand.
WATCH is not available on Upstash, which has no sessions. Transactions themselves work over HTTP, but commands with ambiguous replies (raw, custom or script commands) are rejected with an UpstashError whose reason is "encoding".