redis/redis-rb · error · Redis::Distributed::CannotDistribute
UNWATCH cannot be used in Redis::Distributed because the key
Error message
UNWATCH cannot be used in Redis::Distributed because the keys involved need to be on the same server or because we cannot guarantee that the operation will be atomic.
What it means
Redis::Distributed supports WATCH/MULTI only against a single node: #watch routes all watched keys (which must share a node via ensure_same_node) and records the watched key in @watch_key. #unwatch raises CannotDistribute when @watch_key is nil — that is, when no WATCH is in progress on this instance. The message about same-server/atomicity is misleading here; the real condition is missing transaction state. @watch_key is also cleared by #exec, #discard, and a failed #watch, so unwatch after any of those raises too.
Source
Thrown at lib/redis/distributed.rb:1317
end
# Watch the given keys to determine execution of the MULTI/EXEC block.
def watch(*keys, &block)
ensure_same_node(:watch, keys) do |node|
@watch_key = key_tag(keys.first) || keys.first.to_s
begin
node.watch(*keys, &block)
rescue StandardError
@watch_key = nil
raise
end
end
end
# Forget about all watched keys.
def unwatch
raise CannotDistribute, :unwatch unless @watch_key
result = node_for(@watch_key).unwatch
@watch_key = nil
result
end
def pipelined
raise CannotDistribute, :pipelined
end
# Mark the start of a transaction block.
def multi(&block)
raise CannotDistribute, :multi unless @watch_key
node_for(@watch_key).multi(&block)
end
# Execute all commands issued after MULTI.View on GitHub (pinned to 2ba9010b91)
Solutions
- Prefer the block form — dist.watch("k1", "k2") { dist.multi { |txn| ... } } — the underlying node unwatches itself, including on failure
- Only call unwatch when you know watch succeeded and neither exec nor discard has run since; keep that sequence inside one method so state cannot drift
- If you must probe state first, check @watch_key: dist.unwatch if dist.instance_variable_get(:@watch_key)
- In cleanup blocks, rescue Redis::Distributed::CannotDistribute and treat it as already-clean
Example fix
# before
dist.watch("k1")
val = dist.get("k1")
if val
dist.multi { |txn| txn.set("k1", new_val) }
else
dist.unwatch # fine here, but raises if exec already ran or watch never did
end
# after: block form — watch/unwatch lifecycle handled for you
dist.watch("k1") do
val = dist.get("k1")
dist.multi { |txn| txn.set("k1", new_val) } if val
end Defensive patterns
Strategy: validation
Validate before calling
# The facade has no public watching? reader; check the same state it checks
dist.unwatch if dist.instance_variable_get(:@watch_key)
# Better: wrap the whole optimistic transaction so the state machine cannot be observed mid-flight
def optimistic_write(dist, key)
dist.watch(key) do
current = dist.get(key)
dist.multi { |txn| txn.set(key, yield(current)) }
end
end Type guard
def watching?(dist) !dist.instance_variable_get(:@watch_key).nil? end
Try / catch
begin dist.unwatch rescue Redis::Distributed::CannotDistribute # no watch in progress — already clean end
Prevention
- Prefer the block form of watch; it owns the unwatch, including on failure
- Never split watch/multi/exec/unwatch across threads or methods you do not control
- Remember that exec, discard, and a failed watch all clear the watched key — unwatch after any of them raises
- Treat this CannotDistribute in cleanup paths as a no-op, not a distribution problem
When it happens
Trigger: Calling dist.unwatch before any dist.watch on the instance; after #exec or #discard already consumed the watch; on a different Distributed instance than the one that called watch; mixing the block form of watch (which unwatches via the node) with a manual unwatch afterwards; a raised error inside a watch sequence leaving state out of sync.
Common situations: Porting standalone watch/unwatch choreography to a sharded client; cleanup code that unconditionally calls unwatch in an ensure block; multi-threaded code where one thread's exec clears the watch another thread tries to unwatch; generic transaction wrappers that mirror the Redis command sequence.
Related errors
- Can't unsubscribe if not subscribed.
- MAPPED_MSETNX cannot be used in Redis::Distributed because t
- JSON_MSET cannot be used in Redis::Distributed because the k
- PIPELINED cannot be used in Redis::Distributed because the k
AI-assisted analysis of redis/redis-rb@2ba9010b91 (2026-08-23).
Data as JSON: /api/errors/8903a7afff82dec8.
Report an issue: GitHub.