Repository navigation
[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
Activity
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 inobjectstackbehaves wrongly:RedisKV.setretries and converges, and the card says so first — ⛔ "No claim thatRedisKVis 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 (
ARRAYon a fresh key/fresh connection, where no abort is expected) so theNULLreadings 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), andWATCHis genuinely implemented (a competing write does abort, including a byte-identical rewrite — correct, since real WATCH tracks writes, not value changes). ⇒ ⛔ Not awatch()-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()returnsnullfor 2 of 10 calls in the shippedredis.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):
- A test asserts, against this double, that a transaction committed without a retry — that assertion is unsound here and the card is its refutation.
ioredis-mockis 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.- 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/DELonly, explicitly declining to claim anything about the mechanism insideioredis-mockor 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
- (a) reproducible defect — the defect is real, reproducible and beautifully measured, but it is in
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 nextWATCH+EXECon the same key from that same connection aborts (exec()returnsnull) 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:
The last line is the control, and it fires: the probe reports
ARRAYwhere no abort is expected, so theNULLreadings are a discrimination rather than a constant.Two further measurements bounding the shape:
ARRAY).WATCHandEXECdoes abort, including when the rival rewrites byte-identical content — which is correct: real WATCH tracks writes, not value changes. So this is not awatch()-as-no-op double.Where it already shows up
Instrumenting the shipped
packages/services/service-cluster-redis/src/redis.contract.test.ts(recordingProxyaroundmakeClient, suite otherwise unmodified, 28/28 green under instrumentation):multi().exec()is called 10 times and returnsnullfor 2 of them — inset then get round-trips and increments versionand incas 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.setretries 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
RedisKVis wrong. Its retry loop is what absorbs this.ioredis-mock; only its observable behaviour is measured here. Not checked against the upstream tracker.ioredis-mock@8.13.1in this workspace, nor about any command other thanWATCH/MULTI/EXEC/SET/DEL.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_issuesquery 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'stransaction(), 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.