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

  1. os-zhuang commented on Sep 7, 2026

    @os-zhuang
    Contributor

    Triage: closing not planned — outside the three admission classes (#16351, as refined by PR #16557). ⚠️ This is the closure I am least comfortable with in today's round, so the reasoning is set out in full rather than asserted.

    Against each class:

    • (a) reproducible defect — the defect is real, reproducible and beautifully measured, but it is in ioredis-mock@8.13.1, not in this tree. Nothing in objectstack behaves wrongly: RedisKV.set retries and converges, and the card says so first — ⛔ "No claim that RedisKV is wrong. Its retry loop is what absorbs this."
    • (b) violation of a declared contract — ⛔ none of ours.
    • (c) metadata trap — ⛔ not applicable.

    ⇒ The card's own framing is the disposition: ⛔ "Recording + measurement only — no severity asserted, no fix chosen." There is no work item here, and a card with no work item is a record.

    ⭐ The measurement deserves to be named because it is exemplary, and closing it is not a judgement on its quality. A standalone script — no vitest, no Proxy, no ObjectStack code in the path — replaying the client sequence against a bare mock, with the control on the same probe (ARRAY on a fresh key/fresh connection, where no abort is expected) so the NULL readings are a discrimination rather than a constant. Plus two bounding measurements that rule out the lazy explanations: the flag is per-connection (a second connection is unaffected), and WATCH is genuinely implemented (a competing write does abort, including a byte-identical rewrite — correct, since real WATCH tracks writes, not value changes). ⇒ ⛔ Not a watch()-as-no-op double, and the card proves it rather than assuming it.

    ⭐ And the instrumented reading is the kind of thing that usually goes unmeasured: multi().exec() returns null for 2 of 10 calls in the shipped redis.contract.test.ts, both second writes to the same key on the same connection, with nothing in the suite creating a competing writer. ⇒ Those two aborts are entirely this divergence, and they are invisible because the retry absorbs them.

    承接者: ⛔ none needed — the actionable half is already landed. ⚠️ Checked rather than assumed: the operative consequence is "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", and the card states this is what #15983's pins now do. ⇒ The practice is encoded in the test file, where it survives; what remains here is the explanation of why.

    What I am uncertain about, and it is the real cost of this close: ⭐ this card already prevented one wrong conclusion. #15983 reasoned the retry limb was executed zero times; it is executed twice, for a reason that does not exist on a real server. That is a corrected premise on a landed card — evidence that the explanation has working value, not just archival value. ⛔ Closing does not delete it (a closed issue is searchable and this one is thoroughly indexed), but it does mean nobody meets it unless they look.

    ⇒ ⭐ The durable home for this is a comment beside the double's setup in packages/services/service-cluster-redis/src/redis.contract.test.ts, not an issue — that is where a reader forms the belief this card corrects. ⛔ I am not filing that as work; it is a free rider for whoever next opens that file, and this card is its text.

    Reopen conditions (any, no approval needed):

    1. A test asserts, against this double, that a transaction committed without a retry — that assertion is unsound here and the card is its refutation.
    2. ioredis-mock is upgraded. ⇒ The divergence must be re-measured at the new version; ⛔ do not assume it persists or that it is fixed. The standalone script in this card is the instrument.
    3. A divergence is found in the same family (lock, counter, pub/sub) — explicitly outside this card's claim, and a second member would make it a pattern about the double rather than one behaviour.

    Reversibility: fully reversible; nothing decays. ⭐ Note the scope discipline that makes this safe to close — the card fences its claim to the resolved version in this workspace and to WATCH/MULTI/EXEC/SET/DEL only, explicitly declining to claim anything about the mechanism inside ioredis-mock or about the upstream tracker. ⇒ A finding that states its own boundaries is one a later reader can trust without re-deriving it.


    Generated by Claude Code

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