|
| 1 | +/** |
| 2 | + * The Redis claim under a lost or late reply, against real Redis. A TCP proxy between ioredis and |
| 3 | + * Redis lets the claim script run and then drops the connection before its reply (ioredis replays |
| 4 | + * the command on reconnect) or holds the reply past the command timeout. Skipped without |
| 5 | + * `TEST_REDIS_URL`; the Redis client accessor is the only fixture. |
| 6 | + */ |
| 7 | +import { createServer, type Server, type Socket, connect as tcpConnect } from 'node:net' |
| 8 | +import { readTestRedisUrl } from '@sim/db/testing/test-infrastructure' |
| 9 | +import { redisConfigMock, redisConfigMockFns } from '@sim/testing/mocks/redis-config.mock' |
| 10 | +import { sleep } from '@sim/utils/helpers' |
| 11 | +import { generateId } from '@sim/utils/id' |
| 12 | +import Redis from 'ioredis' |
| 13 | +import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' |
| 14 | +import { isRetryableSetupError } from '@/lib/core/errors/retryable-infrastructure' |
| 15 | +import { IdempotencyService } from '@/lib/core/idempotency/service' |
| 16 | + |
| 17 | +vi.mock('@/lib/core/config/redis', () => redisConfigMock) |
| 18 | + |
| 19 | +const redisUrl = readTestRedisUrl() |
| 20 | +const COMMAND_TIMEOUT_MS = 500 |
| 21 | +const CLAIM_SCRIPT_MARKER = 'local claimed' |
| 22 | + |
| 23 | +type ClaimFault = { kind: 'drop-reply' } | { kind: 'delay-reply'; ms: number } |
| 24 | + |
| 25 | +/** Forwards bytes both ways; applies one armed fault to the next reply after a claim script. */ |
| 26 | +class ClaimFaultProxy { |
| 27 | + private server: Server | undefined |
| 28 | + private armed: ClaimFault | undefined |
| 29 | + claimsForwarded = 0 |
| 30 | + |
| 31 | + async start(upstream: URL): Promise<number> { |
| 32 | + this.server = createServer((client) => this.pipe(client, upstream)) |
| 33 | + await new Promise<void>((resolve) => this.server?.listen(0, '127.0.0.1', resolve)) |
| 34 | + const address = this.server.address() |
| 35 | + if (!address || typeof address === 'string') throw new Error('proxy has no port') |
| 36 | + return address.port |
| 37 | + } |
| 38 | + |
| 39 | + arm(fault: ClaimFault): void { |
| 40 | + this.armed = fault |
| 41 | + } |
| 42 | + |
| 43 | + async stop(): Promise<void> { |
| 44 | + await new Promise((resolve) => this.server?.close(resolve)) |
| 45 | + } |
| 46 | + |
| 47 | + private pipe(client: Socket, upstream: URL): void { |
| 48 | + const server = tcpConnect(Number(upstream.port || 6379), upstream.hostname) |
| 49 | + let pendingFault: ClaimFault | undefined |
| 50 | + client.on('data', (chunk) => { |
| 51 | + if (chunk.includes(CLAIM_SCRIPT_MARKER)) { |
| 52 | + this.claimsForwarded++ |
| 53 | + if (this.armed) { |
| 54 | + pendingFault = this.armed |
| 55 | + this.armed = undefined |
| 56 | + } |
| 57 | + } |
| 58 | + server.write(chunk) |
| 59 | + }) |
| 60 | + server.on('data', (chunk) => { |
| 61 | + const fault = pendingFault |
| 62 | + pendingFault = undefined |
| 63 | + if (fault?.kind === 'drop-reply') { |
| 64 | + client.destroy() |
| 65 | + server.destroy() |
| 66 | + return |
| 67 | + } |
| 68 | + if (fault?.kind === 'delay-reply') { |
| 69 | + server.pause() |
| 70 | + setTimeout(() => { |
| 71 | + if (!client.destroyed) client.write(chunk) |
| 72 | + server.resume() |
| 73 | + }, fault.ms) |
| 74 | + return |
| 75 | + } |
| 76 | + if (!client.destroyed) client.write(chunk) |
| 77 | + }) |
| 78 | + client.on('close', () => server.destroy()) |
| 79 | + server.on('close', () => client.destroy()) |
| 80 | + client.on('error', () => server.destroy()) |
| 81 | + server.on('error', () => client.destroy()) |
| 82 | + } |
| 83 | +} |
| 84 | + |
| 85 | +describe.runIf(Boolean(redisUrl))('Redis idempotency claim with a lost or late reply', () => { |
| 86 | + const proxy = new ClaimFaultProxy() |
| 87 | + let redis: Redis |
| 88 | + let direct: Redis |
| 89 | + const service = new IdempotencyService({ |
| 90 | + namespace: `claim-faults-${generateId()}`, |
| 91 | + inProgressTtlSeconds: 60 * 60 * 2, |
| 92 | + storeResultBody: false, |
| 93 | + forceStorage: 'redis', |
| 94 | + }) |
| 95 | + |
| 96 | + beforeAll(async () => { |
| 97 | + const upstream = new URL(redisUrl ?? '') |
| 98 | + const port = await proxy.start(upstream) |
| 99 | + redis = new Redis({ |
| 100 | + host: '127.0.0.1', |
| 101 | + port, |
| 102 | + db: Number(upstream.pathname.slice(1) || 0), |
| 103 | + lazyConnect: true, |
| 104 | + commandTimeout: COMMAND_TIMEOUT_MS, |
| 105 | + maxRetriesPerRequest: 5, |
| 106 | + retryStrategy: () => 50, |
| 107 | + }) |
| 108 | + await redis.connect() |
| 109 | + direct = new Redis(redisUrl ?? '', { lazyConnect: true, maxRetriesPerRequest: 0 }) |
| 110 | + await direct.connect() |
| 111 | + }) |
| 112 | + |
| 113 | + beforeEach(() => { |
| 114 | + redisConfigMockFns.mockGetRedisClient.mockReturnValue(redis) |
| 115 | + }) |
| 116 | + |
| 117 | + afterAll(async () => { |
| 118 | + redis?.disconnect() |
| 119 | + await direct?.quit() |
| 120 | + await proxy.stop() |
| 121 | + }) |
| 122 | + |
| 123 | + async function storedStatus(identifier: string): Promise<string | undefined> { |
| 124 | + const keys = await direct.keys(`idempotency:*:${identifier}`) |
| 125 | + if (keys.length === 0) return undefined |
| 126 | + const value = await direct.get(keys[0]) |
| 127 | + return value ? (JSON.parse(value) as { status?: string }).status : undefined |
| 128 | + } |
| 129 | + |
| 130 | + it('runs the operation once when the claim applied but its reply was lost', async () => { |
| 131 | + const identifier = generateId() |
| 132 | + const operation = vi.fn(async () => 'ran') |
| 133 | + const claimsBefore = proxy.claimsForwarded |
| 134 | + |
| 135 | + proxy.arm({ kind: 'drop-reply' }) |
| 136 | + const execution = await service.executeOrSkipInProgress('generic', identifier, operation) |
| 137 | + |
| 138 | + expect(proxy.claimsForwarded - claimsBefore).toBe(2) |
| 139 | + expect(execution).toEqual({ outcome: 'resolved', result: 'ran' }) |
| 140 | + expect(operation).toHaveBeenCalledTimes(1) |
| 141 | + expect(await storedStatus(identifier)).toBe('completed') |
| 142 | + }) |
| 143 | + |
| 144 | + it('releases its claim and asks for a retry when the claim reply is late, so a retry runs', async () => { |
| 145 | + const identifier = generateId() |
| 146 | + const operation = vi.fn(async () => 'ran') |
| 147 | + |
| 148 | + proxy.arm({ kind: 'delay-reply', ms: COMMAND_TIMEOUT_MS * 1.5 }) |
| 149 | + const failure = await service.executeOrSkipInProgress('generic', identifier, operation).then( |
| 150 | + () => undefined, |
| 151 | + (error: unknown) => error |
| 152 | + ) |
| 153 | + await sleep(COMMAND_TIMEOUT_MS * 2) |
| 154 | + |
| 155 | + expect(isRetryableSetupError(failure)).toBe(true) |
| 156 | + expect(operation).not.toHaveBeenCalled() |
| 157 | + expect(await storedStatus(identifier)).toBeUndefined() |
| 158 | + |
| 159 | + const retry = await service.executeOrSkipInProgress('generic', identifier, operation) |
| 160 | + expect(retry).toEqual({ outcome: 'resolved', result: 'ran' }) |
| 161 | + expect(operation).toHaveBeenCalledTimes(1) |
| 162 | + }) |
| 163 | + |
| 164 | + it('keeps the original error when the stall also outlasts the release', async () => { |
| 165 | + const identifier = generateId() |
| 166 | + const operation = vi.fn(async () => 'ran') |
| 167 | + |
| 168 | + proxy.arm({ kind: 'delay-reply', ms: COMMAND_TIMEOUT_MS * 3 }) |
| 169 | + const failure = await service.executeOrSkipInProgress('generic', identifier, operation).then( |
| 170 | + () => undefined, |
| 171 | + (error: unknown) => error |
| 172 | + ) |
| 173 | + await sleep(COMMAND_TIMEOUT_MS * 4) |
| 174 | + |
| 175 | + expect(failure).toBeInstanceOf(Error) |
| 176 | + expect(isRetryableSetupError(failure)).toBe(false) |
| 177 | + expect(operation).not.toHaveBeenCalled() |
| 178 | + }) |
| 179 | + |
| 180 | + it("still skips a delivery whose key is another holder's live claim", async () => { |
| 181 | + const identifier = generateId() |
| 182 | + const holder = vi.fn(async () => { |
| 183 | + await sleep(COMMAND_TIMEOUT_MS) |
| 184 | + return 'holder' |
| 185 | + }) |
| 186 | + const duplicate = vi.fn(async () => 'duplicate') |
| 187 | + |
| 188 | + const running = service.executeOrSkipInProgress('generic', identifier, holder) |
| 189 | + await sleep(50) |
| 190 | + const second = await service.executeOrSkipInProgress('generic', identifier, duplicate) |
| 191 | + |
| 192 | + expect(second).toEqual({ outcome: 'in-progress' }) |
| 193 | + expect(duplicate).not.toHaveBeenCalled() |
| 194 | + expect(await running).toEqual({ outcome: 'resolved', result: 'holder' }) |
| 195 | + }) |
| 196 | +}) |
0 commit comments