Skip to content

Commit 4f5e797

Browse files
os-warrenclaude
andauthored
test(service-cluster-redis): pin the WATCH/MULTI abort-retry loop and the versioned-delete MULTI/DEL branch (#16054)
The shipped contract suite drives neither limb to an assertion. Measured with a recording Proxy over an otherwise byte-identical copy of the suite (28/28 green under instrumentation), then re-measured proxy-free by replaying the client-level sequence against a bare ioredis-mock: - `multi().del` is called zero times — `runKVContract` only ever calls `kv.delete('k')` with no options, so the versioned branch of `RedisKV.delete` is entirely unexecuted. The card's reading, confirmed. - `multi().exec()` returns null 2 times out of 10 — so the retry limb is NOT unexecuted, as the card inferred. It runs, unasserted, and it runs for the wrong reason: ioredis-mock@8.13.1 leaves a per-connection stale watch flag after a connection EXECs a write, so that connection's next WATCH+EXEC on the same key aborts with no competing writer present. The new pins therefore run the driver's client on keys it has never EXECed on, seeding and competing from separate connections, so a competing write is the only available cause of an abort. Controls measured and recorded in the file header, including the one that must NOT abort. Claude-Session: https://claude.ai/code/session_01XpTx2tbq3pZRYAdoGt6E6Y Co-authored-by: Claude <noreply@anthropic.com>
1 parent 48b0fcf commit 4f5e797

2 files changed

Lines changed: 277 additions & 0 deletions

File tree

Lines changed: 268 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,268 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* Pins for the two `RedisKV` limbs the shipped contract suite in
5+
* `redis.contract.test.ts` does not assert: the WATCH/MULTI abort-retry
6+
* loop, and the versioned-delete MULTI/DEL branch.
7+
*
8+
* ## What was measured before writing these, and how
9+
*
10+
* Instrument 1 — a recording `Proxy` around the `makeClient()` of an
11+
* otherwise byte-identical copy of the shipped suite, run unmodified
12+
* (28/28 green under instrumentation), dumping every method the driver
13+
* invoked on the injected client plus every `multi().exec()` return
14+
* value. Its reading:
15+
*
16+
* - `multi().del` is called ZERO times. The only call site is the
17+
* `opts.ifVersion !== undefined` branch of `RedisKV.delete`, and
18+
* `runKVContract` only ever calls `kv.delete('k')` with no options,
19+
* so the versioned branch is entirely unexecuted. Confirmed.
20+
* - `multi().exec()` is called 10 times and returns `null` for 2 of
21+
* them. So the `result === null` retry limb is NOT unexecuted — it
22+
* runs twice, in `set then get round-trips` and in `cas succeeds on
23+
* match, fails on mismatch`. Nothing asserts that it ran, and both
24+
* tests pass either way, so it was unpinned rather than unreached.
25+
*
26+
* Instrument 2 — a proxy-free replay of the exact client-level sequence
27+
* `RedisKV.set` issues (WATCH, GET, MULTI/SET, EXEC) against a bare
28+
* `ioredis-mock`, to rule the Proxy itself out as the cause. It
29+
* reproduces the two nulls, so the behaviour is the double's.
30+
*
31+
* ## The divergence those two nulls come from
32+
*
33+
* On `ioredis-mock@8.13.1` a connection that has itself EXECed a write to
34+
* a key carries a stale dirty flag for it: the NEXT WATCH+EXEC on that
35+
* same key from that same connection aborts once even though no competing
36+
* writer exists. Measured, with controls that fire:
37+
*
38+
* - fresh key on a fresh connection, no competing writer: EXEC returns
39+
* an array, NOT null (so the probe distinguishes the two outcomes)
40+
* - second WATCH+EXEC on the same key from the same connection: null
41+
* - third: array again (the abort clears the flag)
42+
* - the flag is per-connection: another connection WATCHing a key this
43+
* one EXECed on is unaffected
44+
* - an explicit UNWATCH between the two clears it
45+
*
46+
* A real server would not abort there. That is why every pin below runs
47+
* the driver's client on a key that client has never EXECed on, seeding
48+
* and competing from SEPARATE connections over ioredis-mock's shared
49+
* store: it makes a competing write the only possible cause of an abort.
50+
*
51+
* ## What these pins do and do not establish
52+
*
53+
* They establish that the driver's retry limb converges, and re-reads
54+
* rather than merely re-EXECing, when its client reports an aborted
55+
* transaction; and that the versioned-delete branch reaches MULTI/DEL and
56+
* reports removal from the reply. They also establish that this double
57+
* does implement WATCH abort on a genuine competing write, rather than
58+
* treating `watch()` as a no-op.
59+
*
60+
* They do NOT establish that a real Redis server aborts under the same
61+
* interleaving, nor that this double's WATCH fidelity matches a real
62+
* server in general — the divergence above is proof that it does not.
63+
* That remains the open question the card records, and closing it needs a
64+
* live-Redis path, which this package has none of.
65+
*/
66+
67+
// `ioredis-mock` publishes no type declarations; the shipped suite's
68+
// import carries the full note on what that costs.
69+
// @ts-expect-error — ioredis-mock has no published types
70+
import RedisMock from 'ioredis-mock';
71+
import { describe, expect, it } from 'vitest';
72+
73+
import { RedisKV, VersionMismatchError } from './kv.js';
74+
75+
// ioredis-mock shares state across instances, so a per-test key prefix is
76+
// what isolates the tests — same device the shipped suite uses.
77+
let suffix = 0;
78+
const uniquePrefix = () => `tx${++suffix}:`;
79+
80+
/** The mock is untyped (see the import note), so its instances are `any`. */
81+
type MockClient = any;
82+
83+
interface Recorder {
84+
/** `'NULL'` or `'ARRAY'` per `multi().exec()`, in call order. */
85+
execOutcomes: string[];
86+
/** Commands queued onto a `multi()` chain, e.g. `'set'`, `'del'`. */
87+
multiCommands: string[];
88+
/** Keys passed to `watch()`, in call order. */
89+
watchedKeys: string[];
90+
}
91+
92+
/**
93+
* Wraps an ioredis-mock connection so a test can observe what the driver
94+
* did inside its WATCH/MULTI loop, and can run a competing writer at the
95+
* one instant that matters: after the driver's GET has resolved and
96+
* before it calls EXEC.
97+
*
98+
* The wrapper adds no Redis semantics of its own — every reply, and the
99+
* WATCH bookkeeping that decides whether EXEC aborts, still comes from
100+
* ioredis-mock.
101+
*/
102+
function instrument(
103+
raw: MockClient,
104+
onFirstGet?: (rec: Recorder) => Promise<void>,
105+
): { client: MockClient; rec: Recorder } {
106+
const rec: Recorder = { execOutcomes: [], multiCommands: [], watchedKeys: [] };
107+
let hookFired = false;
108+
109+
const wrapMulti = (chain: MockClient): MockClient =>
110+
new Proxy(chain, {
111+
get(target, prop, receiver) {
112+
const value = Reflect.get(target, prop, receiver);
113+
if (typeof prop !== 'string' || typeof value !== 'function') return value;
114+
return (...args: unknown[]) => {
115+
if (prop !== 'exec') rec.multiCommands.push(prop);
116+
const out = value.apply(target, args);
117+
if (prop === 'exec') {
118+
return Promise.resolve(out).then((reply: unknown) => {
119+
rec.execOutcomes.push(reply === null ? 'NULL' : 'ARRAY');
120+
return reply;
121+
});
122+
}
123+
// Keep the chain observable when a command returns `this`.
124+
return out === target ? receiver : out;
125+
};
126+
},
127+
});
128+
129+
const client: MockClient = new Proxy(raw, {
130+
get(target, prop, receiver) {
131+
const value = Reflect.get(target, prop, receiver);
132+
if (typeof prop !== 'string' || typeof value !== 'function') return value;
133+
return (...args: unknown[]) => {
134+
if (prop === 'watch') rec.watchedKeys.push(String(args[0]));
135+
if (prop === 'multi') return wrapMulti(value.apply(target, args));
136+
if (prop === 'get' && onFirstGet) {
137+
return (async () => {
138+
const reply = await value.apply(target, args);
139+
if (!hookFired) {
140+
hookFired = true;
141+
await onFirstGet(rec);
142+
}
143+
return reply;
144+
})();
145+
}
146+
return value.apply(target, args);
147+
};
148+
},
149+
});
150+
151+
return { client, rec };
152+
}
153+
154+
describe('RedisKV — WATCH/MULTI transaction limbs — redis(mock)', () => {
155+
it('set(): a competing writer between WATCH and EXEC drives the abort-retry limb', async () => {
156+
const keyPrefix = uniquePrefix();
157+
// The rival is a separate connection driving the same production
158+
// class, so the competing write is a real KV write rather than a
159+
// hand-rolled storage envelope.
160+
const rival = new RedisKV({ client: new RedisMock(), keyPrefix });
161+
const { client, rec } = instrument(new RedisMock(), async () => {
162+
await rival.set('k', 'intruder');
163+
});
164+
const kv = new RedisKV({ client, keyPrefix });
165+
166+
const entry = await kv.set('k', 'mine');
167+
168+
// Exactly one abort then one commit: the `result === null` limb ran
169+
// once. Without the competing writer this key is fresh on this
170+
// connection, so a null here has no other available cause.
171+
expect(rec.execOutcomes).toEqual(['NULL', 'ARRAY']);
172+
// v2, not v1. Only a retry that went back through WATCH and GET can
173+
// have seen the rival's v1 and bumped past it; a retry that merely
174+
// re-issued EXEC would still be writing v1.
175+
expect(entry.version).toBe(2n);
176+
expect(entry.value).toBe('mine');
177+
178+
const got = await kv.get<string>('k');
179+
expect(got?.value).toBe('mine');
180+
expect(got?.version).toBe(2n);
181+
182+
await kv.close();
183+
});
184+
185+
it('delete(key, {ifVersion}): a competing writer drives the abort-retry limb into MULTI/DEL', async () => {
186+
const keyPrefix = uniquePrefix();
187+
// Seed from a separate connection so the driver's client has never
188+
// EXECed on this key (see the header note on the per-connection
189+
// stale flag) — the abort below can then only be the rival's doing.
190+
const seeder = new RedisKV({ client: new RedisMock(), keyPrefix });
191+
expect((await seeder.set('k', 'v0')).version).toBe(1n);
192+
193+
// The rival rewrites the row's current bytes verbatim. WATCH tracks
194+
// writes, not value changes, so this aborts the driver's transaction
195+
// while leaving the version at 1 — which is what lets the retry find
196+
// a still-matching `ifVersion` and proceed into MULTI/DEL. The
197+
// physical key is taken from what the driver itself WATCHed, so this
198+
// test does not encode the key-layout private to RedisKV.
199+
const rivalClient = new RedisMock();
200+
const { client, rec } = instrument(new RedisMock(), async (r) => {
201+
const physical = r.watchedKeys[r.watchedKeys.length - 1];
202+
await rivalClient.set(physical, await rivalClient.get(physical));
203+
});
204+
const kv = new RedisKV({ client, keyPrefix });
205+
206+
expect(await kv.delete('k', { ifVersion: 1n })).toBe(true);
207+
expect(rec.execOutcomes).toEqual(['NULL', 'ARRAY']);
208+
expect(rec.multiCommands).toEqual(['del', 'del']);
209+
expect(await kv.get('k')).toBeUndefined();
210+
211+
await kv.close();
212+
});
213+
214+
it('delete(key, {ifVersion}) removes through the MULTI/DEL branch and reports it from the reply', async () => {
215+
const keyPrefix = uniquePrefix();
216+
const seeder = new RedisKV({ client: new RedisMock(), keyPrefix });
217+
expect((await seeder.set('k', 'v0')).version).toBe(1n);
218+
219+
const { client, rec } = instrument(new RedisMock());
220+
const kv = new RedisKV({ client, keyPrefix });
221+
222+
expect(await kv.delete('k', { ifVersion: 1n })).toBe(true);
223+
// The unversioned fast path calls `client.del` and opens no
224+
// transaction; this asserts the versioned branch was the one taken.
225+
expect(rec.multiCommands).toEqual(['del']);
226+
expect(rec.execOutcomes).toEqual(['ARRAY']);
227+
expect(await kv.get('k')).toBeUndefined();
228+
229+
await kv.close();
230+
});
231+
232+
it('delete(key, {ifVersion}) on an absent key returns false without opening a MULTI', async () => {
233+
const keyPrefix = uniquePrefix();
234+
const { client, rec } = instrument(new RedisMock());
235+
const kv = new RedisKV({ client, keyPrefix });
236+
237+
expect(await kv.delete('absent', { ifVersion: 1n })).toBe(false);
238+
expect(rec.multiCommands).toEqual([]);
239+
expect(rec.execOutcomes).toEqual([]);
240+
241+
await kv.close();
242+
});
243+
244+
it('delete(key, {ifVersion}) rejects a stale version, opens no MULTI, and leaves the row', async () => {
245+
const keyPrefix = uniquePrefix();
246+
const seeder = new RedisKV({ client: new RedisMock(), keyPrefix });
247+
await seeder.set('k', 'v0');
248+
249+
const { client, rec } = instrument(new RedisMock());
250+
const kv = new RedisKV({ client, keyPrefix });
251+
252+
// Assert the error's structured fields, not just that something was
253+
// thrown: a bare `toThrow()` stays green when the driver throws a
254+
// plain Error for an unrelated reason.
255+
const err: unknown = await kv
256+
.delete('k', { ifVersion: 99n })
257+
.then(() => null, (e: unknown) => e);
258+
expect(err).toBeInstanceOf(VersionMismatchError);
259+
expect((err as VersionMismatchError).key).toBe('k');
260+
expect((err as VersionMismatchError).expected).toBe(99n);
261+
expect((err as VersionMismatchError).actual).toBe(1n);
262+
263+
expect(rec.multiCommands).toEqual([]);
264+
expect((await kv.get<string>('k'))?.value).toBe('v0');
265+
266+
await kv.close();
267+
});
268+
});

packages/services/service-cluster-redis/src/redis.contract.test.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,15 @@
7070
* why the version gap is inert here, and it is not a reason to trust the
7171
* double: the mock is simply never asked to stand in for the surface on
7272
* which the two majors differ.
73+
*
74+
* Two `RedisKV` limbs this file drives but does not assert are pinned in
75+
* the sibling `kv.transaction.test.ts`: the WATCH/MULTI abort-retry loop,
76+
* and the versioned-delete MULTI/DEL branch. Measured here rather than
77+
* assumed: `multi().del` is called zero times by these suites, while
78+
* `multi().exec()` DOES return null twice — not because a competing
79+
* writer exists, but because of a per-connection stale-watch divergence
80+
* in the double. The sibling file carries that measurement and its
81+
* controls.
7382
*/
7483

7584
// `ioredis-mock` publishes no type declarations of its own — no `types`

0 commit comments

Comments
 (0)