@nxtedition/cache 2.1.14 → 2.1.16
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 +105 -19
- package/lib/index.d.ts +3 -1
- package/lib/index.d.ts.map +1 -1
- package/lib/index.js +486 -310
- package/lib/index.js.map +1 -1
- package/lib/memory.d.ts +2 -2
- package/lib/memory.d.ts.map +1 -1
- package/lib/memory.js +15 -4
- package/lib/memory.js.map +1 -1
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -5,6 +5,7 @@ A two-tier async cache with SQLite persistence, in-memory pseudo-LRU, stale-whil
|
|
|
5
5
|
## Features
|
|
6
6
|
|
|
7
7
|
- **Two-tier storage** — In-memory cache backed by SQLite on disk
|
|
8
|
+
- **File-sharded SQLite** — Keys are hash-routed across N independent SQLite files, bypassing SQLite's per-file writer serialization. ~50% higher throughput under multi-thread write contention (see [Benchmarks](#benchmarks)).
|
|
8
9
|
- **Stale-while-revalidate** — Serve stale data synchronously while refreshing in the background
|
|
9
10
|
- **Request coalescing** — Concurrent fetches for the same key share a single in-flight `Promise`
|
|
10
11
|
- **Cross-thread locking** — `SharedArrayBuffer` + `Atomics.compareExchange` / `Atomics.waitAsync` prevent redundant `valueSelector` calls across worker threads in the same process that share the same `location`
|
|
@@ -87,10 +88,11 @@ The in-memory tier uses a random-two-choice eviction strategy: when the cache is
|
|
|
87
88
|
|
|
88
89
|
#### `DatabaseOptions`
|
|
89
90
|
|
|
90
|
-
| Option | Type | Default | Description
|
|
91
|
-
| --------- | -------- | ---------------------------- |
|
|
92
|
-
| `timeout` | `number` | `20` | SQLite busy timeout in milliseconds.
|
|
93
|
-
| `maxSize` | `number` | `128 * 1024 * 1024` (128 MB) | Maximum database
|
|
91
|
+
| Option | Type | Default | Description |
|
|
92
|
+
| --------- | -------- | ---------------------------- | ------------------------------------------------------------------------------------------------------------------------- |
|
|
93
|
+
| `timeout` | `number` | `20` | SQLite busy timeout in milliseconds. |
|
|
94
|
+
| `maxSize` | `number` | `128 * 1024 * 1024` (128 MB) | Maximum total database size across all shards. Divided evenly per shard. Oldest entries are evicted when a shard is full. |
|
|
95
|
+
| `shards` | `number` | `4` | Number of SQLite files (shards) the cache is spread across. See [File Sharding](#file-sharding). Use `1` for single-file. |
|
|
94
96
|
|
|
95
97
|
#### `Serializer<V>`
|
|
96
98
|
|
|
@@ -138,16 +140,22 @@ Forces a new fetch via `valueSelector` regardless of cache state. Unlike `get()`
|
|
|
138
140
|
|
|
139
141
|
Remove an entry from both memory and SQLite. Also cancels any in-flight deduplication for that key — a pending fetch will still resolve for its callers, but the result is **not** written to the cache.
|
|
140
142
|
|
|
141
|
-
#### `cache.
|
|
143
|
+
#### `cache.gc(): void`
|
|
142
144
|
|
|
143
145
|
Remove all expired entries (past `ttl + stale`) from both the in-memory cache and SQLite, and run `PRAGMA wal_checkpoint(TRUNCATE)` + `PRAGMA optimize`.
|
|
144
146
|
|
|
147
|
+
#### `cache.flushSync(): void`
|
|
148
|
+
|
|
149
|
+
Synchronously drain all pending batched writes to SQLite. The cache remains open and usable afterwards. This is useful when you need to guarantee persistence at a specific point without closing the cache (e.g. before handing off to another cache instance that shares the same database).
|
|
150
|
+
|
|
145
151
|
#### `cache.close(): void`
|
|
146
152
|
|
|
147
|
-
|
|
153
|
+
Calls `flushSync()` to drain pending writes, then closes the SQLite database and releases resources. Clears all in-flight deduplication. Operations after `close()` throw.
|
|
148
154
|
|
|
149
155
|
Also available as `[Symbol.dispose]()` for use with `using` declarations.
|
|
150
156
|
|
|
157
|
+
Open caches are automatically closed on the `beforeExit` event, ensuring pending writes are flushed before the process exits.
|
|
158
|
+
|
|
151
159
|
#### `cache.stats`
|
|
152
160
|
|
|
153
161
|
Returns runtime statistics:
|
|
@@ -183,19 +191,19 @@ Once the stale window expires, the entry is purged entirely and the next `get()`
|
|
|
183
191
|
|
|
184
192
|
```
|
|
185
193
|
|--- ttl ---|--- stale ---|
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
sync hit
|
|
189
|
-
|
|
194
|
+
fresh stale expired
|
|
195
|
+
↓ ↓ ↓
|
|
196
|
+
sync hit sync hit async miss
|
|
197
|
+
+ bg refresh
|
|
190
198
|
```
|
|
191
199
|
|
|
192
200
|
## Cross-Thread Locking
|
|
193
201
|
|
|
194
|
-
Worker threads in the same process that pass the same `location` to `new Cache(...)` share a `SharedArrayBuffer` (acquired via `@nxtedition/shared`'s `getOrCreate` registry). That buffer is treated as a hash table of per-key
|
|
202
|
+
Worker threads in the same process that pass the same `location` to `new Cache(...)` share a 256 KiB `SharedArrayBuffer` (64K `Int32` slots, acquired via `@nxtedition/shared`'s `getOrCreate` registry). That buffer is treated as a hash table of per-key binary mutex slots:
|
|
195
203
|
|
|
196
|
-
- Acquire
|
|
197
|
-
-
|
|
198
|
-
- Exception paths (sync `valueSelector` throw, async rejection, serializer/ttl/stale throwing inside `#set`)
|
|
204
|
+
- **Acquire:** `Atomics.compareExchange(slot, 0, 1)`. The winner runs `valueSelector`; losers call `Atomics.waitAsync` (with a 1 s timeout as a guard against missed notifies and holder crashes). On timeout, the waiter falls back to a lockless refresh.
|
|
205
|
+
- **Release:** The holder does `Atomics.sub(slot, 1)` + `Atomics.notify` inside the batched flush, waking waiters which then read the freshly-cached value from the database.
|
|
206
|
+
- **Exception safety:** All exception paths (sync `valueSelector` throw, async rejection, serializer/ttl/stale throwing inside `#set`) release the slot under a `try/finally`, so a buggy user callback can never wedge a slot.
|
|
199
207
|
|
|
200
208
|
When the `location` is `':memory:'`, the cache is inherently per-instance (the SQLite DB isn't shared), so the SAB is skipped entirely and all coordination falls back to the instance-local `#dedupe` Map.
|
|
201
209
|
|
|
@@ -209,15 +217,32 @@ There is **no cross-process coordination** — two separate Node processes point
|
|
|
209
217
|
- Single-threaded app that doesn't spawn workers — disable to skip the cheap SAB round-trip on every `get()` cache miss.
|
|
210
218
|
- Workload where each worker uses a disjoint keyspace — the SAB adds overhead without any dedupe benefit.
|
|
211
219
|
|
|
212
|
-
|
|
220
|
+
## File Sharding
|
|
221
|
+
|
|
222
|
+
The cache partitions its SQLite storage across N physical files (default `shards: 4`). Each key is hash-routed (via xxhash32) to one shard, and reads/writes for that key only touch that shard's connection. This is intended to reduce the SQLite writer-serialization ceiling: SQLite allows only one writer at a time per database file, even in WAL mode, so a single file caps concurrent-writer throughput at roughly one thread's worth of work regardless of how many threads the process spawns.
|
|
223
|
+
|
|
224
|
+
**On-disk layout:**
|
|
225
|
+
|
|
226
|
+
| `shards` | Files |
|
|
227
|
+
| ----------- | -------------------------------------------------------------------- |
|
|
228
|
+
| `1` | `<location>` (single file, legacy layout — compatible with `stat()`) |
|
|
229
|
+
| `N` (N ≥ 2) | `<location>.0`, `<location>.1`, …, `<location>.{N-1}` |
|
|
230
|
+
|
|
231
|
+
With `shards: 1` the cache is a single file at the given `location`, identical to the pre-sharding layout — useful for backup scripts or observability tools that expect one file.
|
|
213
232
|
|
|
214
|
-
|
|
233
|
+
**Cross-instance consistency:** hash routing is deterministic (xxhash32 is pure), so two Cache instances pointing at the same `location` with the same `shards` count will route the same keys to the same shards. Data persists across instance restarts.
|
|
234
|
+
|
|
235
|
+
**Changing `shards` invalidates data.** If you open a cache with `shards: 2` after previously using `shards: 4`, keys hash-route to different shard files than they were written to. The old data is still on disk but effectively unreachable until you open with the original shard count.
|
|
236
|
+
|
|
237
|
+
**Per-shard state:** `maxSize` divides evenly across shards (`maxSize / shards` per shard), eviction runs per-shard on `SQLITE_FULL`, and `stats.database.size` is summed across all shards.
|
|
215
238
|
|
|
216
239
|
## Batched Writes
|
|
217
240
|
|
|
218
|
-
SQLite writes are batched using `setImmediate` — multiple `set()` calls within the same microtask turn are coalesced into a single `BEGIN`/`COMMIT` transaction. While a write batch is pending, the in-memory cache is corked (eviction deferred) to avoid dropping entries before they reach disk. If a batch exceeds 512 items, it is flushed immediately.
|
|
241
|
+
SQLite writes are batched using `setImmediate` — multiple `set()` calls within the same microtask turn are coalesced into a single `BEGIN`/`COMMIT` transaction per shard. While a write batch is pending, the in-memory cache is corked (eviction deferred) to avoid dropping entries before they reach disk. If a per-shard batch exceeds 512 items, it is flushed immediately.
|
|
219
242
|
|
|
220
|
-
|
|
243
|
+
Flushes iterate shards starting at a random index on each call, so no single shard is starved when the 10 ms per-flush time budget is exhausted mid-pass — the next flush picks a different starting shard.
|
|
244
|
+
|
|
245
|
+
If a shard's database is full (`SQLITE_FULL`), the cache evicts the 256 oldest entries in that shard and retries up to 3 times. On other errors, the entire pending batch for that shard is dropped (items remain in the in-memory cache until natural eviction/TTL) and the error is emitted — this prevents error floods when the underlying failure is persistent (e.g. a read-only DB).
|
|
221
246
|
|
|
222
247
|
## Error Handling
|
|
223
248
|
|
|
@@ -225,7 +250,68 @@ If the database is full (`SQLITE_FULL`), the cache evicts the 256 oldest entries
|
|
|
225
250
|
|
|
226
251
|
## Off-Peak Purge
|
|
227
252
|
|
|
228
|
-
All cache instances listen on the `nxt:offPeak` `BroadcastChannel`. When a message is received, `
|
|
253
|
+
All cache instances listen on the `nxt:offPeak` `BroadcastChannel`. When a message is received, `gc()` is called on every active instance, enabling coordinated cleanup during low-traffic periods.
|
|
254
|
+
|
|
255
|
+
## Benchmarks
|
|
256
|
+
|
|
257
|
+
Measured on Apple M3 Pro (12 CPUs), Node 25.6.1. Throughput is `ops/sec`; latency is `ns` (median).
|
|
258
|
+
|
|
259
|
+
### Single-thread hot paths
|
|
260
|
+
|
|
261
|
+
| Operation | ops/sec | p50 | p99 |
|
|
262
|
+
| ------------------------------------------- | ------- | ------ | ------- |
|
|
263
|
+
| `get()` memory hit (sequential keys) | 4.90 M | 125 ns | 666 ns |
|
|
264
|
+
| `get()` memory hit (random keys) | 6.05 M | 125 ns | 584 ns |
|
|
265
|
+
| `peek()` memory hit | 8.05 M | 125 ns | 250 ns |
|
|
266
|
+
| `get()` memory miss, DB hit (sequential) | 303 K | 2.3 µs | 10.6 µs |
|
|
267
|
+
| `get()` memory miss, DB hit (random) | 399 K | 2.3 µs | 6.4 µs |
|
|
268
|
+
| `get()` cold (sync `valueSelector`) | 274 K | 1.8 µs | 4.5 µs |
|
|
269
|
+
| `get()` memory-only hit (no DB) | 5.39 M | 125 ns | 500 ns |
|
|
270
|
+
| `get()` memory-only cold (no DB) | 1.16 M | 708 ns | 2.3 µs |
|
|
271
|
+
| `get()` eviction pressure (`maxCount=1000`) | 1.64 M | 542 ns | 1.5 µs |
|
|
272
|
+
| `delete()` existing keys | 67 K | 13 µs | 44 µs |
|
|
273
|
+
| `gc()` 10 K expired entries | 7.2 ms | — | — |
|
|
274
|
+
|
|
275
|
+
### Shard-count comparison
|
|
276
|
+
|
|
277
|
+
Single-thread overhead of sharding on the hot paths is ≤5 % in either direction. The pay-off is under multi-thread write contention:
|
|
278
|
+
|
|
279
|
+
**12 threads, partitioned cold writes** (each worker writes unique keys, stressing concurrent writers):
|
|
280
|
+
|
|
281
|
+
| `shards` | Aggregate throughput | Scaling vs. 1 thread | Δ vs. `shards: 1` |
|
|
282
|
+
| -------- | -------------------- | -------------------- | ----------------- |
|
|
283
|
+
| 1 | 154 K ops/s | 0.80× | baseline |
|
|
284
|
+
| 2 | 223 K ops/s | 1.04× | **+45 %** |
|
|
285
|
+
| 4 | 237 K ops/s | 1.37× | **+54 %** |
|
|
286
|
+
| 8 | 182 K ops/s | 1.08× | **+19 %** |
|
|
287
|
+
|
|
288
|
+
**12 threads, shared-keys hot-hit** (mostly memory-resident; sharding shouldn't help much here):
|
|
289
|
+
|
|
290
|
+
| `shards` | Aggregate throughput |
|
|
291
|
+
| -------- | -------------------- |
|
|
292
|
+
| 1 | 7.69 M ops/s |
|
|
293
|
+
| 2 | 7.86 M ops/s |
|
|
294
|
+
| 4 | 7.70 M ops/s |
|
|
295
|
+
| 8 | 7.51 M ops/s |
|
|
296
|
+
|
|
297
|
+
So `shards: 4` (the default) is a large win for write-heavy multi-threaded workloads and roughly break-even otherwise. If you run single-threaded or strictly read-heavy, `shards: 1` removes all sharding overhead and restores the single-file on-disk layout.
|
|
298
|
+
|
|
299
|
+
### Lock option overhead (single-thread)
|
|
300
|
+
|
|
301
|
+
| Path | lock enabled | lock disabled |
|
|
302
|
+
| --------------------------------- | ------------ | ------------- |
|
|
303
|
+
| `get()` cold (sync valueSelector) | 298 K ops/s | 306 K ops/s |
|
|
304
|
+
| `get()` memory hit | 7.44 M ops/s | 6.19 M ops/s |
|
|
305
|
+
|
|
306
|
+
Cross-thread SAB locking adds ~3 % on the cold path and is negligible on memory hits. It is worth leaving on unless the workload is strictly single-threaded or partitioned across workers.
|
|
307
|
+
|
|
308
|
+
### Reproducing
|
|
309
|
+
|
|
310
|
+
```sh
|
|
311
|
+
yarn build
|
|
312
|
+
node scripts/bench.mjs # full bench suite
|
|
313
|
+
node scripts/bench-shards.mjs # shard-count comparison (1, 2, 4, 8 shards)
|
|
314
|
+
```
|
|
229
315
|
|
|
230
316
|
## Scripts
|
|
231
317
|
|
package/lib/index.d.ts
CHANGED
|
@@ -3,6 +3,7 @@ import { type MemoryOptions } from './memory.ts';
|
|
|
3
3
|
export type { MemoryOptions } from './memory.ts';
|
|
4
4
|
export interface DatabaseOptions {
|
|
5
5
|
timeout?: number;
|
|
6
|
+
shards?: number;
|
|
6
7
|
maxSize?: number;
|
|
7
8
|
}
|
|
8
9
|
export interface Serializer<V> {
|
|
@@ -48,12 +49,13 @@ export declare class Cache<V = unknown, A extends unknown[] = [string]> extends
|
|
|
48
49
|
} | undefined;
|
|
49
50
|
};
|
|
50
51
|
[Symbol.dispose](): void;
|
|
52
|
+
flushSync(): void;
|
|
51
53
|
close(): void;
|
|
52
54
|
get(...args: A): CacheResult<V>;
|
|
53
55
|
peek(...args: A): CacheResult<V>;
|
|
54
56
|
refresh(...args: A): CacheResult<V>;
|
|
55
57
|
delete(...args: A): void;
|
|
56
|
-
|
|
58
|
+
gc(): void;
|
|
57
59
|
}
|
|
58
60
|
/** @deprecated Use `Cache` instead. */
|
|
59
61
|
export declare const AsyncCache: typeof Cache;
|
package/lib/index.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,YAAY,EAAE,MAAM,aAAa,CAAA;AAO1C,OAAO,EAAsC,KAAK,aAAa,EAAE,MAAM,aAAa,CAAA;AAEpF,YAAY,EAAE,aAAa,EAAE,MAAM,aAAa,CAAA;
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,YAAY,EAAE,MAAM,aAAa,CAAA;AAO1C,OAAO,EAAsC,KAAK,aAAa,EAAE,MAAM,aAAa,CAAA;AAEpF,YAAY,EAAE,aAAa,EAAE,MAAM,aAAa,CAAA;AAiDhD,MAAM,WAAW,eAAe;IAC9B,OAAO,CAAC,EAAE,MAAM,CAAA;IAChB,MAAM,CAAC,EAAE,MAAM,CAAA;IACf,OAAO,CAAC,EAAE,MAAM,CAAA;CACjB;AAED,MAAM,WAAW,UAAU,CAAC,CAAC;IAC3B,SAAS,EAAE,CAAC,KAAK,EAAE,CAAC,KAAK,MAAM,GAAG,UAAU,GAAG,MAAM,CAAA;IACrD,WAAW,EAAE,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,KAAK,CAAC,CAAA;CAC1C;AAaD,MAAM,WAAW,YAAY,CAAC,CAAC;IAC7B,GAAG,CAAC,EAAE,MAAM,GAAG,CAAC,CAAC,KAAK,EAAE,CAAC,EAAE,GAAG,EAAE,MAAM,KAAK,MAAM,CAAC,CAAA;IAClD,KAAK,CAAC,EAAE,MAAM,GAAG,CAAC,CAAC,KAAK,EAAE,CAAC,EAAE,GAAG,EAAE,MAAM,KAAK,MAAM,CAAC,CAAA;IACpD,MAAM,CAAC,EAAE,aAAa,GAAG,KAAK,GAAG,IAAI,CAAA;IACrC,IAAI,CAAC,EAAE,KAAK,GAAG,IAAI,CAAA;IACnB,QAAQ,CAAC,EAAE,eAAe,GAAG,KAAK,GAAG,IAAI,CAAA;IACzC,UAAU,CAAC,EAAE,UAAU,CAAC,CAAC,CAAC,CAAA;CAC3B;AAED,MAAM,MAAM,WAAW,CAAC,CAAC,IACrB;IAAE,KAAK,EAAE,CAAC,GAAG,SAAS,CAAC;IAAC,KAAK,EAAE,KAAK,CAAA;CAAE,GACtC;IAAE,KAAK,EAAE,OAAO,CAAC,CAAC,CAAC,CAAC;IAAC,KAAK,EAAE,IAAI,CAAA;CAAE,CAAA;AAEtC,OAAO,CAAC,MAAM,CAAC;IAEb,IAAI,WAAW,EAAE,OAAO,CAAC;QAAE,KAAK,EAAE,KAAK,CAAC,OAAO,CAAC,CAAA;KAAE,CAAC,EAAE,CAAA;CACtD;AA6FD,qBAAa,KAAK,CAAC,CAAC,GAAG,OAAO,EAAE,CAAC,SAAS,OAAO,EAAE,GAAG,CAAC,MAAM,CAAC,CAAE,SAAQ,YAAY;;gBAuChF,QAAQ,EAAE,MAAM,EAChB,aAAa,CAAC,EAAE,CAAC,GAAG,IAAI,EAAE,CAAC,KAAK,CAAC,GAAG,WAAW,CAAC,CAAC,CAAC,EAClD,WAAW,CAAC,EAAE,CAAC,GAAG,IAAI,EAAE,CAAC,KAAK,MAAM,EACpC,IAAI,CAAC,EAAE,YAAY,CAAC,CAAC,CAAC;IAuHxB,IAAI,KAAK;;;;;;;;;;;sBACmB,MAAM;kBAAQ,MAAM,GAAG,SAAS;;MAqB3D;IAED,CAAC,MAAM,CAAC,OAAO,CAAC;IAIhB,SAAS;IAiBT,KAAK;IA4BL,GAAG,CAAC,GAAG,IAAI,EAAE,CAAC,GAAG,WAAW,CAAC,CAAC,CAAC;IAO/B,IAAI,CAAC,GAAG,IAAI,EAAE,CAAC,GAAG,WAAW,CAAC,CAAC,CAAC;IAOhC,OAAO,CAAC,GAAG,IAAI,EAAE,CAAC,GAAG,WAAW,CAAC,CAAC,CAAC;IAcnC,MAAM,CAAC,GAAG,IAAI,EAAE,CAAC,GAAG,IAAI;IAYxB,EAAE,IAAI,IAAI;CA6bX;AA2BD,uCAAuC;AACvC,eAAO,MAAM,UAAU,cAAQ,CAAA;AAC/B,8CAA8C;AAC9C,MAAM,MAAM,iBAAiB,CAAC,CAAC,IAAI,YAAY,CAAC,CAAC,CAAC,CAAA"}
|