Transactions
Radish supports Redis-style transactions using MULTI, EXEC, and DISCARD. Transactions provide atomicity — a group of commands executes as a single indivisible unit, with no other client’s commands interleaved.
Why Transactions?
Consider a simple bank transfer:
S_SET account_A 1000
S_SET account_B 500
To transfer 100 from A to B, you need three operations:
- Read A’s balance
- Decrement A by 100
- Increment B by 100
Without transactions, another client could modify A between steps 1 and 2, leading to an incorrect final state. Transactions solve this by guaranteeing that all three operations happen atomically.
How to Use Them
Basic Flow
RADISH-CLI> MULTI # Start transaction
OK
RADISH-CLI> S_SET mykey hello # Commands are queued, not executed
QUEUED
RADISH-CLI> S_GET mykey
QUEUED
RADISH-CLI> EXEC # Execute all at once
✅ [OK, hello]
Counter Increment
RADISH-CLI> S_SET counter 10
OK
RADISH-CLI> MULTI
OK
RADISH-CLI> S_INCR counter
QUEUED
RADISH-CLI> S_INCR counter
QUEUED
RADISH-CLI> S_GET counter
QUEUED
RADISH-CLI> EXEC
✅ [true, true, 12]
Aborting a Transaction
RADISH-CLI> MULTI
OK
RADISH-CLI> S_SET key value
QUEUED
RADISH-CLI> DISCARD # Clear the queue, nothing happens
OK
RADISH-CLI> S_GET key
✅ (nil) # Key was never set
Implementation Details
Client Session State
Each client connection maintains a ClientSession:
mutable struct ClientSession
in_transaction::Bool
queued_commands::Vector{Command}
end
When in_transaction == true, the dispatcher queues commands instead of executing them immediately, returning QUEUED to the client.
Atomic Execution
When EXEC is called, the transaction executes in these steps:
sequenceDiagram
participant Client
participant Dispatcher
participant Lock as ShardedLock
participant Context as RadishContext
Client->>Dispatcher: EXEC
Dispatcher->>Dispatcher: Extract all keys from queued commands
Dispatcher->>Dispatcher: Sort keys to prevent deadlock
Dispatcher->>Lock: Acquire write locks (sorted order)
loop For each queued command
Dispatcher->>Context: Execute without re-locking
Context-->>Dispatcher: Result
end
Dispatcher->>Lock: Release all locks (reverse order)
Dispatcher-->>Client: Array of results
Preventing Deadlock
The critical detail is sorted lock acquisition. If Transaction A locks key apple then banana, and Transaction B locks banana then apple, they would deadlock. By always acquiring locks in sorted order, this is impossible.
function extract_all_keys(commands::Vector{Command})
keys = String[]
for cmd in commands
if cmd.key !== nothing
push!(keys, cmd.key)
end
# Multi-key operations (S_LCS, L_MOVE, etc.)
if cmd.name in MULTI_KEY_OPS && !isempty(cmd.args)
push!(keys, cmd.args[1])
end
end
return keys
end
No Rollback
Like Redis, Radish transactions have no rollback. If one command in the transaction fails (e.g., type mismatch), the other commands still execute. The error is included in the result array.
This is a deliberate design choice:
- Commands typically only fail due to programming errors (wrong type, wrong arguments), not runtime conditions
- Rollback would add significant complexity with little practical benefit
- Redis doesn’t support rollback either, and it works well in practice
Error Handling
| Situation | Behavior |
|---|---|
EXEC without MULTI | Returns error |
DISCARD without MULTI | Returns error |
Command fails during EXEC | Error included in result array, other commands continue |
| All commands succeed | Array of results returned |
Limitations
- No WATCH/UNWATCH — Redis’s optimistic locking is not yet implemented
- Write locks for everything — even read operations within a transaction acquire write locks (simpler but more restrictive)
- No partial rollback — if a command fails, there’s no undo