Skip to content

[finding] ioredis-mock@8.13.1 aborts a WATCHed transaction with no competing writer — a per-connection stale watch flag, measured with controls #16053

Description

@os-warren

Recording + measurement only — no severity asserted, no fix chosen. Found while closing #15983 (pinning RedisKV's WATCH/MULTI limbs). It is a different defect class from that card (fidelity of the double, not coverage of our code), and different again from #15986 (which pins the ioredis 5-vs-6 version pair), so it is filed rather than folded into either.

The reading

On ioredis-mock@8.13.1, a connection that has itself EXECed a write to a key carries a stale dirty flag for that key: the next WATCH + EXEC on the same key from that same connection aborts (exec() returns null) even though no competing writer exists. A real Redis server would commit there.

Measured with a standalone script — no vitest, no Proxy, no ObjectStack code in the path — replaying the client-level sequence directly against a bare mock:

ARRAY  set#1  (WATCH k, GET k, MULTI/SET k, EXEC)
NULL   set#2  (same key, same connection, no competing writer)
ARRAY  set#3  (the abort clears the flag)
ARRAY  after an explicit UNWATCH between the two
ARRAY  CONTROL: fresh key on a fresh connection

The last line is the control, and it fires: the probe reports ARRAY where no abort is expected, so the NULL readings are a discrimination rather than a constant.

Two further measurements bounding the shape:

  • The flag is per-connection. A second connection WATCHing a key the first EXECed on is unaffected (ARRAY).
  • WATCH itself is genuinely implemented. A competing write from another connection between WATCH and EXEC does abort, including when the rival rewrites byte-identical content — which is correct: real WATCH tracks writes, not value changes. So this is not a watch()-as-no-op double.

Where it already shows up

Instrumenting the shipped packages/services/service-cluster-redis/src/redis.contract.test.ts (recording Proxy around makeClient, suite otherwise unmodified, 28/28 green under instrumentation): multi().exec() is called 10 times and returns null for 2 of them — in set then get round-trips and increments version and in cas succeeds on match, fails on mismatch. Both are second writes to the same key on the same connection. Nothing in the suite creates a competing writer, so those two aborts are entirely this divergence.

Consequence today: RedisKV.set retries and converges, so both tests pass and the divergence is invisible. It cost #15983 a corrected premise — that card reasoned the retry limb was executed zero times; it is executed twice, for a reason that does not exist on a real server.

Scope of the claim

  • ⛔ No claim that RedisKV is wrong. Its retry loop is what absorbs this.
  • ⛔ No claim about the mechanism inside ioredis-mock; only its observable behaviour is measured here. Not checked against the upstream tracker.
  • ⛔ No claim about any version other than the resolved ioredis-mock@8.13.1 in this workspace, nor about any command other than WATCH/MULTI/EXEC/SET/DEL.
  • ⛔ Nothing about lock, counter or pub/sub behaviour.

Why it is worth writing down

It bounds what this double can certify. Transactional abort semantics are the surface a real server and an in-memory double are most likely to diverge on, and here they measurably do — in the direction of extra aborts, which a retry loop hides. Any future assertion of the form "this transaction committed without a retry" is unsound against this double, and any test that means to drive an abort must arrange a competing writer on a key the driver's own connection has not EXECed on (which is what #15983's pins now do).

Dedup

One targeted MCP search_issues query for this shape ⇒ 4 results, none describing it (#15983 and #15986 are the adjacent cards named above; #15467 is the closed parent; #7880 is objectql's transaction(), unrelated). The same call returning those known-hitting cards is the positive control that the channel answers, so the zero for this shape is a reading and not a silent empty. Search covered closed issues as well as open.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions