resplite 1.4.20 → 1.5.2

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.
@@ -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
+ });