{"id":"5411ba00c490aebd","repo":"redis/node-redis","slug":"one-or-more-of-the-watched-keys-has-been-changed","errorCode":null,"errorMessage":"One (or more) of the watched keys has been changed","messagePattern":"One \\(or more\\) of the watched keys has been changed","errorType":"exception","errorClass":"WatchError","httpStatus":null,"severity":"error","filePath":"packages/client/lib/client/index.ts","lineNumber":1928,"sourceCode":"   * @internal\n   */\n  async _executeMulti(\n    commands: Array<RedisMultiQueuedCommand>,\n    selectedDB?: number\n  ) {\n    assertNoHimportSessionCommands(commands);\n\n    const dirtyWatch = this._self.#dirtyWatch;\n    this._self.#dirtyWatch = undefined;\n    const watchEpoch = this._self.#watchEpoch;\n    this._self.#watchEpoch = undefined;\n\n    if (!this._self.#socket.isOpen) {\n      throw new ClientClosedError();\n    }\n\n    if (dirtyWatch) {\n      throw new WatchError(dirtyWatch);\n    }\n\n    if (watchEpoch && watchEpoch !== this._self.socketEpoch) {\n      throw new WatchError('Client reconnected after WATCH');\n    }\n\n    const batchSize = commands.length;\n\n    return trace(CHANNELS.TRACE_BATCH,\n      async () => {\n        const typeMapping = this._commandOptions?.typeMapping;\n        const chainId = Symbol('MULTI Chain');\n        const promises: Array<Promise<unknown>> = [\n          this._self.#queue.addCommand(['MULTI'], { chainId }),\n        ];\n\n        for (const { args } of commands) {\n          promises.push(","sourceCodeStart":1910,"sourceCodeEnd":1946,"githubUrl":"https://github.com/redis/node-redis/blob/bb5beb56578573910e2ee8f39681edc214c41398/packages/client/lib/client/index.ts#L1910-L1946","documentation":"Thrown from _executeMulti (client/index.ts:1928) when #dirtyWatch is set. The client-side flag is set via setDirtyWatch(), which the Sentinel client calls ('sentinel config changed in middle of a WATCH Transaction') when a Sentinel master switch happens during a WATCH transaction. Rather than risk executing EXEC against a new master that never saw the WATCH, the client aborts the transaction locally and never sends EXEC. It surfaces as a WatchError carrying the dirty-watch reason.","triggerScenarios":"Using a Sentinel-managed client: call client.WATCH(key), then while a multi()/EXEC is in flight a Sentinel master failover/switch occurs, marking the client dirty. The next multi().exec() rejects before contacting the server.","commonSituations":"Sentinel deployments under failover; long-running MULTI/EXEC transactions that span a master switch; testing against a Sentinel topology that flips masters frequently.","solutions":["Treat WatchError as a transient, retryable condition: catch it and replay the entire WATCH + MULTI/EXEC sequence on the (now current) master.","Keep WATCH/MULTI/EXEC transactions short to shrink the window in which a Sentinel switch can invalidate them.","After a catch, re-acquire the leased/current master from Sentinel before retrying, since the old connection's master is gone."],"exampleFix":"// before\nawait client.watch('k');\nconst res = await client.multi().get('k').set('k', 'v').exec(); // rejects with WatchError on master switch\n\n// after\nasync function txn(client) {\n  for (let attempt = 0; attempt < 3; attempt++) {\n    await client.watch('k');\n    try {\n      return await client.multi().get('k').set('k', 'v').exec();\n    } catch (e) {\n      if (e.name === 'WatchError' && attempt < 2) continue;\n      throw e;\n    }\n  }\n}","handlingStrategy":"retry","validationCode":"if (client.isDirtyWatch) {\n  // a topology change has already invalidated WATCH; do not EXEC\n  await client.unwatch();\n}","typeGuard":"import { WatchError } from '@redis/client';\nfunction isWatchError(e: unknown): e is WatchError {\n  return e instanceof Error && (e instanceof WatchError || e.constructor.name === 'WatchError');\n}","tryCatchPattern":"try {\n  await client.multi().set('k', 'v').exec();\n} catch (e) {\n  if (isWatchError(e)) { /* re-acquire master from Sentinel, replay WATCH+MULTI */ }\n  else throw e;\n}","preventionTips":["Keep WATCH/MULTI/EXEC transactions short in Sentinel deployments.","Check client.isDirtyWatch before queuing commands and UNWATCH if true.","Always replay the full WATCH+transaction on WatchError; never assume EXEC ran."],"tags":["sentinel","transactions","watch","optimistic-locking","topology"],"analyzedSha":"bb5beb56578573910e2ee8f39681edc214c41398","analyzedAt":"2026-08-03T19:09:15.686Z","schemaVersion":2}