resplite 1.4.20 → 1.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/commands/hexpire.js +54 -0
- package/src/commands/hpersist.js +36 -0
- package/src/commands/httl.js +36 -0
- package/src/commands/registry.js +6 -0
- package/src/engine/engine.js +40 -3
- package/src/engine/expiration.js +28 -0
- package/src/storage/sqlite/hashes.js +154 -11
- package/src/storage/sqlite/schema.js +11 -0
- package/test/integration/hashes.test.js +82 -0
- package/test/unit/engine-hashes.test.js +162 -0
- package/test/unit/expiration.test.js +43 -0
package/package.json
CHANGED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* HEXPIRE key seconds [NX | XX | GT | LT] FIELDS numfields field [field ...]
|
|
3
|
+
* Redis 7.4 per-field TTL. Returns array of integers (-2/0/1/2) per field.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
const CONDITIONS = new Set(['NX', 'XX', 'GT', 'LT']);
|
|
7
|
+
|
|
8
|
+
function parseFieldsTail(args, startIdx) {
|
|
9
|
+
if (args.length <= startIdx) return { error: 'ERR syntax error' };
|
|
10
|
+
const token = (Buffer.isBuffer(args[startIdx]) ? args[startIdx].toString('utf8') : String(args[startIdx])).toUpperCase();
|
|
11
|
+
if (token !== 'FIELDS') return { error: 'ERR syntax error' };
|
|
12
|
+
const numStr = args[startIdx + 1];
|
|
13
|
+
if (numStr == null) return { error: 'ERR syntax error' };
|
|
14
|
+
const n = parseInt(Buffer.isBuffer(numStr) ? numStr.toString('utf8') : String(numStr), 10);
|
|
15
|
+
if (!Number.isInteger(n) || n < 1) {
|
|
16
|
+
return { error: 'ERR numfields should be greater than 0' };
|
|
17
|
+
}
|
|
18
|
+
const fields = args.slice(startIdx + 2);
|
|
19
|
+
if (fields.length !== n) {
|
|
20
|
+
return { error: "ERR Parameter `numFields` should be equal to the number of arguments" };
|
|
21
|
+
}
|
|
22
|
+
return { fields };
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function handleHexpire(engine, args) {
|
|
26
|
+
if (!args || args.length < 5) {
|
|
27
|
+
return { error: "ERR wrong number of arguments for 'HEXPIRE' command" };
|
|
28
|
+
}
|
|
29
|
+
const key = args[0];
|
|
30
|
+
const secondsStr = Buffer.isBuffer(args[1]) ? args[1].toString('utf8') : String(args[1]);
|
|
31
|
+
const seconds = parseInt(secondsStr, 10);
|
|
32
|
+
if (!Number.isInteger(seconds)) {
|
|
33
|
+
return { error: 'ERR value is not an integer or out of range' };
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
let idx = 2;
|
|
37
|
+
let condition = null;
|
|
38
|
+
const maybeCond = (Buffer.isBuffer(args[idx]) ? args[idx].toString('utf8') : String(args[idx])).toUpperCase();
|
|
39
|
+
if (CONDITIONS.has(maybeCond)) {
|
|
40
|
+
condition = maybeCond;
|
|
41
|
+
idx += 1;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const parsed = parseFieldsTail(args, idx);
|
|
45
|
+
if (parsed.error) return parsed;
|
|
46
|
+
|
|
47
|
+
const expiresAtMs = engine._clock() + seconds * 1000;
|
|
48
|
+
try {
|
|
49
|
+
return engine.hexpire(key, expiresAtMs, parsed.fields, { condition });
|
|
50
|
+
} catch (e) {
|
|
51
|
+
const msg = e && e.message ? e.message : String(e);
|
|
52
|
+
return { error: msg.startsWith('ERR ') || msg.startsWith('WRONGTYPE') ? msg : 'ERR ' + msg };
|
|
53
|
+
}
|
|
54
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* HPERSIST key FIELDS numfields field [field ...]
|
|
3
|
+
* Removes per-field TTL. Returns array: -2 (missing), -1 (no TTL), 1 (cleared).
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
function parseFieldsTail(args, startIdx) {
|
|
7
|
+
if (args.length <= startIdx) return { error: 'ERR syntax error' };
|
|
8
|
+
const token = (Buffer.isBuffer(args[startIdx]) ? args[startIdx].toString('utf8') : String(args[startIdx])).toUpperCase();
|
|
9
|
+
if (token !== 'FIELDS') return { error: 'ERR syntax error' };
|
|
10
|
+
const numStr = args[startIdx + 1];
|
|
11
|
+
if (numStr == null) return { error: 'ERR syntax error' };
|
|
12
|
+
const n = parseInt(Buffer.isBuffer(numStr) ? numStr.toString('utf8') : String(numStr), 10);
|
|
13
|
+
if (!Number.isInteger(n) || n < 1) {
|
|
14
|
+
return { error: 'ERR numfields should be greater than 0' };
|
|
15
|
+
}
|
|
16
|
+
const fields = args.slice(startIdx + 2);
|
|
17
|
+
if (fields.length !== n) {
|
|
18
|
+
return { error: "ERR Parameter `numFields` should be equal to the number of arguments" };
|
|
19
|
+
}
|
|
20
|
+
return { fields };
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function handleHpersist(engine, args) {
|
|
24
|
+
if (!args || args.length < 4) {
|
|
25
|
+
return { error: "ERR wrong number of arguments for 'HPERSIST' command" };
|
|
26
|
+
}
|
|
27
|
+
const key = args[0];
|
|
28
|
+
const parsed = parseFieldsTail(args, 1);
|
|
29
|
+
if (parsed.error) return parsed;
|
|
30
|
+
try {
|
|
31
|
+
return engine.hpersist(key, parsed.fields);
|
|
32
|
+
} catch (e) {
|
|
33
|
+
const msg = e && e.message ? e.message : String(e);
|
|
34
|
+
return { error: msg.startsWith('ERR ') || msg.startsWith('WRONGTYPE') ? msg : 'ERR ' + msg };
|
|
35
|
+
}
|
|
36
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* HTTL key FIELDS numfields field [field ...]
|
|
3
|
+
* Returns array of seconds per field: -2 (missing), -1 (no TTL), else remaining seconds.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
function parseFieldsTail(args, startIdx) {
|
|
7
|
+
if (args.length <= startIdx) return { error: 'ERR syntax error' };
|
|
8
|
+
const token = (Buffer.isBuffer(args[startIdx]) ? args[startIdx].toString('utf8') : String(args[startIdx])).toUpperCase();
|
|
9
|
+
if (token !== 'FIELDS') return { error: 'ERR syntax error' };
|
|
10
|
+
const numStr = args[startIdx + 1];
|
|
11
|
+
if (numStr == null) return { error: 'ERR syntax error' };
|
|
12
|
+
const n = parseInt(Buffer.isBuffer(numStr) ? numStr.toString('utf8') : String(numStr), 10);
|
|
13
|
+
if (!Number.isInteger(n) || n < 1) {
|
|
14
|
+
return { error: 'ERR numfields should be greater than 0' };
|
|
15
|
+
}
|
|
16
|
+
const fields = args.slice(startIdx + 2);
|
|
17
|
+
if (fields.length !== n) {
|
|
18
|
+
return { error: "ERR Parameter `numFields` should be equal to the number of arguments" };
|
|
19
|
+
}
|
|
20
|
+
return { fields };
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function handleHttl(engine, args) {
|
|
24
|
+
if (!args || args.length < 4) {
|
|
25
|
+
return { error: "ERR wrong number of arguments for 'HTTL' command" };
|
|
26
|
+
}
|
|
27
|
+
const key = args[0];
|
|
28
|
+
const parsed = parseFieldsTail(args, 1);
|
|
29
|
+
if (parsed.error) return parsed;
|
|
30
|
+
try {
|
|
31
|
+
return engine.httl(key, parsed.fields);
|
|
32
|
+
} catch (e) {
|
|
33
|
+
const msg = e && e.message ? e.message : String(e);
|
|
34
|
+
return { error: msg.startsWith('ERR ') || msg.startsWith('WRONGTYPE') ? msg : 'ERR ' + msg };
|
|
35
|
+
}
|
|
36
|
+
}
|
package/src/commands/registry.js
CHANGED
|
@@ -36,6 +36,9 @@ import * as hdel from './hdel.js';
|
|
|
36
36
|
import * as hlen from './hlen.js';
|
|
37
37
|
import * as hexists from './hexists.js';
|
|
38
38
|
import * as hincrby from './hincrby.js';
|
|
39
|
+
import * as hexpireCmd from './hexpire.js';
|
|
40
|
+
import * as httlCmd from './httl.js';
|
|
41
|
+
import * as hpersistCmd from './hpersist.js';
|
|
39
42
|
import * as sadd from './sadd.js';
|
|
40
43
|
import * as srem from './srem.js';
|
|
41
44
|
import * as smembers from './smembers.js';
|
|
@@ -122,6 +125,9 @@ const HANDLERS = new Map([
|
|
|
122
125
|
['HLEN', (e, a) => hlen.handleHlen(e, a)],
|
|
123
126
|
['HEXISTS', (e, a) => hexists.handleHexists(e, a)],
|
|
124
127
|
['HINCRBY', (e, a) => hincrby.handleHincrby(e, a)],
|
|
128
|
+
['HEXPIRE', (e, a) => hexpireCmd.handleHexpire(e, a)],
|
|
129
|
+
['HTTL', (e, a) => httlCmd.handleHttl(e, a)],
|
|
130
|
+
['HPERSIST', (e, a) => hpersistCmd.handleHpersist(e, a)],
|
|
125
131
|
['SADD', (e, a) => sadd.handleSadd(e, a)],
|
|
126
132
|
['SREM', (e, a) => srem.handleSrem(e, a)],
|
|
127
133
|
['SMEMBERS', (e, a) => smembers.handleSmembers(e, a)],
|
package/src/engine/engine.js
CHANGED
|
@@ -16,15 +16,14 @@ import { asKey, asValue } from '../util/buffers.js';
|
|
|
16
16
|
|
|
17
17
|
export function createEngine(opts = {}) {
|
|
18
18
|
const { db, cache } = opts;
|
|
19
|
+
const clock = opts.clock ?? (() => Date.now());
|
|
19
20
|
const keys = createKeysStorage(db);
|
|
20
21
|
const strings = createStringsStorage(db, keys);
|
|
21
|
-
const hashes = createHashesStorage(db, keys);
|
|
22
|
+
const hashes = createHashesStorage(db, keys, { clock });
|
|
22
23
|
const sets = createSetsStorage(db, keys);
|
|
23
24
|
const lists = createListsStorage(db, keys);
|
|
24
25
|
const zsets = createZsetsStorage(db, keys);
|
|
25
26
|
|
|
26
|
-
const clock = opts.clock ?? (() => Date.now());
|
|
27
|
-
|
|
28
27
|
function _incrBy(key, delta) {
|
|
29
28
|
const k = asKey(key);
|
|
30
29
|
const meta = getKeyMeta(key);
|
|
@@ -254,6 +253,44 @@ export function createEngine(opts = {}) {
|
|
|
254
253
|
return hashes.incr(k, asKey(field), amt);
|
|
255
254
|
},
|
|
256
255
|
|
|
256
|
+
/**
|
|
257
|
+
* HEXPIRE: apply absolute expiresAtMs to each hash field with optional NX/XX/GT/LT.
|
|
258
|
+
* Returns an array of integers per spec: -2 (missing), 0 (cond), 1 (set), 2 (deleted).
|
|
259
|
+
*/
|
|
260
|
+
hexpire(key, expiresAtMs, fields, { condition = null } = {}) {
|
|
261
|
+
const k = asKey(key);
|
|
262
|
+
const meta = getKeyMeta(key);
|
|
263
|
+
if (!meta) return fields.map(() => -2);
|
|
264
|
+
expectHash(meta);
|
|
265
|
+
return fields.map((f) => hashes.setFieldExpire(k, asKey(f), expiresAtMs, { condition }));
|
|
266
|
+
},
|
|
267
|
+
|
|
268
|
+
/**
|
|
269
|
+
* HTTL: seconds remaining per field. -2 missing, -1 no TTL, else seconds.
|
|
270
|
+
*/
|
|
271
|
+
httl(key, fields) {
|
|
272
|
+
const k = asKey(key);
|
|
273
|
+
const meta = getKeyMeta(key);
|
|
274
|
+
if (!meta) return fields.map(() => -2);
|
|
275
|
+
expectHash(meta);
|
|
276
|
+
return fields.map((f) => {
|
|
277
|
+
const ms = hashes.getFieldTtl(k, asKey(f));
|
|
278
|
+
if (ms < 0) return ms;
|
|
279
|
+
return Math.floor(ms / 1000);
|
|
280
|
+
});
|
|
281
|
+
},
|
|
282
|
+
|
|
283
|
+
/**
|
|
284
|
+
* HPERSIST: clear field TTL. -2 missing, -1 no TTL, 1 cleared.
|
|
285
|
+
*/
|
|
286
|
+
hpersist(key, fields) {
|
|
287
|
+
const k = asKey(key);
|
|
288
|
+
const meta = getKeyMeta(key);
|
|
289
|
+
if (!meta) return fields.map(() => -2);
|
|
290
|
+
expectHash(meta);
|
|
291
|
+
return fields.map((f) => hashes.persistField(k, asKey(f)));
|
|
292
|
+
},
|
|
293
|
+
|
|
257
294
|
sadd(key, ...members) {
|
|
258
295
|
const k = asKey(key);
|
|
259
296
|
getKeyMeta(key);
|
package/src/engine/expiration.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Active expiration: background sweeper that deletes expired keys in batches.
|
|
3
|
+
* Also prunes expired hash fields (redis_hash_field_ttl) and drops now-empty hashes.
|
|
3
4
|
*/
|
|
4
5
|
|
|
5
6
|
/**
|
|
@@ -18,11 +19,38 @@ export function createExpirationSweeper(opts) {
|
|
|
18
19
|
const deleteExpiredStmt = db.prepare(
|
|
19
20
|
'DELETE FROM redis_keys WHERE key IN (SELECT key FROM redis_keys WHERE expires_at IS NOT NULL AND expires_at <= ? LIMIT ?)'
|
|
20
21
|
);
|
|
22
|
+
const selectExpiredFieldsStmt = db.prepare(
|
|
23
|
+
'SELECT key, field FROM redis_hash_field_ttl WHERE expires_at <= ? LIMIT ?'
|
|
24
|
+
);
|
|
25
|
+
const deleteHashFieldStmt = db.prepare('DELETE FROM redis_hashes WHERE key = ? AND field = ?');
|
|
26
|
+
const deleteFieldTtlStmt = db.prepare('DELETE FROM redis_hash_field_ttl WHERE key = ? AND field = ?');
|
|
27
|
+
const countHashStmt = db.prepare('SELECT COUNT(*) AS n FROM redis_hashes WHERE key = ?');
|
|
28
|
+
const updateHashCountStmt = db.prepare('UPDATE redis_keys SET hash_count = ? WHERE key = ?');
|
|
29
|
+
const deleteKeyStmt = db.prepare('DELETE FROM redis_keys WHERE key = ?');
|
|
30
|
+
|
|
31
|
+
const sweepFieldsTxn = db.transaction((pairs) => {
|
|
32
|
+
const affected = new Map();
|
|
33
|
+
for (const pair of pairs) {
|
|
34
|
+
deleteHashFieldStmt.run(pair.key, pair.field);
|
|
35
|
+
deleteFieldTtlStmt.run(pair.key, pair.field);
|
|
36
|
+
const seenKey = pair.key.toString('base64');
|
|
37
|
+
if (!affected.has(seenKey)) affected.set(seenKey, pair.key);
|
|
38
|
+
}
|
|
39
|
+
for (const key of affected.values()) {
|
|
40
|
+
const row = countHashStmt.get(key);
|
|
41
|
+
const n = row ? row.n : 0;
|
|
42
|
+
if (n === 0) deleteKeyStmt.run(key);
|
|
43
|
+
else updateHashCountStmt.run(n, key);
|
|
44
|
+
}
|
|
45
|
+
});
|
|
46
|
+
|
|
21
47
|
let intervalId = null;
|
|
22
48
|
|
|
23
49
|
function sweep() {
|
|
24
50
|
const now = clock();
|
|
25
51
|
deleteExpiredStmt.run(now, maxKeysPerSweep);
|
|
52
|
+
const pairs = selectExpiredFieldsStmt.all(now, maxKeysPerSweep);
|
|
53
|
+
if (pairs.length > 0) sweepFieldsTxn(pairs);
|
|
26
54
|
}
|
|
27
55
|
|
|
28
56
|
return {
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Hash storage: redis_hashes + coordination with redis_keys.
|
|
3
3
|
* Empty hash removes the key (Section 8.6).
|
|
4
|
+
* Per-field TTL is tracked in redis_hash_field_ttl (epoch milliseconds);
|
|
5
|
+
* HSET/HINCRBY clear a field's TTL, lazy-expiration prunes stale fields.
|
|
4
6
|
*/
|
|
5
7
|
|
|
6
8
|
import { KEY_TYPES } from './schema.js';
|
|
@@ -9,28 +11,84 @@ import { runInTransaction } from './tx.js';
|
|
|
9
11
|
/**
|
|
10
12
|
* @param {import('better-sqlite3').Database} db
|
|
11
13
|
* @param {ReturnType<import('./keys.js').createKeysStorage>} keys
|
|
14
|
+
* @param {{ clock?: () => number }} [options]
|
|
12
15
|
*/
|
|
13
|
-
export function createHashesStorage(db, keys) {
|
|
16
|
+
export function createHashesStorage(db, keys, options = {}) {
|
|
17
|
+
const clock = options.clock ?? (() => Date.now());
|
|
18
|
+
|
|
14
19
|
const getStmt = db.prepare('SELECT value FROM redis_hashes WHERE key = ? AND field = ?');
|
|
15
20
|
const getAllStmt = db.prepare('SELECT field, value FROM redis_hashes WHERE key = ?').raw(true);
|
|
21
|
+
const getAllLiveStmt = db
|
|
22
|
+
.prepare(
|
|
23
|
+
`SELECT h.field, h.value
|
|
24
|
+
FROM redis_hashes h
|
|
25
|
+
LEFT JOIN redis_hash_field_ttl t ON t.key = h.key AND t.field = h.field
|
|
26
|
+
WHERE h.key = ? AND (t.expires_at IS NULL OR t.expires_at > ?)`
|
|
27
|
+
)
|
|
28
|
+
.raw(true);
|
|
16
29
|
const insertStmt = db.prepare('INSERT OR REPLACE INTO redis_hashes (key, field, value) VALUES (?, ?, ?)');
|
|
17
30
|
const deleteStmt = db.prepare('DELETE FROM redis_hashes WHERE key = ? AND field = ?');
|
|
18
31
|
const deleteAllStmt = db.prepare('DELETE FROM redis_hashes WHERE key = ?');
|
|
19
32
|
const countStmt = db.prepare('SELECT COUNT(*) AS n FROM redis_hashes WHERE key = ?');
|
|
33
|
+
const countLiveStmt = db.prepare(
|
|
34
|
+
`SELECT COUNT(*) AS n
|
|
35
|
+
FROM redis_hashes h
|
|
36
|
+
LEFT JOIN redis_hash_field_ttl t ON t.key = h.key AND t.field = h.field
|
|
37
|
+
WHERE h.key = ? AND (t.expires_at IS NULL OR t.expires_at > ?)`
|
|
38
|
+
);
|
|
39
|
+
const hasAnyTtlStmt = db.prepare('SELECT 1 FROM redis_hash_field_ttl WHERE key = ? LIMIT 1').pluck();
|
|
40
|
+
|
|
41
|
+
const getFieldTtlStmt = db.prepare(
|
|
42
|
+
'SELECT expires_at AS expiresAt FROM redis_hash_field_ttl WHERE key = ? AND field = ?'
|
|
43
|
+
);
|
|
44
|
+
const upsertFieldTtlStmt = db.prepare(
|
|
45
|
+
'INSERT OR REPLACE INTO redis_hash_field_ttl (key, field, expires_at) VALUES (?, ?, ?)'
|
|
46
|
+
);
|
|
47
|
+
const deleteFieldTtlStmt = db.prepare('DELETE FROM redis_hash_field_ttl WHERE key = ? AND field = ?');
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* If the field has an expired TTL row, delete the field + TTL row, adjust count
|
|
51
|
+
* (and drop the whole key when empty). Returns true if the field was purged.
|
|
52
|
+
*/
|
|
53
|
+
function expireFieldIfDue(key, field, now) {
|
|
54
|
+
const ttl = getFieldTtlStmt.get(key, field);
|
|
55
|
+
if (!ttl || ttl.expiresAt > now) return false;
|
|
56
|
+
const existed = getStmt.get(key, field) != null;
|
|
57
|
+
deleteFieldTtlStmt.run(key, field);
|
|
58
|
+
if (!existed) return true;
|
|
59
|
+
deleteStmt.run(key, field);
|
|
60
|
+
const meta = keys.get(key);
|
|
61
|
+
if (!meta) return true;
|
|
62
|
+
const before = meta.hashCount != null ? meta.hashCount : (countStmt.get(key) || { n: 0 }).n + 1;
|
|
63
|
+
const remaining = Math.max(0, before - 1);
|
|
64
|
+
if (remaining === 0) {
|
|
65
|
+
deleteAllStmt.run(key);
|
|
66
|
+
keys.delete(key);
|
|
67
|
+
} else {
|
|
68
|
+
keys.setHashCount(key, remaining, { touchUpdatedAt: false });
|
|
69
|
+
}
|
|
70
|
+
return true;
|
|
71
|
+
}
|
|
20
72
|
|
|
21
73
|
return {
|
|
22
74
|
get(key, field) {
|
|
75
|
+
runInTransaction(db, () => {
|
|
76
|
+
expireFieldIfDue(key, field, clock());
|
|
77
|
+
});
|
|
23
78
|
const row = getStmt.get(key, field);
|
|
24
79
|
return row ? row.value : null;
|
|
25
80
|
},
|
|
26
81
|
|
|
27
82
|
getAll(key) {
|
|
28
|
-
|
|
83
|
+
const now = clock();
|
|
84
|
+
const hasTtl = hasAnyTtlStmt.get(key);
|
|
85
|
+
if (!hasTtl) return getAllStmt.all(key).flat();
|
|
86
|
+
return getAllLiveStmt.all(key, now).flat();
|
|
29
87
|
},
|
|
30
88
|
|
|
31
89
|
set(key, field, value, options = {}) {
|
|
32
90
|
runInTransaction(db, () => {
|
|
33
|
-
const now = options.updatedAt ??
|
|
91
|
+
const now = options.updatedAt ?? clock();
|
|
34
92
|
const meta = keys.get(key);
|
|
35
93
|
let knownCount = 0;
|
|
36
94
|
if (meta) {
|
|
@@ -50,6 +108,7 @@ export function createHashesStorage(db, keys) {
|
|
|
50
108
|
}
|
|
51
109
|
const existed = getStmt.get(key, field) != null;
|
|
52
110
|
insertStmt.run(key, field, value);
|
|
111
|
+
deleteFieldTtlStmt.run(key, field);
|
|
53
112
|
if (!existed) {
|
|
54
113
|
if (meta) keys.incrHashCount(key, 1, { touchUpdatedAt: false });
|
|
55
114
|
else keys.setHashCount(key, 1, { touchUpdatedAt: false });
|
|
@@ -62,7 +121,7 @@ export function createHashesStorage(db, keys) {
|
|
|
62
121
|
|
|
63
122
|
setMultiple(key, pairs, options = {}) {
|
|
64
123
|
runInTransaction(db, () => {
|
|
65
|
-
const now = options.updatedAt ??
|
|
124
|
+
const now = options.updatedAt ?? clock();
|
|
66
125
|
const meta = keys.get(key);
|
|
67
126
|
let knownCount = 0;
|
|
68
127
|
if (meta) {
|
|
@@ -84,6 +143,7 @@ export function createHashesStorage(db, keys) {
|
|
|
84
143
|
for (let i = 0; i < pairs.length; i += 2) {
|
|
85
144
|
const existed = getStmt.get(key, pairs[i]) != null;
|
|
86
145
|
insertStmt.run(key, pairs[i], pairs[i + 1]);
|
|
146
|
+
deleteFieldTtlStmt.run(key, pairs[i]);
|
|
87
147
|
if (!existed) added++;
|
|
88
148
|
}
|
|
89
149
|
if (added > 0) {
|
|
@@ -103,6 +163,7 @@ export function createHashesStorage(db, keys) {
|
|
|
103
163
|
let n = 0;
|
|
104
164
|
for (const field of fields) {
|
|
105
165
|
const r = deleteStmt.run(key, field);
|
|
166
|
+
deleteFieldTtlStmt.run(key, field);
|
|
106
167
|
n += r.changes;
|
|
107
168
|
}
|
|
108
169
|
const remaining = before != null ? Math.max(0, before - n) : ((countStmt.get(key) || {}).n ?? 0);
|
|
@@ -118,21 +179,26 @@ export function createHashesStorage(db, keys) {
|
|
|
118
179
|
|
|
119
180
|
count(key) {
|
|
120
181
|
const meta = keys.get(key);
|
|
121
|
-
if (meta
|
|
122
|
-
|
|
182
|
+
if (!meta || meta.type !== KEY_TYPES.HASH) {
|
|
183
|
+
const row = countStmt.get(key);
|
|
184
|
+
return row ? row.n : 0;
|
|
185
|
+
}
|
|
186
|
+
const hasTtl = hasAnyTtlStmt.get(key);
|
|
187
|
+
if (hasTtl) {
|
|
188
|
+
const row = countLiveStmt.get(key, clock());
|
|
189
|
+
return row ? row.n : 0;
|
|
123
190
|
}
|
|
191
|
+
if (meta.hashCount != null) return meta.hashCount;
|
|
124
192
|
const row = countStmt.get(key);
|
|
125
193
|
const n = row ? row.n : 0;
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
keys.setHashCount(key, n, { touchUpdatedAt: false });
|
|
129
|
-
}
|
|
194
|
+
// One-time hydration for databases created before hash_count existed.
|
|
195
|
+
keys.setHashCount(key, n, { touchUpdatedAt: false });
|
|
130
196
|
return n;
|
|
131
197
|
},
|
|
132
198
|
|
|
133
199
|
incr(key, field, delta, options = {}) {
|
|
134
200
|
return runInTransaction(db, () => {
|
|
135
|
-
const now = options.updatedAt ??
|
|
201
|
+
const now = options.updatedAt ?? clock();
|
|
136
202
|
const meta = keys.get(key);
|
|
137
203
|
if (meta && meta.type !== KEY_TYPES.HASH) {
|
|
138
204
|
throw new Error('WRONGTYPE Operation against a key holding the wrong kind of value');
|
|
@@ -147,11 +213,13 @@ export function createHashesStorage(db, keys) {
|
|
|
147
213
|
keys.setHashCount(key, hydrated, { touchUpdatedAt: false });
|
|
148
214
|
}
|
|
149
215
|
}
|
|
216
|
+
expireFieldIfDue(key, field, now);
|
|
150
217
|
const cur = getStmt.get(key, field);
|
|
151
218
|
const num = cur == null ? 0 : parseInt(cur.value.toString('utf8'), 10);
|
|
152
219
|
if (Number.isNaN(num)) throw new Error('ERR hash value is not an integer');
|
|
153
220
|
const next = num + delta;
|
|
154
221
|
insertStmt.run(key, field, Buffer.from(String(next), 'utf8'));
|
|
222
|
+
deleteFieldTtlStmt.run(key, field);
|
|
155
223
|
if (cur == null) {
|
|
156
224
|
if (meta) keys.incrHashCount(key, 1, { touchUpdatedAt: false });
|
|
157
225
|
else keys.setHashCount(key, 1, { touchUpdatedAt: false });
|
|
@@ -160,6 +228,81 @@ export function createHashesStorage(db, keys) {
|
|
|
160
228
|
});
|
|
161
229
|
},
|
|
162
230
|
|
|
231
|
+
/**
|
|
232
|
+
* Per-field expiration set. Returns -2/0/1/2 per HEXPIRE spec.
|
|
233
|
+
* `condition` is null or one of 'NX','XX','GT','LT'.
|
|
234
|
+
*/
|
|
235
|
+
setFieldExpire(key, field, expiresAtMs, { condition = null } = {}) {
|
|
236
|
+
return runInTransaction(db, () => {
|
|
237
|
+
const now = clock();
|
|
238
|
+
expireFieldIfDue(key, field, now);
|
|
239
|
+
const exists = getStmt.get(key, field) != null;
|
|
240
|
+
if (!exists) return -2;
|
|
241
|
+
const current = getFieldTtlStmt.get(key, field);
|
|
242
|
+
const currentMs = current ? current.expiresAt : null;
|
|
243
|
+
if (condition === 'NX' && currentMs != null) return 0;
|
|
244
|
+
if (condition === 'XX' && currentMs == null) return 0;
|
|
245
|
+
if (condition === 'GT') {
|
|
246
|
+
if (currentMs == null) return 0;
|
|
247
|
+
if (!(expiresAtMs > currentMs)) return 0;
|
|
248
|
+
}
|
|
249
|
+
if (condition === 'LT') {
|
|
250
|
+
if (currentMs != null && !(expiresAtMs < currentMs)) return 0;
|
|
251
|
+
}
|
|
252
|
+
if (expiresAtMs <= now) {
|
|
253
|
+
deleteStmt.run(key, field);
|
|
254
|
+
deleteFieldTtlStmt.run(key, field);
|
|
255
|
+
const meta = keys.get(key);
|
|
256
|
+
if (meta) {
|
|
257
|
+
const before = meta.hashCount != null ? meta.hashCount : (countStmt.get(key) || { n: 0 }).n + 1;
|
|
258
|
+
const remaining = Math.max(0, before - 1);
|
|
259
|
+
if (remaining === 0) {
|
|
260
|
+
deleteAllStmt.run(key);
|
|
261
|
+
keys.delete(key);
|
|
262
|
+
} else {
|
|
263
|
+
keys.setHashCount(key, remaining, { touchUpdatedAt: false });
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
return 2;
|
|
267
|
+
}
|
|
268
|
+
upsertFieldTtlStmt.run(key, field, expiresAtMs);
|
|
269
|
+
return 1;
|
|
270
|
+
});
|
|
271
|
+
},
|
|
272
|
+
|
|
273
|
+
/**
|
|
274
|
+
* Returns remaining ms (>= 0), -1 if field has no TTL, -2 if field missing.
|
|
275
|
+
*/
|
|
276
|
+
getFieldTtl(key, field) {
|
|
277
|
+
const now = clock();
|
|
278
|
+
let removed = false;
|
|
279
|
+
runInTransaction(db, () => {
|
|
280
|
+
removed = expireFieldIfDue(key, field, now);
|
|
281
|
+
});
|
|
282
|
+
if (removed) return -2;
|
|
283
|
+
const exists = getStmt.get(key, field) != null;
|
|
284
|
+
if (!exists) return -2;
|
|
285
|
+
const row = getFieldTtlStmt.get(key, field);
|
|
286
|
+
if (!row) return -1;
|
|
287
|
+
return Math.max(0, row.expiresAt - now);
|
|
288
|
+
},
|
|
289
|
+
|
|
290
|
+
/**
|
|
291
|
+
* Clears a field's TTL. Returns 1 if cleared, -1 if no TTL, -2 if field missing.
|
|
292
|
+
*/
|
|
293
|
+
persistField(key, field) {
|
|
294
|
+
return runInTransaction(db, () => {
|
|
295
|
+
const now = clock();
|
|
296
|
+
expireFieldIfDue(key, field, now);
|
|
297
|
+
const exists = getStmt.get(key, field) != null;
|
|
298
|
+
if (!exists) return -2;
|
|
299
|
+
const row = getFieldTtlStmt.get(key, field);
|
|
300
|
+
if (!row) return -1;
|
|
301
|
+
deleteFieldTtlStmt.run(key, field);
|
|
302
|
+
return 1;
|
|
303
|
+
});
|
|
304
|
+
},
|
|
305
|
+
|
|
163
306
|
/** Copy all field/value rows from oldKey to newKey. Caller ensures newKey exists in redis_keys. */
|
|
164
307
|
copyKey(oldKey, newKey) {
|
|
165
308
|
const rows = getAllStmt.all(oldKey);
|
|
@@ -66,6 +66,17 @@ CREATE TABLE IF NOT EXISTS redis_zsets (
|
|
|
66
66
|
CREATE INDEX IF NOT EXISTS redis_zsets_key_score_member_idx
|
|
67
67
|
ON redis_zsets(key, score, member);
|
|
68
68
|
|
|
69
|
+
CREATE TABLE IF NOT EXISTS redis_hash_field_ttl (
|
|
70
|
+
key BLOB NOT NULL,
|
|
71
|
+
field BLOB NOT NULL,
|
|
72
|
+
expires_at INTEGER NOT NULL,
|
|
73
|
+
PRIMARY KEY (key, field),
|
|
74
|
+
FOREIGN KEY(key) REFERENCES redis_keys(key) ON DELETE CASCADE
|
|
75
|
+
);
|
|
76
|
+
|
|
77
|
+
CREATE INDEX IF NOT EXISTS redis_hash_field_ttl_expires_at_idx
|
|
78
|
+
ON redis_hash_field_ttl(expires_at);
|
|
79
|
+
|
|
69
80
|
CREATE TABLE IF NOT EXISTS search_indices (
|
|
70
81
|
name TEXT PRIMARY KEY,
|
|
71
82
|
schema_json TEXT NOT NULL,
|
|
@@ -65,6 +65,88 @@ describe('Hashes integration', () => {
|
|
|
65
65
|
assert.equal(tryParseValue(reply, 0).value, 3);
|
|
66
66
|
});
|
|
67
67
|
|
|
68
|
+
it('HEXPIRE + HTTL + HPERSIST round-trip (node-redis style argv)', async () => {
|
|
69
|
+
await sendCommand(port, argv('HSET', 'LobbyStream', '6GQZW:FBAX7', '1'));
|
|
70
|
+
// Mirror the exact argv seen in the node-redis client log.
|
|
71
|
+
const hexpireReply = await sendCommand(
|
|
72
|
+
port,
|
|
73
|
+
argv('HEXPIRE', 'LobbyStream', '90', 'FIELDS', '1', '6GQZW:FBAX7')
|
|
74
|
+
);
|
|
75
|
+
const hexpireVal = tryParseValue(hexpireReply, 0).value;
|
|
76
|
+
assert.ok(Array.isArray(hexpireVal));
|
|
77
|
+
assert.equal(hexpireVal.length, 1);
|
|
78
|
+
assert.equal(Number(hexpireVal[0]), 1);
|
|
79
|
+
|
|
80
|
+
const httlReply = await sendCommand(
|
|
81
|
+
port,
|
|
82
|
+
argv('HTTL', 'LobbyStream', 'FIELDS', '1', '6GQZW:FBAX7')
|
|
83
|
+
);
|
|
84
|
+
const httlVal = tryParseValue(httlReply, 0).value;
|
|
85
|
+
assert.ok(Array.isArray(httlVal));
|
|
86
|
+
assert.equal(httlVal.length, 1);
|
|
87
|
+
const secs = Number(httlVal[0]);
|
|
88
|
+
assert.ok(secs > 0 && secs <= 90, `expected 0 < ttl <= 90, got ${secs}`);
|
|
89
|
+
|
|
90
|
+
const hpersistReply = await sendCommand(
|
|
91
|
+
port,
|
|
92
|
+
argv('HPERSIST', 'LobbyStream', 'FIELDS', '1', '6GQZW:FBAX7')
|
|
93
|
+
);
|
|
94
|
+
const hpersistVal = tryParseValue(hpersistReply, 0).value;
|
|
95
|
+
assert.equal(Number(hpersistVal[0]), 1);
|
|
96
|
+
|
|
97
|
+
const httlReply2 = await sendCommand(
|
|
98
|
+
port,
|
|
99
|
+
argv('HTTL', 'LobbyStream', 'FIELDS', '1', '6GQZW:FBAX7')
|
|
100
|
+
);
|
|
101
|
+
const ttl2 = Number(tryParseValue(httlReply2, 0).value[0]);
|
|
102
|
+
assert.equal(ttl2, -1);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it('HEXPIRE returns -2 for missing key/field', async () => {
|
|
106
|
+
const missingKey = await sendCommand(
|
|
107
|
+
port,
|
|
108
|
+
argv('HEXPIRE', 'hexp:nokey', '5', 'FIELDS', '1', 'f1')
|
|
109
|
+
);
|
|
110
|
+
assert.equal(Number(tryParseValue(missingKey, 0).value[0]), -2);
|
|
111
|
+
|
|
112
|
+
await sendCommand(port, argv('HSET', 'hexp:k', 'f1', 'v1'));
|
|
113
|
+
const missingField = await sendCommand(
|
|
114
|
+
port,
|
|
115
|
+
argv('HEXPIRE', 'hexp:k', '5', 'FIELDS', '1', 'nope')
|
|
116
|
+
);
|
|
117
|
+
assert.equal(Number(tryParseValue(missingField, 0).value[0]), -2);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it('HEXPIRE with 0 seconds deletes the field (returns 2)', async () => {
|
|
121
|
+
await sendCommand(port, argv('HSET', 'hexp:zero', 'f1', 'v1', 'f2', 'v2'));
|
|
122
|
+
const reply = await sendCommand(
|
|
123
|
+
port,
|
|
124
|
+
argv('HEXPIRE', 'hexp:zero', '0', 'FIELDS', '1', 'f1')
|
|
125
|
+
);
|
|
126
|
+
assert.equal(Number(tryParseValue(reply, 0).value[0]), 2);
|
|
127
|
+
const getReply = await sendCommand(port, argv('HGET', 'hexp:zero', 'f1'));
|
|
128
|
+
assert.ok(getReply.toString('utf8').startsWith('$-1'));
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
it('HEXPIRE NX condition fails on existing TTL', async () => {
|
|
132
|
+
await sendCommand(port, argv('HSET', 'hexp:nx', 'f1', 'v1'));
|
|
133
|
+
await sendCommand(port, argv('HEXPIRE', 'hexp:nx', '10', 'FIELDS', '1', 'f1'));
|
|
134
|
+
const reply = await sendCommand(
|
|
135
|
+
port,
|
|
136
|
+
argv('HEXPIRE', 'hexp:nx', '20', 'NX', 'FIELDS', '1', 'f1')
|
|
137
|
+
);
|
|
138
|
+
assert.equal(Number(tryParseValue(reply, 0).value[0]), 0);
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
it('HEXPIRE FIELDS count mismatch is a syntax error', async () => {
|
|
142
|
+
await sendCommand(port, argv('HSET', 'hexp:bad', 'f1', 'v1'));
|
|
143
|
+
const reply = await sendCommand(
|
|
144
|
+
port,
|
|
145
|
+
argv('HEXPIRE', 'hexp:bad', '10', 'FIELDS', '2', 'f1')
|
|
146
|
+
);
|
|
147
|
+
assert.ok(reply.toString('utf8').startsWith('-'), 'expected error reply');
|
|
148
|
+
});
|
|
149
|
+
|
|
68
150
|
it('legacy hash rows with null hash_count hydrate on first HLEN', async () => {
|
|
69
151
|
const s1 = await createTestServer();
|
|
70
152
|
await sendCommand(s1.port, argv('HSET', 'legacy:h', 'f1', 'v1', 'f2', 'v2'));
|
|
@@ -56,3 +56,165 @@ describe('Engine hashes', () => {
|
|
|
56
56
|
assert.throws(() => engine.hlen('hlen:str'), /WRONGTYPE/);
|
|
57
57
|
});
|
|
58
58
|
});
|
|
59
|
+
|
|
60
|
+
describe('Engine hash field TTL', () => {
|
|
61
|
+
function makeEngine(nowMs) {
|
|
62
|
+
const dbPath = tmpDbPath();
|
|
63
|
+
const db = openDb(dbPath);
|
|
64
|
+
let t = nowMs;
|
|
65
|
+
const clock = () => t;
|
|
66
|
+
const engine = createEngine({ db, clock });
|
|
67
|
+
return {
|
|
68
|
+
engine,
|
|
69
|
+
advance(ms) { t += ms; },
|
|
70
|
+
clock: () => t,
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
it('HEXPIRE sets TTL; HTTL reports seconds', () => {
|
|
75
|
+
const { engine } = makeEngine(1_000_000);
|
|
76
|
+
engine.hset('h', 'f1', 'v1');
|
|
77
|
+
const res = engine.hexpire('h', engine._clock() + 60_000, [Buffer.from('f1')]);
|
|
78
|
+
assert.deepEqual(res, [1]);
|
|
79
|
+
assert.deepEqual(engine.httl('h', [Buffer.from('f1')]), [60]);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it('HTTL returns -1 for field without TTL, -2 for missing field/key', () => {
|
|
83
|
+
const { engine } = makeEngine(1_000_000);
|
|
84
|
+
engine.hset('h', 'f1', 'v1');
|
|
85
|
+
assert.deepEqual(engine.httl('h', [Buffer.from('f1')]), [-1]);
|
|
86
|
+
assert.deepEqual(engine.httl('h', [Buffer.from('missing')]), [-2]);
|
|
87
|
+
assert.deepEqual(engine.httl('nokey', [Buffer.from('f1')]), [-2]);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it('HEXPIRE with non-existent field returns -2', () => {
|
|
91
|
+
const { engine } = makeEngine(1_000_000);
|
|
92
|
+
engine.hset('h', 'f1', 'v1');
|
|
93
|
+
const res = engine.hexpire('h', engine._clock() + 1000, [Buffer.from('nope')]);
|
|
94
|
+
assert.deepEqual(res, [-2]);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it('HEXPIRE with expiresAt in the past deletes the field (returns 2)', () => {
|
|
98
|
+
const { engine } = makeEngine(1_000_000);
|
|
99
|
+
engine.hset('h', 'f1', 'v1', 'f2', 'v2');
|
|
100
|
+
const res = engine.hexpire('h', engine._clock() - 1, [Buffer.from('f1')]);
|
|
101
|
+
assert.deepEqual(res, [2]);
|
|
102
|
+
assert.equal(engine.hget('h', 'f1'), null);
|
|
103
|
+
assert.equal(engine.hlen('h'), 1);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it('HEXPIRE NX/XX condition semantics', () => {
|
|
107
|
+
const { engine } = makeEngine(1_000_000);
|
|
108
|
+
engine.hset('h', 'f1', 'v1');
|
|
109
|
+
assert.deepEqual(
|
|
110
|
+
engine.hexpire('h', engine._clock() + 1000, [Buffer.from('f1')], { condition: 'XX' }),
|
|
111
|
+
[0]
|
|
112
|
+
);
|
|
113
|
+
assert.deepEqual(
|
|
114
|
+
engine.hexpire('h', engine._clock() + 1000, [Buffer.from('f1')], { condition: 'NX' }),
|
|
115
|
+
[1]
|
|
116
|
+
);
|
|
117
|
+
assert.deepEqual(
|
|
118
|
+
engine.hexpire('h', engine._clock() + 2000, [Buffer.from('f1')], { condition: 'NX' }),
|
|
119
|
+
[0]
|
|
120
|
+
);
|
|
121
|
+
assert.deepEqual(
|
|
122
|
+
engine.hexpire('h', engine._clock() + 2000, [Buffer.from('f1')], { condition: 'XX' }),
|
|
123
|
+
[1]
|
|
124
|
+
);
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it('HEXPIRE GT/LT condition semantics', () => {
|
|
128
|
+
const { engine } = makeEngine(1_000_000);
|
|
129
|
+
engine.hset('h', 'f1', 'v1');
|
|
130
|
+
assert.deepEqual(
|
|
131
|
+
engine.hexpire('h', engine._clock() + 1000, [Buffer.from('f1')], { condition: 'GT' }),
|
|
132
|
+
[0]
|
|
133
|
+
);
|
|
134
|
+
assert.deepEqual(
|
|
135
|
+
engine.hexpire('h', engine._clock() + 1000, [Buffer.from('f1')], { condition: 'LT' }),
|
|
136
|
+
[1]
|
|
137
|
+
);
|
|
138
|
+
assert.deepEqual(
|
|
139
|
+
engine.hexpire('h', engine._clock() + 500, [Buffer.from('f1')], { condition: 'GT' }),
|
|
140
|
+
[0]
|
|
141
|
+
);
|
|
142
|
+
assert.deepEqual(
|
|
143
|
+
engine.hexpire('h', engine._clock() + 2000, [Buffer.from('f1')], { condition: 'GT' }),
|
|
144
|
+
[1]
|
|
145
|
+
);
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
it('lazy expiration: HGET returns null after TTL; HLEN reflects live count', () => {
|
|
149
|
+
const { engine, advance } = makeEngine(1_000_000);
|
|
150
|
+
engine.hset('h', 'f1', 'v1', 'f2', 'v2');
|
|
151
|
+
engine.hexpire('h', engine._clock() + 1000, [Buffer.from('f1')]);
|
|
152
|
+
assert.equal(engine.hget('h', 'f1').toString(), 'v1');
|
|
153
|
+
assert.equal(engine.hlen('h'), 2);
|
|
154
|
+
advance(2000);
|
|
155
|
+
assert.equal(engine.hget('h', 'f1'), null);
|
|
156
|
+
assert.equal(engine.hlen('h'), 1);
|
|
157
|
+
const all = engine.hgetall('h');
|
|
158
|
+
assert.equal(all.length, 2);
|
|
159
|
+
assert.equal(all[0].toString(), 'f2');
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
it('empty hash after lazy expiration removes the key', () => {
|
|
163
|
+
const { engine, advance } = makeEngine(1_000_000);
|
|
164
|
+
engine.hset('h', 'f1', 'v1');
|
|
165
|
+
engine.hexpire('h', engine._clock() + 1000, [Buffer.from('f1')]);
|
|
166
|
+
advance(2000);
|
|
167
|
+
engine.hget('h', 'f1');
|
|
168
|
+
assert.equal(engine.type('h'), 'none');
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
it('HSET clears a field TTL', () => {
|
|
172
|
+
const { engine } = makeEngine(1_000_000);
|
|
173
|
+
engine.hset('h', 'f1', 'v1');
|
|
174
|
+
engine.hexpire('h', engine._clock() + 5000, [Buffer.from('f1')]);
|
|
175
|
+
assert.deepEqual(engine.httl('h', [Buffer.from('f1')]), [5]);
|
|
176
|
+
engine.hset('h', 'f1', 'v2');
|
|
177
|
+
assert.deepEqual(engine.httl('h', [Buffer.from('f1')]), [-1]);
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
it('HINCRBY clears a field TTL', () => {
|
|
181
|
+
const { engine } = makeEngine(1_000_000);
|
|
182
|
+
engine.hset('h', 'n', '1');
|
|
183
|
+
engine.hexpire('h', engine._clock() + 5000, [Buffer.from('n')]);
|
|
184
|
+
engine.hincrby('h', 'n', 2);
|
|
185
|
+
assert.deepEqual(engine.httl('h', [Buffer.from('n')]), [-1]);
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
it('HDEL removes field TTL row too (no leak)', () => {
|
|
189
|
+
const { engine } = makeEngine(1_000_000);
|
|
190
|
+
engine.hset('h', 'f1', 'v1', 'f2', 'v2');
|
|
191
|
+
engine.hexpire('h', engine._clock() + 5000, [Buffer.from('f1')]);
|
|
192
|
+
engine.hdel('h', ['f1']);
|
|
193
|
+
assert.deepEqual(engine.httl('h', [Buffer.from('f1')]), [-2]);
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
it('HPERSIST clears field TTL', () => {
|
|
197
|
+
const { engine } = makeEngine(1_000_000);
|
|
198
|
+
engine.hset('h', 'f1', 'v1');
|
|
199
|
+
engine.hexpire('h', engine._clock() + 5000, [Buffer.from('f1')]);
|
|
200
|
+
assert.deepEqual(engine.hpersist('h', [Buffer.from('f1')]), [1]);
|
|
201
|
+
assert.deepEqual(engine.httl('h', [Buffer.from('f1')]), [-1]);
|
|
202
|
+
assert.deepEqual(engine.hpersist('h', [Buffer.from('f1')]), [-1]);
|
|
203
|
+
assert.deepEqual(engine.hpersist('h', [Buffer.from('nope')]), [-2]);
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
it('HEXPIRE on missing hash key returns -2 for each field', () => {
|
|
207
|
+
const { engine } = makeEngine(1_000_000);
|
|
208
|
+
const res = engine.hexpire('nokey', engine._clock() + 1000, [Buffer.from('a'), Buffer.from('b')]);
|
|
209
|
+
assert.deepEqual(res, [-2, -2]);
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
it('HEXPIRE against a wrong-type key raises WRONGTYPE', () => {
|
|
213
|
+
const { engine } = makeEngine(1_000_000);
|
|
214
|
+
engine.set('str', 'v');
|
|
215
|
+
assert.throws(
|
|
216
|
+
() => engine.hexpire('str', engine._clock() + 1000, [Buffer.from('x')]),
|
|
217
|
+
/WRONGTYPE/
|
|
218
|
+
);
|
|
219
|
+
});
|
|
220
|
+
});
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { describe, it } from 'node:test';
|
|
2
2
|
import assert from 'node:assert/strict';
|
|
3
3
|
import { createEngine } from '../../src/engine/engine.js';
|
|
4
|
+
import { createExpirationSweeper } from '../../src/engine/expiration.js';
|
|
4
5
|
import { openDb } from '../../src/storage/sqlite/db.js';
|
|
5
6
|
import { tmpDbPath } from '../helpers/tmp.js';
|
|
6
7
|
import { fixedClock } from '../helpers/clock.js';
|
|
@@ -43,3 +44,45 @@ describe('Expiration', () => {
|
|
|
43
44
|
assert.equal(engine.ttl('p'), -1);
|
|
44
45
|
});
|
|
45
46
|
});
|
|
47
|
+
|
|
48
|
+
describe('Hash field expiration sweeper', () => {
|
|
49
|
+
it('sweeps expired hash fields and removes now-empty hash keys', () => {
|
|
50
|
+
const dbPath = tmpDbPath();
|
|
51
|
+
const db = openDb(dbPath);
|
|
52
|
+
let t = 1_000_000;
|
|
53
|
+
const clock = () => t;
|
|
54
|
+
const engine = createEngine({ db, clock });
|
|
55
|
+
const sweeper = createExpirationSweeper({ db, clock, sweepIntervalMs: 30 });
|
|
56
|
+
|
|
57
|
+
engine.hset('h1', 'f1', 'v1', 'f2', 'v2');
|
|
58
|
+
engine.hset('h2', 'only', 'gone');
|
|
59
|
+
engine.hexpire('h1', t + 500, [Buffer.from('f1')]);
|
|
60
|
+
engine.hexpire('h2', t + 500, [Buffer.from('only')]);
|
|
61
|
+
|
|
62
|
+
t += 1000;
|
|
63
|
+
|
|
64
|
+
// Drive a single sweep via start()/stop() bookends plus a manual flush.
|
|
65
|
+
// Sweeper's sweep() is internal; expose it by creating one tick's worth of behavior.
|
|
66
|
+
const row = db.prepare('SELECT COUNT(*) AS n FROM redis_hash_field_ttl WHERE expires_at <= ?').get(t);
|
|
67
|
+
assert.equal(row.n, 2);
|
|
68
|
+
|
|
69
|
+
// Drain by advancing: reuse setInterval semantics indirectly by stepping logic.
|
|
70
|
+
// Emulate a tick by starting the sweeper with a very short interval and waiting briefly.
|
|
71
|
+
sweeper.start();
|
|
72
|
+
return new Promise((resolve) => {
|
|
73
|
+
setTimeout(() => {
|
|
74
|
+
try {
|
|
75
|
+
sweeper.stop();
|
|
76
|
+
const ttlRows = db.prepare('SELECT COUNT(*) AS n FROM redis_hash_field_ttl').get();
|
|
77
|
+
assert.equal(ttlRows.n, 0, 'TTL rows should be swept');
|
|
78
|
+
assert.equal(engine.hget('h1', 'f1'), null);
|
|
79
|
+
assert.equal(engine.hlen('h1'), 1);
|
|
80
|
+
assert.equal(engine.type('h2'), 'none');
|
|
81
|
+
resolve();
|
|
82
|
+
} catch (e) {
|
|
83
|
+
resolve(Promise.reject(e));
|
|
84
|
+
}
|
|
85
|
+
}, 120);
|
|
86
|
+
});
|
|
87
|
+
});
|
|
88
|
+
});
|