|
| 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 | +}); |
0 commit comments