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

  1. Prefer the block form — dist.watch("k1", "k2") { dist.multi { |txn| ... } } — the underlying node unwatches itself, including on failure
  2. 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
  3. If you must probe state first, check @watch_key: dist.unwatch if dist.instance_variable_get(:@watch_key)
  4. 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

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


AI-assisted analysis of redis/redis-rb@2ba9010b91 (2026-08-23). Data as JSON: /api/errors/8903a7afff82dec8. Report an issue: GitHub.