diff --git a/src/lua/loader.ts b/src/lua/loader.ts index e2931d9..4ae612b 100644 --- a/src/lua/loader.ts +++ b/src/lua/loader.ts @@ -80,6 +80,20 @@ export async function evalScript( argv: Array, numKeys: number, ): Promise { - const sha = await loadScript(client, name); - return (client as any).evalsha(sha, numKeys, ...argv); + const runEvalSha = async () => { + const sha = await loadScript(client, name); + return (client as any).evalsha(sha, numKeys, ...argv) as Promise; + }; + + try { + return await runEvalSha(); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + if (!message.includes('NOSCRIPT')) { + throw error; + } + + cacheByClient.get(client)?.delete(name); + return await runEvalSha(); + } } diff --git a/test/lua-loader.test.ts b/test/lua-loader.test.ts new file mode 100644 index 0000000..a4e1888 --- /dev/null +++ b/test/lua-loader.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, it } from 'vitest'; +import { evalScript } from '../src/lua/loader'; + +describe('Lua script loader', () => { + it('reloads and retries once when Redis returns NOSCRIPT', async () => { + let loadCount = 0; + const evalshaCalls: string[] = []; + const redis = { + script: async (command: string) => { + expect(command).toBe('load'); + loadCount += 1; + return `sha-${loadCount}`; + }, + evalsha: async (sha: string) => { + evalshaCalls.push(sha); + if (evalshaCalls.length === 1) { + throw new Error('NOSCRIPT No matching script. Please use EVAL.'); + } + return 'ok'; + }, + }; + + await expect( + evalScript(redis as any, 'enqueue', ['test-namespace'], 1), + ).resolves.toBe('ok'); + + expect(loadCount).toBe(2); + expect(evalshaCalls).toEqual(['sha-1', 'sha-2']); + }); + + it('does not retry non-NOSCRIPT Redis errors', async () => { + let loadCount = 0; + const redis = { + script: async () => { + loadCount += 1; + return `sha-${loadCount}`; + }, + evalsha: async () => { + throw new Error('READONLY You cannot write against a read only replica.'); + }, + }; + + await expect( + evalScript(redis as any, 'enqueue', ['test-namespace'], 1), + ).rejects.toThrow('READONLY'); + + expect(loadCount).toBe(1); + }); +}); diff --git a/test/queue.redis-disconnect.test.ts b/test/queue.redis-disconnect.test.ts index 3e66b9e..ed11146 100644 --- a/test/queue.redis-disconnect.test.ts +++ b/test/queue.redis-disconnect.test.ts @@ -114,6 +114,25 @@ describe('Redis Disconnect/Reconnect Tests', () => { await redis.quit(); }); + it('should reload cached Lua scripts after Redis SCRIPT FLUSH', async () => { + const redis = new Redis(REDIS_URL); + const q = new Queue({ redis, namespace: `${namespace}:script-flush` }); + + await q.add({ groupId: 'script-flush-group', data: { phase: 'before' } }); + + // Redis does not persist its Lua script cache across restarts/failovers. + // SCRIPT FLUSH reproduces the NOSCRIPT condition without restarting Redis. + await (redis as any).script('flush'); + + await expect( + q.add({ groupId: 'script-flush-group', data: { phase: 'after' } }), + ).resolves.toBeDefined(); + + expect(await q.getWaitingCount()).toBe(2); + + await redis.quit(); + }); + it('should handle network partitions and blocking operations', async () => { const redis = new Redis(REDIS_URL, { connectTimeout: 1000,