Skip to content

Commit 428475d

Browse files
authored
Fix TTL=0 race: start new window at 1 atomically in Lua (#218)
- Update the increment script to handle expiration and reset conditions more effectively. - Introduce parameters for window duration and reset behavior, enhancing script functionality. - Add Redis 2.6.12 minimum version requirement to README
1 parent 8439dec commit 428475d

File tree

3 files changed

+128
-10
lines changed

3 files changed

+128
-10
lines changed

readme.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,7 @@ Replace `{version}` with the version of the package that you want to use, e.g.:
4848
This library is provided in ESM as well as CJS forms, and works with both
4949
Javascript and Typescript projects.
5050

51-
**This package requires you to use Node 16 or above.**
51+
**This package requires you to use Node 16 or above and Redis 2.6.12 or above.**
5252

5353
Import it in a CommonJS project (`type: commonjs` or no `type` field in
5454
`package.json`) as follows:
@@ -140,7 +140,7 @@ below:
140140
| Library | Function |
141141
| ------------------------------------------------------------------ | ----------------------------------------------------------------------------- |
142142
| [`node-redis`](https://github.com/redis/node-redis) | `async (...args: string[]) => client.sendCommand(args)` |
143-
| [`node-redis`](https://github.com/redis/node-redis) (cluster) | `async (...args: string[]) => cluster.sendCommand(args[1], false, args)` |
143+
| [`node-redis`](https://github.com/redis/node-redis) (cluster) | `async (...args: string[]) => cluster.sendCommand(args[1], false, args)` |
144144
| [`ioredis`](https://github.com/luin/ioredis) | `async (command: string, ...args: string[]) => client.call(command, ...args)` |
145145
| [`handy-redis`](https://github.com/mmkal/handy-redis) | `async (...args: string[]) => client.nodeRedis.sendCommand(args)` |
146146
| [`tedis`](https://github.com/silkjs/tedis) | `async (...args: string[]) => client.command(...args)` |

source/scripts.ts

Lines changed: 17 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -7,15 +7,24 @@
77
*/
88
const scripts = {
99
increment: `
10-
local totalHits = redis.call("INCR", KEYS[1])
11-
local timeToExpire = redis.call("PTTL", KEYS[1])
12-
if timeToExpire < 0 or ARGV[1] == "1"
13-
then
14-
redis.call("PEXPIRE", KEYS[1], tonumber(ARGV[2]))
15-
timeToExpire = tonumber(ARGV[2])
16-
end
10+
local windowMs = tonumber(ARGV[2])
11+
local resetOnChange = ARGV[1] == "1"
1712
18-
return { totalHits, timeToExpire }
13+
local timeToExpire = redis.call("PTTL", KEYS[1])
14+
15+
if timeToExpire <= 0 then
16+
redis.call("SET", KEYS[1], 1, "PX", windowMs)
17+
return { 1, windowMs }
18+
end
19+
20+
local totalHits = redis.call("INCR", KEYS[1])
21+
22+
if resetOnChange then
23+
redis.call("PEXPIRE", KEYS[1], windowMs)
24+
timeToExpire = windowMs
25+
end
26+
27+
return { totalHits, timeToExpire }
1928
`
2029
// Ensure that code changes that affect whitespace do not affect
2130
// the script contents.

test/store-test.ts

Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -200,6 +200,28 @@ describe('redis store test', () => {
200200
expect(await client.get('rl:test-store-two')).toEqual(null)
201201
})
202202

203+
it('starts new window with count 1 when TTL expired (race fix)', async () => {
204+
const store = new RedisStore({ sendCommand })
205+
const windowMs = 50
206+
store.init({ windowMs } as Options)
207+
208+
const key = 'test-expired-window'
209+
210+
// First hit in first window
211+
const first = await store.increment(key)
212+
expect(first.totalHits).toEqual(1)
213+
expect(Number(await client.pttl(`rl:${key}`))).toEqual(windowMs)
214+
215+
// Advance beyond expiry so Redis reports expired (-2)
216+
jest.advanceTimersByTime(windowMs + 1)
217+
218+
// Next increment should start a fresh window with count=1 and TTL reset
219+
const afterExpiry = await store.increment(key)
220+
expect(afterExpiry.totalHits).toEqual(1)
221+
expect(Number(await client.get(`rl:${key}`))).toEqual(1)
222+
expect(Number(await client.pttl(`rl:${key}`))).toEqual(windowMs)
223+
})
224+
203225
it.skip('do not reset the expiration when the ttl is very close to 0', async () => {
204226
const store = new RedisStore({ sendCommand })
205227
const windowMs = 60
@@ -236,4 +258,91 @@ describe('redis store test', () => {
236258
expect(Number(await client.get('rl:test-store'))).toEqual(1)
237259
expect(Number(await client.pttl('rl:test-store'))).toEqual(10)
238260
})
261+
262+
it('unit: when PTTL==0 but key exists, script starts new window with 1', async () => {
263+
// In-memory fake Redis state for a single key
264+
const state: { value: number; ttl: number } = { value: 0, ttl: -2 }
265+
let loadedIncrementScript = ''
266+
267+
// Stub that simulates Redis primitives and applies logic depending on script order
268+
const sendCommandStub = async (...args: string[]): Promise<RedisReply> => {
269+
const [command, ...rest] = args
270+
if (command === 'SCRIPT' && rest[0] === 'LOAD') {
271+
const scriptBody = rest[1]
272+
if (scriptBody.includes('INCR')) loadedIncrementScript = scriptBody
273+
// Return a fake sha
274+
return 'sha-' + (scriptBody.includes('INCR') ? 'incr' : 'get')
275+
}
276+
277+
if (command === 'EVALSHA') {
278+
const sha = rest[0]
279+
const numberKeys = rest[1]
280+
const key = rest[2]
281+
const resetOnChange = rest[3] === '1'
282+
const windowMs = Number(rest[4])
283+
if (sha.endsWith('incr') && numberKeys === '1' && key) {
284+
// Determine algorithm: does the script read PTTL before INCR?
285+
const pttlIndex = loadedIncrementScript.indexOf('PTTL')
286+
const incrIndex = loadedIncrementScript.indexOf('INCR')
287+
288+
let totalHits = 0
289+
let { ttl } = state
290+
291+
if (pttlIndex > -1 && incrIndex > -1 && pttlIndex < incrIndex) {
292+
// NEW script: check ttl first
293+
if (ttl <= 0) {
294+
state.value = 1
295+
state.ttl = windowMs
296+
totalHits = 1
297+
ttl = windowMs
298+
} else {
299+
state.value += 1
300+
totalHits = state.value
301+
if (resetOnChange) state.ttl = windowMs
302+
ttl = state.ttl
303+
}
304+
} else {
305+
// OLD script: INCR first, then PTTL; only resets when ttl<0
306+
state.value += 1
307+
totalHits = state.value
308+
// Read pttl after incr; here ttl is 0 (exists but expired)
309+
if (ttl < 0 || resetOnChange) {
310+
state.ttl = windowMs
311+
ttl = windowMs
312+
} else {
313+
// Ttl == 0 branch
314+
ttl = 0
315+
}
316+
}
317+
318+
return [totalHits as unknown as number, ttl as unknown as number]
319+
}
320+
}
321+
322+
// Fallback for get script
323+
if (command === 'EVALSHA' && args[0].endsWith('get')) {
324+
return [
325+
state.value as unknown as number,
326+
state.ttl as unknown as number,
327+
]
328+
}
329+
330+
return -99
331+
}
332+
333+
const store = new RedisStore({ sendCommand: sendCommandStub })
334+
store.init({ windowMs: 60 } as Options)
335+
336+
const key = 'pttl-zero-exists'
337+
338+
// First increment to create key with value=1 and ttl=60
339+
await store.increment(key)
340+
state.value = 1
341+
state.ttl = 0 // Simulate edge: key still exists but PTTL==0 exactly
342+
343+
const result = await store.increment(key)
344+
345+
// With NEW script we expect a fresh window: hits=1 and ttl reset
346+
expect(result.totalHits).toEqual(1)
347+
})
239348
})

0 commit comments

Comments
 (0)