bundis 0.1.0 → 0.2.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/README.md +26 -3
- package/package.json +1 -1
- package/src/cli.ts +14 -0
- package/src/commands/admin.ts +108 -0
- package/src/commands/expire.ts +36 -7
- package/src/commands/handshake.ts +25 -2
- package/src/commands/multikey.ts +8 -4
- package/src/commands/pubsub.ts +26 -3
- package/src/commands/set.ts +7 -1
- package/src/commands/string.ts +24 -11
- package/src/commands/transaction.ts +19 -9
- package/src/config.ts +29 -2
- package/src/connection.ts +34 -3
- package/src/dispatcher.ts +24 -0
- package/src/engine/errors.ts +6 -2
- package/src/launch.ts +27 -3
- package/src/resp/parser.ts +46 -6
- package/src/resp/serializer.ts +3 -0
- package/src/server.ts +60 -13
- package/src/sidecar/memory-guard.ts +31 -0
- package/src/sidecar/pubsub.ts +7 -0
- package/src/sidecar/reaper.ts +7 -1
- package/src/sidecar/watch.ts +52 -12
- package/src/storage/cache.ts +373 -0
- package/src/storage/sqlite.ts +125 -42
- package/src/storage/types.ts +2 -0
|
@@ -0,0 +1,373 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* HotCacheStorage — write-through in-memory hot cache over a StorageEngine.
|
|
3
|
+
*
|
|
4
|
+
* Policy (write-through + adaptive time-to-idle + LRU byte cap):
|
|
5
|
+
* - kvSet writes through to the inner engine, then caches the fresh value, so
|
|
6
|
+
* a key is hot from the moment it is written.
|
|
7
|
+
* - kvGet serves live cache entries without touching SQLite; misses read
|
|
8
|
+
* through and fill the cache.
|
|
9
|
+
* - An entry is evicted when idle longer than its effective TTI. The TTI grows
|
|
10
|
+
* with accumulated hits — `min(maxFactor·base, base·(1 + log2(1 + hits)))` —
|
|
11
|
+
* so frequently-read keys survive longer idle gaps. Hit counts halve on every
|
|
12
|
+
* sweep so past popularity decays.
|
|
13
|
+
* - Total cached bytes are capped; the least-recently-used entries are evicted
|
|
14
|
+
* first when the cap is exceeded. Values larger than 1/8 of the cap are never
|
|
15
|
+
* cached (one huge value must not flush the whole working set).
|
|
16
|
+
*
|
|
17
|
+
* Coherence (single-writer, single-process):
|
|
18
|
+
* - Every mutation in the inner engine fires its onWrite hook; the server wires
|
|
19
|
+
* that hook to {@link invalidate}, so all write paths (DEL, EXPIRE, INCR,
|
|
20
|
+
* hash/set ops, sweeps, type overwrites) evict stale entries automatically.
|
|
21
|
+
* - Inside an explicit transaction (MULTI/EXEC) fills are suppressed: a rolled
|
|
22
|
+
* back transaction must not leave uncommitted values in the cache.
|
|
23
|
+
* Invalidations still apply immediately (evicting too much is always safe).
|
|
24
|
+
* - Cache entries carry expireAtMs and are checked lazily on every hit, so an
|
|
25
|
+
* expired key never serves from cache.
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
import type { RedisType, SetOptions, StorageEngine } from "./types";
|
|
29
|
+
|
|
30
|
+
interface CacheEntry {
|
|
31
|
+
value: Uint8Array;
|
|
32
|
+
expireAtMs: number | null;
|
|
33
|
+
lastAccess: number;
|
|
34
|
+
hits: number;
|
|
35
|
+
bytes: number;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface HotCacheOptions {
|
|
39
|
+
/** Hard ceiling for cached bytes (keys + values + overhead). */
|
|
40
|
+
readonly maxBytes: number;
|
|
41
|
+
/** Base time-to-idle in ms. */
|
|
42
|
+
readonly baseIdleMs: number;
|
|
43
|
+
/** Cap on the adaptive TTI as a multiple of baseIdleMs (default 8). */
|
|
44
|
+
readonly maxIdleFactor?: number;
|
|
45
|
+
/** Min interval between idle sweeps in ms (default 5000). */
|
|
46
|
+
readonly sweepEveryMs?: number;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export interface CacheStats {
|
|
50
|
+
entries: number;
|
|
51
|
+
bytes: number;
|
|
52
|
+
maxBytes: number;
|
|
53
|
+
hits: number;
|
|
54
|
+
misses: number;
|
|
55
|
+
fills: number;
|
|
56
|
+
invalidations: number;
|
|
57
|
+
evictedIdle: number;
|
|
58
|
+
evictedLru: number;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const ENTRY_OVERHEAD = 96; // rough per-entry bookkeeping cost in bytes
|
|
62
|
+
|
|
63
|
+
/** Sentinel: the key vanished between write/read and the expiry read-back. */
|
|
64
|
+
const ABSENT = Symbol("absent");
|
|
65
|
+
|
|
66
|
+
export class HotCacheStorage implements StorageEngine {
|
|
67
|
+
// Map iteration order doubles as the LRU order: a hit re-inserts its entry.
|
|
68
|
+
#cache = new Map<string, CacheEntry>();
|
|
69
|
+
#bytes = 0;
|
|
70
|
+
#inTxn = false;
|
|
71
|
+
#lastSweep = 0;
|
|
72
|
+
|
|
73
|
+
#hits = 0;
|
|
74
|
+
#misses = 0;
|
|
75
|
+
#fills = 0;
|
|
76
|
+
#invalidations = 0;
|
|
77
|
+
#evictedIdle = 0;
|
|
78
|
+
#evictedLru = 0;
|
|
79
|
+
|
|
80
|
+
readonly #maxBytes: number;
|
|
81
|
+
readonly #baseIdleMs: number;
|
|
82
|
+
readonly #maxIdleMs: number;
|
|
83
|
+
readonly #sweepEveryMs: number;
|
|
84
|
+
readonly #maxEntryBytes: number;
|
|
85
|
+
|
|
86
|
+
constructor(
|
|
87
|
+
private readonly inner: StorageEngine,
|
|
88
|
+
opts: HotCacheOptions,
|
|
89
|
+
) {
|
|
90
|
+
this.#maxBytes = opts.maxBytes;
|
|
91
|
+
this.#baseIdleMs = opts.baseIdleMs;
|
|
92
|
+
this.#maxIdleMs = opts.baseIdleMs * (opts.maxIdleFactor ?? 8);
|
|
93
|
+
this.#sweepEveryMs = opts.sweepEveryMs ?? 5000;
|
|
94
|
+
this.#maxEntryBytes = Math.max(1, Math.floor(opts.maxBytes / 8));
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** Evict one key (no-op if absent). Wired to the inner engine's onWrite. */
|
|
98
|
+
invalidate = (key: Uint8Array): void => {
|
|
99
|
+
const k = mapKey(key);
|
|
100
|
+
const entry = this.#cache.get(k);
|
|
101
|
+
if (entry) {
|
|
102
|
+
this.#cache.delete(k);
|
|
103
|
+
this.#bytes -= entry.bytes;
|
|
104
|
+
this.#invalidations++;
|
|
105
|
+
}
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
stats(): CacheStats {
|
|
109
|
+
return {
|
|
110
|
+
entries: this.#cache.size,
|
|
111
|
+
bytes: this.#bytes,
|
|
112
|
+
maxBytes: this.#maxBytes,
|
|
113
|
+
hits: this.#hits,
|
|
114
|
+
misses: this.#misses,
|
|
115
|
+
fills: this.#fills,
|
|
116
|
+
invalidations: this.#invalidations,
|
|
117
|
+
evictedIdle: this.#evictedIdle,
|
|
118
|
+
evictedLru: this.#evictedLru,
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// ── cached fast paths ────────────────────────────────────────────────────-
|
|
123
|
+
|
|
124
|
+
kvGet(key: Uint8Array, now: number): Uint8Array | null {
|
|
125
|
+
const entry = this.#liveEntry(key, now);
|
|
126
|
+
if (entry) {
|
|
127
|
+
this.#hits++;
|
|
128
|
+
return entry.value;
|
|
129
|
+
}
|
|
130
|
+
this.#misses++;
|
|
131
|
+
const value = this.inner.kvGet(key, now);
|
|
132
|
+
if (value !== null && !this.#inTxn) {
|
|
133
|
+
const exp = this.#expireOf(key, now);
|
|
134
|
+
if (exp !== ABSENT) this.#fill(key, value, exp, now);
|
|
135
|
+
}
|
|
136
|
+
return value;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
kvSet(key: Uint8Array, value: Uint8Array, now: number, opts?: SetOptions): "set" | "noop" {
|
|
140
|
+
const result = this.inner.kvSet(key, value, now, opts);
|
|
141
|
+
// onWrite already invalidated any stale entry; cache the committed value.
|
|
142
|
+
// A "noop" (NX/XX guard) fires no onWrite, so the old entry stays valid.
|
|
143
|
+
if (result === "set" && !this.#inTxn) {
|
|
144
|
+
// If the write carried an already-past expiry, the read-back's lazy
|
|
145
|
+
// expiry deletes the row and reports ABSENT — caching then would create
|
|
146
|
+
// an immortal ghost entry that SQLite no longer backs.
|
|
147
|
+
const exp = this.#expireOf(key, now);
|
|
148
|
+
if (exp !== ABSENT) this.#fill(key, value, exp, now);
|
|
149
|
+
}
|
|
150
|
+
return result;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
exists(key: Uint8Array, now: number): boolean {
|
|
154
|
+
if (this.#liveEntry(key, now)) {
|
|
155
|
+
this.#hits++;
|
|
156
|
+
return true;
|
|
157
|
+
}
|
|
158
|
+
this.#misses++;
|
|
159
|
+
return this.inner.exists(key, now);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
typeOf(key: Uint8Array, now: number): RedisType | null {
|
|
163
|
+
if (this.#liveEntry(key, now)) {
|
|
164
|
+
this.#hits++;
|
|
165
|
+
return "string";
|
|
166
|
+
}
|
|
167
|
+
this.#misses++;
|
|
168
|
+
return this.inner.typeOf(key, now);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
pttl(key: Uint8Array, now: number): number {
|
|
172
|
+
const entry = this.#liveEntry(key, now);
|
|
173
|
+
if (entry) {
|
|
174
|
+
this.#hits++;
|
|
175
|
+
return entry.expireAtMs === null ? -1 : Math.max(0, entry.expireAtMs - now);
|
|
176
|
+
}
|
|
177
|
+
this.#misses++;
|
|
178
|
+
return this.inner.pttl(key, now);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
sweepExpired(now: number): number {
|
|
182
|
+
if (now - this.#lastSweep >= this.#sweepEveryMs) {
|
|
183
|
+
this.#lastSweep = now;
|
|
184
|
+
this.#idleSweep(now);
|
|
185
|
+
}
|
|
186
|
+
// Inner sweep fires onWrite per removed key → cache invalidation included.
|
|
187
|
+
return this.inner.sweepExpired(now);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
withTransaction<T>(fn: () => T): T {
|
|
191
|
+
// Suppress fills while a MULTI/EXEC body runs: if the transaction rolls
|
|
192
|
+
// back, the cache must not retain values that were never committed.
|
|
193
|
+
const outer = this.#inTxn;
|
|
194
|
+
this.#inTxn = true;
|
|
195
|
+
try {
|
|
196
|
+
return this.inner.withTransaction(fn);
|
|
197
|
+
} finally {
|
|
198
|
+
this.#inTxn = outer;
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
close(): void {
|
|
203
|
+
this.#cache.clear();
|
|
204
|
+
this.#bytes = 0;
|
|
205
|
+
this.inner.close();
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// ── pure delegations (onWrite keeps the cache coherent) ──────────────────-
|
|
209
|
+
|
|
210
|
+
del(keys: Uint8Array[], now: number): number {
|
|
211
|
+
// Eager eviction (not just onWrite): inner.del only fires onWrite for keys
|
|
212
|
+
// whose row exists, so this guarantees DEL always heals any divergence.
|
|
213
|
+
for (const k of keys) this.invalidate(k);
|
|
214
|
+
return this.inner.del(keys, now);
|
|
215
|
+
}
|
|
216
|
+
expireSet(key: Uint8Array, atMs: number, now: number): boolean {
|
|
217
|
+
return this.inner.expireSet(key, atMs, now);
|
|
218
|
+
}
|
|
219
|
+
persist(key: Uint8Array, now: number): boolean {
|
|
220
|
+
return this.inner.persist(key, now);
|
|
221
|
+
}
|
|
222
|
+
dbsize(now: number): number {
|
|
223
|
+
return this.inner.dbsize(now);
|
|
224
|
+
}
|
|
225
|
+
flushAll(): void {
|
|
226
|
+
this.invalidateAll();
|
|
227
|
+
this.inner.flushAll();
|
|
228
|
+
}
|
|
229
|
+
/** Drop every cache entry (FLUSHDB or any whole-keyspace event). */
|
|
230
|
+
invalidateAll(): void {
|
|
231
|
+
this.#invalidations += this.#cache.size;
|
|
232
|
+
this.#cache.clear();
|
|
233
|
+
this.#bytes = 0;
|
|
234
|
+
}
|
|
235
|
+
incrBy(key: Uint8Array, delta: bigint, now: number): bigint {
|
|
236
|
+
return this.inner.incrBy(key, delta, now);
|
|
237
|
+
}
|
|
238
|
+
incrByFloat(key: Uint8Array, delta: number, now: number): number {
|
|
239
|
+
return this.inner.incrByFloat(key, delta, now);
|
|
240
|
+
}
|
|
241
|
+
append(key: Uint8Array, value: Uint8Array, now: number): number {
|
|
242
|
+
return this.inner.append(key, value, now);
|
|
243
|
+
}
|
|
244
|
+
hSet(key: Uint8Array, pairs: ReadonlyArray<readonly [Uint8Array, Uint8Array]>, now: number): number {
|
|
245
|
+
return this.inner.hSet(key, pairs, now);
|
|
246
|
+
}
|
|
247
|
+
hGet(key: Uint8Array, field: Uint8Array, now: number): Uint8Array | null {
|
|
248
|
+
return this.inner.hGet(key, field, now);
|
|
249
|
+
}
|
|
250
|
+
hDel(key: Uint8Array, fields: Uint8Array[], now: number): number {
|
|
251
|
+
return this.inner.hDel(key, fields, now);
|
|
252
|
+
}
|
|
253
|
+
hGetAll(key: Uint8Array, now: number): Array<[Uint8Array, Uint8Array]> {
|
|
254
|
+
return this.inner.hGetAll(key, now);
|
|
255
|
+
}
|
|
256
|
+
hKeys(key: Uint8Array, now: number): Uint8Array[] {
|
|
257
|
+
return this.inner.hKeys(key, now);
|
|
258
|
+
}
|
|
259
|
+
hVals(key: Uint8Array, now: number): Uint8Array[] {
|
|
260
|
+
return this.inner.hVals(key, now);
|
|
261
|
+
}
|
|
262
|
+
hLen(key: Uint8Array, now: number): number {
|
|
263
|
+
return this.inner.hLen(key, now);
|
|
264
|
+
}
|
|
265
|
+
hExists(key: Uint8Array, field: Uint8Array, now: number): boolean {
|
|
266
|
+
return this.inner.hExists(key, field, now);
|
|
267
|
+
}
|
|
268
|
+
hIncrBy(key: Uint8Array, field: Uint8Array, delta: bigint, now: number): bigint {
|
|
269
|
+
return this.inner.hIncrBy(key, field, delta, now);
|
|
270
|
+
}
|
|
271
|
+
hIncrByFloat(key: Uint8Array, field: Uint8Array, delta: number, now: number): number {
|
|
272
|
+
return this.inner.hIncrByFloat(key, field, delta, now);
|
|
273
|
+
}
|
|
274
|
+
sAdd(key: Uint8Array, members: Uint8Array[], now: number): number {
|
|
275
|
+
return this.inner.sAdd(key, members, now);
|
|
276
|
+
}
|
|
277
|
+
sRem(key: Uint8Array, members: Uint8Array[], now: number): number {
|
|
278
|
+
return this.inner.sRem(key, members, now);
|
|
279
|
+
}
|
|
280
|
+
sIsMember(key: Uint8Array, member: Uint8Array, now: number): boolean {
|
|
281
|
+
return this.inner.sIsMember(key, member, now);
|
|
282
|
+
}
|
|
283
|
+
sMembers(key: Uint8Array, now: number): Uint8Array[] {
|
|
284
|
+
return this.inner.sMembers(key, now);
|
|
285
|
+
}
|
|
286
|
+
sCard(key: Uint8Array, now: number): number {
|
|
287
|
+
return this.inner.sCard(key, now);
|
|
288
|
+
}
|
|
289
|
+
sRandMember(key: Uint8Array, count: number | null, now: number): Uint8Array[] | Uint8Array | null {
|
|
290
|
+
return this.inner.sRandMember(key, count, now);
|
|
291
|
+
}
|
|
292
|
+
sPop(key: Uint8Array, count: number | null, now: number): Uint8Array[] | Uint8Array | null {
|
|
293
|
+
return this.inner.sPop(key, count, now);
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
// ── internals ────────────────────────────────────────────────────────────-
|
|
297
|
+
|
|
298
|
+
/** Live cache entry for key, refreshed as most-recently-used; null if absent/expired. */
|
|
299
|
+
#liveEntry(key: Uint8Array, now: number): CacheEntry | null {
|
|
300
|
+
const k = mapKey(key);
|
|
301
|
+
const entry = this.#cache.get(k);
|
|
302
|
+
if (!entry) return null;
|
|
303
|
+
if (entry.expireAtMs !== null && entry.expireAtMs <= now) {
|
|
304
|
+
// Key TTL passed: drop from cache; the inner lazy path owns row deletion.
|
|
305
|
+
this.#cache.delete(k);
|
|
306
|
+
this.#bytes -= entry.bytes;
|
|
307
|
+
return null;
|
|
308
|
+
}
|
|
309
|
+
entry.lastAccess = now;
|
|
310
|
+
entry.hits++;
|
|
311
|
+
this.#cache.delete(k); // re-insert → most recently used
|
|
312
|
+
this.#cache.set(k, entry);
|
|
313
|
+
return entry;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
#fill(key: Uint8Array, value: Uint8Array, expireAtMs: number | null, now: number): void {
|
|
317
|
+
const bytes = key.length + value.byteLength + ENTRY_OVERHEAD;
|
|
318
|
+
if (bytes > this.#maxEntryBytes) return;
|
|
319
|
+
const k = mapKey(key);
|
|
320
|
+
const prev = this.#cache.get(k);
|
|
321
|
+
if (prev) {
|
|
322
|
+
this.#bytes -= prev.bytes;
|
|
323
|
+
this.#cache.delete(k);
|
|
324
|
+
}
|
|
325
|
+
this.#cache.set(k, {
|
|
326
|
+
value,
|
|
327
|
+
expireAtMs,
|
|
328
|
+
lastAccess: now,
|
|
329
|
+
hits: prev ? prev.hits : 0, // overwrite keeps earned popularity
|
|
330
|
+
bytes,
|
|
331
|
+
});
|
|
332
|
+
this.#bytes += bytes;
|
|
333
|
+
this.#fills++;
|
|
334
|
+
while (this.#bytes > this.#maxBytes && this.#cache.size > 0) {
|
|
335
|
+
const oldest = this.#cache.keys().next().value as string;
|
|
336
|
+
const evicted = this.#cache.get(oldest)!;
|
|
337
|
+
this.#cache.delete(oldest);
|
|
338
|
+
this.#bytes -= evicted.bytes;
|
|
339
|
+
this.#evictedLru++;
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
/**
|
|
344
|
+
* Resulting expiry of a just-read/just-written string key: epoch ms, null
|
|
345
|
+
* for "no expiry", or ABSENT when the key no longer exists (-2) — callers
|
|
346
|
+
* must skip the fill in that case.
|
|
347
|
+
*/
|
|
348
|
+
#expireOf(key: Uint8Array, now: number): number | null | typeof ABSENT {
|
|
349
|
+
const ttl = this.inner.pttl(key, now);
|
|
350
|
+
if (ttl === -2) return ABSENT;
|
|
351
|
+
return ttl === -1 ? null : now + ttl;
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
/** Evict idle entries; decay hit counts so popularity is not permanent. */
|
|
355
|
+
#idleSweep(now: number): void {
|
|
356
|
+
for (const [k, entry] of this.#cache) {
|
|
357
|
+
const grown = this.#baseIdleMs * (1 + Math.log2(1 + entry.hits));
|
|
358
|
+
const tti = Math.min(this.#maxIdleMs, grown);
|
|
359
|
+
if (now - entry.lastAccess > tti) {
|
|
360
|
+
this.#cache.delete(k);
|
|
361
|
+
this.#bytes -= entry.bytes;
|
|
362
|
+
this.#evictedIdle++;
|
|
363
|
+
} else {
|
|
364
|
+
entry.hits >>= 1;
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
/** Binary-safe Map key for raw key bytes. */
|
|
371
|
+
function mapKey(key: Uint8Array): string {
|
|
372
|
+
return Buffer.from(key).toString("latin1");
|
|
373
|
+
}
|
package/src/storage/sqlite.ts
CHANGED
|
@@ -17,25 +17,57 @@ import {
|
|
|
17
17
|
} from "../engine/errors";
|
|
18
18
|
import type { RedisType, SetOptions, StorageEngine } from "./types";
|
|
19
19
|
|
|
20
|
+
const ENC = new TextEncoder();
|
|
21
|
+
|
|
22
|
+
/** Max expired keys reclaimed per sweep call (bounds reaper-tick stalls). */
|
|
23
|
+
const SWEEP_BATCH = 1000;
|
|
24
|
+
|
|
20
25
|
export interface SqliteStorageOptions {
|
|
21
26
|
/** Hook invoked with a key whenever it is mutated (drives WATCH versioning). */
|
|
22
27
|
readonly onWrite?: (key: Uint8Array) => void;
|
|
28
|
+
/** Hook invoked after FLUSHDB/FLUSHALL (per-key hooks can't enumerate). */
|
|
29
|
+
readonly onFlushAll?: () => void;
|
|
30
|
+
/** SQLite page-cache size in KB (PRAGMA cache_size). Default: SQLite's own. */
|
|
31
|
+
readonly pageCacheKb?: number;
|
|
23
32
|
}
|
|
24
33
|
|
|
25
34
|
export class SqliteStorage implements StorageEngine {
|
|
26
35
|
#db: Database;
|
|
27
36
|
#onWrite: (key: Uint8Array) => void;
|
|
37
|
+
#onFlushAll: () => void;
|
|
28
38
|
|
|
29
39
|
constructor(path = ":memory:", opts: SqliteStorageOptions = {}) {
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
40
|
+
try {
|
|
41
|
+
this.#db = new Database(path, { create: true, strict: false });
|
|
42
|
+
this.#onWrite = opts.onWrite ?? (() => {});
|
|
43
|
+
this.#onFlushAll = opts.onFlushAll ?? (() => {});
|
|
44
|
+
this.#init(opts.pageCacheKb);
|
|
45
|
+
} catch (err) {
|
|
46
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
47
|
+
if (/locked|busy/i.test(msg)) {
|
|
48
|
+
throw new Error(
|
|
49
|
+
`database "${path}" is already in use by another bundis process ` +
|
|
50
|
+
"(the single-writer contract forbids sharing one .db file)",
|
|
51
|
+
);
|
|
52
|
+
}
|
|
53
|
+
throw new Error(`cannot open database "${path}": ${msg}`);
|
|
54
|
+
}
|
|
33
55
|
}
|
|
34
56
|
|
|
35
|
-
#init(): void {
|
|
57
|
+
#init(pageCacheKb?: number): void {
|
|
58
|
+
// Enforce the documented single-writer contract (§5.3): EXCLUSIVE locking
|
|
59
|
+
// holds the file lock for the process lifetime, so a second bundis on the
|
|
60
|
+
// same .db fails fast here instead of corrupting cache coherence later.
|
|
61
|
+
// (:memory: is unaffected; WAL+exclusive runs with a heap WAL-index.)
|
|
62
|
+
this.#db.exec("PRAGMA busy_timeout = 2000;");
|
|
63
|
+
this.#db.exec("PRAGMA locking_mode = EXCLUSIVE;");
|
|
36
64
|
this.#db.exec("PRAGMA journal_mode = WAL;");
|
|
37
65
|
this.#db.exec("PRAGMA synchronous = NORMAL;");
|
|
38
66
|
this.#db.exec("PRAGMA foreign_keys = ON;");
|
|
67
|
+
if (pageCacheKb !== undefined && pageCacheKb > 0) {
|
|
68
|
+
// negative value = size in KB (positive would mean pages)
|
|
69
|
+
this.#db.exec(`PRAGMA cache_size = -${Math.floor(pageCacheKb)};`);
|
|
70
|
+
}
|
|
39
71
|
this.#db.exec(`
|
|
40
72
|
CREATE TABLE IF NOT EXISTS keys (
|
|
41
73
|
key BLOB PRIMARY KEY,
|
|
@@ -146,22 +178,43 @@ export class SqliteStorage implements StorageEngine {
|
|
|
146
178
|
return true;
|
|
147
179
|
}
|
|
148
180
|
|
|
181
|
+
/**
|
|
182
|
+
* One bounded sweep batch per call (the reaper ticks every ~100ms, so a
|
|
183
|
+
* burst of expirations is reclaimed incrementally instead of blocking every
|
|
184
|
+
* connection for one giant transaction — measured 380ms p99 before the cap).
|
|
185
|
+
*/
|
|
149
186
|
sweepExpired(now: number): number {
|
|
150
187
|
return this.withTransaction(() => {
|
|
151
188
|
const rows = this.#stmt(
|
|
152
|
-
"
|
|
189
|
+
"DELETE FROM keys WHERE key IN (" +
|
|
190
|
+
"SELECT key FROM keys WHERE expire_at_ms IS NOT NULL AND expire_at_ms <= ? " +
|
|
191
|
+
`LIMIT ${SWEEP_BATCH}) RETURNING key`,
|
|
153
192
|
).all(now) as Array<{ key: Uint8Array }>;
|
|
154
|
-
for (const r of rows) this.#
|
|
193
|
+
for (const r of rows) this.#onWrite(r.key);
|
|
155
194
|
return rows.length;
|
|
156
195
|
});
|
|
157
196
|
}
|
|
158
197
|
|
|
159
198
|
dbsize(now: number): number {
|
|
160
|
-
|
|
161
|
-
|
|
199
|
+
// Count live keys without sweeping: INFO/DBSIZE polling must never pay
|
|
200
|
+
// (or trigger) a reclamation pass.
|
|
201
|
+
const row = this.#stmt(
|
|
202
|
+
"SELECT COUNT(*) AS n FROM keys WHERE expire_at_ms IS NULL OR expire_at_ms > ?",
|
|
203
|
+
).get(now) as { n: number };
|
|
162
204
|
return row.n;
|
|
163
205
|
}
|
|
164
206
|
|
|
207
|
+
flushAll(): void {
|
|
208
|
+
this.withTransaction(() => {
|
|
209
|
+
// Children first: kv/hash_fields/set_members reference keys.
|
|
210
|
+
this.#stmt("DELETE FROM kv").run();
|
|
211
|
+
this.#stmt("DELETE FROM hash_fields").run();
|
|
212
|
+
this.#stmt("DELETE FROM set_members").run();
|
|
213
|
+
this.#stmt("DELETE FROM keys").run();
|
|
214
|
+
});
|
|
215
|
+
this.#onFlushAll();
|
|
216
|
+
}
|
|
217
|
+
|
|
165
218
|
// ── string / kv ─────────────────────────────────────────────────────────--
|
|
166
219
|
|
|
167
220
|
kvGet(key: Uint8Array, now: number): Uint8Array | null {
|
|
@@ -215,7 +268,7 @@ export class SqliteStorage implements StorageEngine {
|
|
|
215
268
|
n = parseIntStrict(cur);
|
|
216
269
|
}
|
|
217
270
|
const next = n + delta;
|
|
218
|
-
const buf =
|
|
271
|
+
const buf = ENC.encode(next.toString());
|
|
219
272
|
this.#stmt(
|
|
220
273
|
"INSERT INTO keys(key, type, expire_at_ms) VALUES (?, 'string', NULL) " +
|
|
221
274
|
"ON CONFLICT(key) DO UPDATE SET type='string'",
|
|
@@ -237,7 +290,7 @@ export class SqliteStorage implements StorageEngine {
|
|
|
237
290
|
if (!Number.isFinite(next)) {
|
|
238
291
|
throw new RespError("ERR", "increment would produce NaN or Infinity");
|
|
239
292
|
}
|
|
240
|
-
const buf =
|
|
293
|
+
const buf = ENC.encode(formatFloat(next));
|
|
241
294
|
this.#stmt(
|
|
242
295
|
"INSERT INTO keys(key, type, expire_at_ms) VALUES (?, 'string', NULL) " +
|
|
243
296
|
"ON CONFLICT(key) DO UPDATE SET type='string'",
|
|
@@ -361,7 +414,7 @@ export class SqliteStorage implements StorageEngine {
|
|
|
361
414
|
const cur = this.hGet(key, field, now);
|
|
362
415
|
const n = cur === null ? 0n : parseIntStrict(cur);
|
|
363
416
|
const next = n + delta;
|
|
364
|
-
this.hSet(key, [[field,
|
|
417
|
+
this.hSet(key, [[field, ENC.encode(next.toString())]], now);
|
|
365
418
|
return next;
|
|
366
419
|
});
|
|
367
420
|
}
|
|
@@ -371,16 +424,16 @@ export class SqliteStorage implements StorageEngine {
|
|
|
371
424
|
const cur = this.hGet(key, field, now);
|
|
372
425
|
const n = cur === null ? 0 : parseFloatStrict(cur);
|
|
373
426
|
const next = n + delta;
|
|
374
|
-
this.hSet(key, [[field,
|
|
427
|
+
this.hSet(key, [[field, ENC.encode(formatFloat(next))]], now);
|
|
375
428
|
return next;
|
|
376
429
|
});
|
|
377
430
|
}
|
|
378
431
|
|
|
379
432
|
#dropIfEmptyHash(key: Uint8Array): void {
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
).get(key)
|
|
383
|
-
if (
|
|
433
|
+
// Existence probe, not COUNT(*): a 100k-field hash must not pay a full
|
|
434
|
+
// index scan on every HDEL just to learn it is non-empty.
|
|
435
|
+
const any = this.#stmt("SELECT 1 FROM hash_fields WHERE key = ? LIMIT 1").get(key);
|
|
436
|
+
if (any === null) this.#stmt("DELETE FROM keys WHERE key = ?").run(key);
|
|
384
437
|
}
|
|
385
438
|
|
|
386
439
|
// ── set ────────────────────────────────────────────────────────────────---
|
|
@@ -449,15 +502,25 @@ export class SqliteStorage implements StorageEngine {
|
|
|
449
502
|
count: number | null,
|
|
450
503
|
now: number,
|
|
451
504
|
): Uint8Array[] | Uint8Array | null {
|
|
452
|
-
|
|
505
|
+
if (!this.#expectType(key, "set", now)) {
|
|
506
|
+
return count === null ? null : [];
|
|
507
|
+
}
|
|
508
|
+
// Random selection stays in SQL: materializing all members into JS just to
|
|
509
|
+
// pick a few measured ~5ms per op on a 100k set.
|
|
453
510
|
if (count === null) {
|
|
454
|
-
|
|
455
|
-
return
|
|
511
|
+
const row = this.#randomMember(key);
|
|
512
|
+
return row ?? null;
|
|
456
513
|
}
|
|
457
514
|
if (count >= 0) {
|
|
458
|
-
return
|
|
515
|
+
return (
|
|
516
|
+
this.#stmt(
|
|
517
|
+
"SELECT member FROM set_members WHERE key = ? ORDER BY random() LIMIT ?",
|
|
518
|
+
).all(key, count) as Array<{ member: Uint8Array }>
|
|
519
|
+
).map((r) => r.member);
|
|
459
520
|
}
|
|
460
|
-
// Negative count:
|
|
521
|
+
// Negative count: |count| picks WITH repeats — needs independent draws, so
|
|
522
|
+
// the full-load path is genuinely required here.
|
|
523
|
+
const members = this.sMembers(key, now);
|
|
461
524
|
const out: Uint8Array[] = [];
|
|
462
525
|
if (members.length === 0) return out;
|
|
463
526
|
for (let i = 0; i < -count; i++) {
|
|
@@ -472,24 +535,53 @@ export class SqliteStorage implements StorageEngine {
|
|
|
472
535
|
now: number,
|
|
473
536
|
): Uint8Array[] | Uint8Array | null {
|
|
474
537
|
return this.withTransaction(() => {
|
|
475
|
-
|
|
538
|
+
if (!this.#expectType(key, "set", now)) {
|
|
539
|
+
return count === null ? null : [];
|
|
540
|
+
}
|
|
476
541
|
if (count === null) {
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
this
|
|
480
|
-
return
|
|
542
|
+
// Single pop: uniform COUNT+OFFSET pick is ~3x cheaper than
|
|
543
|
+
// ORDER BY random() (no per-row random + top-k sort).
|
|
544
|
+
const member = this.#randomMember(key);
|
|
545
|
+
if (member === undefined) return null;
|
|
546
|
+
this.#stmt("DELETE FROM set_members WHERE key = ? AND member = ?").run(key, member);
|
|
547
|
+
this.#dropIfEmptySet(key);
|
|
548
|
+
this.#onWrite(key);
|
|
549
|
+
return member;
|
|
550
|
+
}
|
|
551
|
+
const k = Math.max(0, count);
|
|
552
|
+
const rows =
|
|
553
|
+
k === 0
|
|
554
|
+
? []
|
|
555
|
+
: (this.#stmt(
|
|
556
|
+
"DELETE FROM set_members WHERE key = ? AND member IN (" +
|
|
557
|
+
"SELECT member FROM set_members WHERE key = ? ORDER BY random() LIMIT ?" +
|
|
558
|
+
") RETURNING member",
|
|
559
|
+
).all(key, key, k) as Array<{ member: Uint8Array }>);
|
|
560
|
+
if (rows.length > 0) {
|
|
561
|
+
this.#dropIfEmptySet(key);
|
|
562
|
+
this.#onWrite(key);
|
|
481
563
|
}
|
|
482
|
-
|
|
483
|
-
if (picks.length > 0) this.sRem(key, picks, now);
|
|
484
|
-
return picks;
|
|
564
|
+
return rows.map((r) => r.member);
|
|
485
565
|
});
|
|
486
566
|
}
|
|
487
567
|
|
|
488
|
-
|
|
568
|
+
/** Uniform random member of a set key, or undefined when empty. */
|
|
569
|
+
#randomMember(key: Uint8Array): Uint8Array | undefined {
|
|
570
|
+
const n = (
|
|
571
|
+
this.#stmt("SELECT COUNT(*) AS n FROM set_members WHERE key = ?").get(key) as {
|
|
572
|
+
n: number;
|
|
573
|
+
}
|
|
574
|
+
).n;
|
|
575
|
+
if (n === 0) return undefined;
|
|
489
576
|
const row = this.#stmt(
|
|
490
|
-
"SELECT
|
|
491
|
-
).get(key) as {
|
|
492
|
-
|
|
577
|
+
"SELECT member FROM set_members WHERE key = ? LIMIT 1 OFFSET ?",
|
|
578
|
+
).get(key, Math.floor(Math.random() * n)) as { member: Uint8Array } | null;
|
|
579
|
+
return row?.member;
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
#dropIfEmptySet(key: Uint8Array): void {
|
|
583
|
+
const any = this.#stmt("SELECT 1 FROM set_members WHERE key = ? LIMIT 1").get(key);
|
|
584
|
+
if (any === null) this.#stmt("DELETE FROM keys WHERE key = ?").run(key);
|
|
493
585
|
}
|
|
494
586
|
|
|
495
587
|
// ── atomicity / lifecycle ───────────────────────────────────────────────--
|
|
@@ -556,12 +648,3 @@ function concatBytes(a: Uint8Array, b: Uint8Array): Uint8Array {
|
|
|
556
648
|
out.set(b, a.length);
|
|
557
649
|
return out;
|
|
558
650
|
}
|
|
559
|
-
|
|
560
|
-
function shuffle<T>(arr: T[]): T[] {
|
|
561
|
-
const a = arr.slice();
|
|
562
|
-
for (let i = a.length - 1; i > 0; i--) {
|
|
563
|
-
const j = Math.floor(Math.random() * (i + 1));
|
|
564
|
-
[a[i], a[j]] = [a[j]!, a[i]!];
|
|
565
|
-
}
|
|
566
|
-
return a;
|
|
567
|
-
}
|
package/src/storage/types.ts
CHANGED
|
@@ -38,6 +38,8 @@ export interface StorageEngine {
|
|
|
38
38
|
sweepExpired(now: number): number;
|
|
39
39
|
/** Total live key count (after lazy considerations are out of scope here). */
|
|
40
40
|
dbsize(now: number): number;
|
|
41
|
+
/** Remove every key (FLUSHDB/FLUSHALL). */
|
|
42
|
+
flushAll(): void;
|
|
41
43
|
|
|
42
44
|
// ── string / kv ─────────────────────────────────────────────────────────--
|
|
43
45
|
kvGet(key: Uint8Array, now: number): Uint8Array | null;
|