@nxtedition/cache 2.1.7 → 2.1.9
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 +91 -48
- package/lib/index.d.ts +3 -2
- package/lib/index.js +63 -29
- package/lib/memory.js +2 -7
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -1,15 +1,17 @@
|
|
|
1
1
|
# @nxtedition/cache
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
A two-tier async cache with SQLite persistence, in-memory LRU, stale-while-revalidate, cross-process deduplication, and automatic request deduplication.
|
|
4
4
|
|
|
5
5
|
## Features
|
|
6
6
|
|
|
7
|
-
- **Two-tier storage
|
|
8
|
-
- **Stale-while-revalidate
|
|
9
|
-
- **Request deduplication
|
|
10
|
-
- **
|
|
11
|
-
- **
|
|
12
|
-
- **
|
|
7
|
+
- **Two-tier storage** — In-memory LRU cache backed by SQLite on disk
|
|
8
|
+
- **Stale-while-revalidate** — Serve stale data synchronously while refreshing in the background
|
|
9
|
+
- **Request deduplication** — Concurrent fetches for the same key share a single in-flight request
|
|
10
|
+
- **Cross-process locking** — SQLite-based distributed locks prevent redundant work across processes/threads
|
|
11
|
+
- **Async value resolution** — Transparently fetches missing values via a user-defined `valueSelector`
|
|
12
|
+
- **Binary support** — Store and retrieve `Buffer` / `Uint8Array` alongside JSON values
|
|
13
|
+
- **Size-bounded storage** — Configurable max database size with automatic eviction of oldest entries
|
|
14
|
+
- **Custom serialization** — Pluggable `serialize`/`deserialize` for non-JSON value types
|
|
13
15
|
|
|
14
16
|
## Usage
|
|
15
17
|
|
|
@@ -19,14 +21,13 @@ import { Cache } from '@nxtedition/cache'
|
|
|
19
21
|
const cache = new Cache(
|
|
20
22
|
'./my-cache.db', // SQLite file path, or ':memory:'
|
|
21
23
|
async (id: string) => {
|
|
22
|
-
// fetch the value for this key
|
|
23
24
|
const res = await fetch(`https://api.example.com/items/${id}`)
|
|
24
25
|
return res.json()
|
|
25
26
|
},
|
|
26
27
|
(id: string) => id, // keySelector: derive cache key from arguments
|
|
27
28
|
{
|
|
28
|
-
ttl: 60_000, //
|
|
29
|
-
stale: 30_000, // serve stale for
|
|
29
|
+
ttl: 60_000, // 60 s before value is considered stale
|
|
30
|
+
stale: 30_000, // serve stale for 30 s while revalidating
|
|
30
31
|
},
|
|
31
32
|
)
|
|
32
33
|
|
|
@@ -43,23 +44,25 @@ if (result.async) {
|
|
|
43
44
|
|
|
44
45
|
## API
|
|
45
46
|
|
|
46
|
-
### `new Cache(location, valueSelector
|
|
47
|
+
### `new Cache(location, valueSelector?, keySelector?, opts?)`
|
|
47
48
|
|
|
48
|
-
| Parameter | Type
|
|
49
|
-
| --------------- |
|
|
50
|
-
| `location` | `string`
|
|
51
|
-
| `valueSelector` | `(...args) => V \|
|
|
52
|
-
| `keySelector` | `(...args) => string`
|
|
53
|
-
| `opts` | `CacheOptions<V>`
|
|
49
|
+
| Parameter | Type | Description |
|
|
50
|
+
| --------------- | ---------------------------------- | --------------------------------------------- |
|
|
51
|
+
| `location` | `string` | SQLite database path, or `':memory:'` |
|
|
52
|
+
| `valueSelector` | `(...args) => V \| PromiseLike<V>` | Function to fetch a value on cache miss |
|
|
53
|
+
| `keySelector` | `(...args) => string` | Function to derive a cache key from arguments |
|
|
54
|
+
| `opts` | `CacheOptions<V>` | Optional configuration |
|
|
54
55
|
|
|
55
|
-
####
|
|
56
|
+
#### `CacheOptions`
|
|
56
57
|
|
|
57
|
-
| Option
|
|
58
|
-
|
|
|
59
|
-
| `ttl`
|
|
60
|
-
| `stale`
|
|
61
|
-
| `memory`
|
|
62
|
-
| `database`
|
|
58
|
+
| Option | Type | Default | Description |
|
|
59
|
+
| ------------ | ---------------------------------- | ------------------------------------- | ------------------------------------------------------------------------------ |
|
|
60
|
+
| `ttl` | `number \| (value, key) => number` | `MAX_SAFE_INTEGER` | Time-to-live in milliseconds. After this, the entry is stale. |
|
|
61
|
+
| `stale` | `number \| (value, key) => number` | `MAX_SAFE_INTEGER` | Stale-while-revalidate window in ms. After `ttl + stale`, the entry is purged. |
|
|
62
|
+
| `memory` | `MemoryOptions \| false \| null` | `{ maxSize: 16MB, maxCount: 16384 }` | In-memory cache config, or `false`/`null` to disable. |
|
|
63
|
+
| `database` | `DatabaseOptions \| false \| null` | `{ timeout: 20, maxSize: 128MB }` | SQLite config, or `false`/`null` to disable persistence. |
|
|
64
|
+
| `lock` | `LockOptions \| false \| null` | `{ minTimeout: 1, maxTimeout: 1000 }` | Cross-process lock config, or `false`/`null` to disable. |
|
|
65
|
+
| `serializer` | `Serializer<V>` | JSON + ArrayBufferView passthrough | Custom `{ serialize, deserialize }` for value encoding. |
|
|
63
66
|
|
|
64
67
|
#### `MemoryOptions`
|
|
65
68
|
|
|
@@ -73,26 +76,42 @@ if (result.async) {
|
|
|
73
76
|
| Option | Type | Default | Description |
|
|
74
77
|
| --------- | -------- | ---------------------------- | ----------------------------------------------------------------- |
|
|
75
78
|
| `timeout` | `number` | `20` | SQLite busy timeout in milliseconds. |
|
|
76
|
-
| `maxSize` | `number` | `
|
|
79
|
+
| `maxSize` | `number` | `128 * 1024 * 1024` (128 MB) | Maximum database file size. Oldest entries are evicted when full. |
|
|
80
|
+
|
|
81
|
+
#### `LockOptions`
|
|
82
|
+
|
|
83
|
+
Cross-process locking prevents multiple processes from computing the same value simultaneously. The lock timeout is adaptive — it uses an exponential moving average (EMA) of `valueSelector` durations to estimate how long to wait before taking over a lock.
|
|
84
|
+
|
|
85
|
+
| Option | Type | Default | Description |
|
|
86
|
+
| ------------ | -------- | ------- | -------------------------------------------------------------------------- |
|
|
87
|
+
| `minTimeout` | `number` | `1` | Minimum lock timeout in ms. Also the starting timeout before EMA warms up. |
|
|
88
|
+
| `maxTimeout` | `number` | `1000` | Maximum lock timeout in ms. Caps the EMA-derived timeout. |
|
|
89
|
+
|
|
90
|
+
#### `Serializer<V>`
|
|
91
|
+
|
|
92
|
+
| Method | Signature | Description |
|
|
93
|
+
| ------------- | ---------------------------------------------- | --------------------------- |
|
|
94
|
+
| `serialize` | `(value: V) => Buffer \| Uint8Array \| string` | Encode a value for storage. |
|
|
95
|
+
| `deserialize` | `(data: Buffer \| string) => V` | Decode a stored value. |
|
|
96
|
+
|
|
97
|
+
The default serializer passes `ArrayBufferView` values through as-is and uses `JSON.stringify`/`JSON.parse` for everything else.
|
|
77
98
|
|
|
78
99
|
### `CacheResult<V>`
|
|
79
100
|
|
|
80
101
|
Both `get()` and `peek()` return a `CacheResult<V>`, a discriminated union on the `async` property:
|
|
81
102
|
|
|
82
|
-
| `async` | `value` | Meaning
|
|
83
|
-
| ------- | ---------------- |
|
|
84
|
-
| `false` | `V \| undefined` | Cache hit — the value is available synchronously. Also returned for stale entries (a background refresh is triggered
|
|
85
|
-
| `true` | `Promise<V>` | Cache miss — `value` is a `Promise` that resolves when the `valueSelector` completes.
|
|
103
|
+
| `async` | `value` | Meaning |
|
|
104
|
+
| ------- | ---------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
105
|
+
| `false` | `V \| undefined` | Cache hit — the value is available synchronously. Also returned for stale entries (a background refresh is triggered). `undefined` when `peek()` has no cached entry. |
|
|
106
|
+
| `true` | `Promise<V>` | Cache miss — `value` is a `Promise` that resolves when the `valueSelector` completes. |
|
|
86
107
|
|
|
87
108
|
```ts
|
|
88
109
|
const result = cache.get('key')
|
|
89
110
|
|
|
90
111
|
if (result.async) {
|
|
91
|
-
// miss — await the fetch
|
|
92
|
-
const value = await result.value
|
|
112
|
+
const value = await result.value // miss — await the fetch
|
|
93
113
|
} else {
|
|
94
|
-
// hit (fresh or stale) — use directly
|
|
95
|
-
const value = result.value
|
|
114
|
+
const value = result.value // hit (fresh or stale) — use directly
|
|
96
115
|
}
|
|
97
116
|
```
|
|
98
117
|
|
|
@@ -100,61 +119,85 @@ if (result.async) {
|
|
|
100
119
|
|
|
101
120
|
#### `cache.get(...args): CacheResult<V>`
|
|
102
121
|
|
|
103
|
-
Returns a cached value or triggers a fetch on cache miss.
|
|
122
|
+
Returns a cached value or triggers a fetch on cache miss. If the entry is stale and the `valueSelector` is async, returns the stale value synchronously while a background refresh runs.
|
|
104
123
|
|
|
105
124
|
#### `cache.peek(...args): CacheResult<V>`
|
|
106
125
|
|
|
107
|
-
Same as `get()` but does **not** trigger a refresh on cache miss. Returns `{ value: undefined, async: false }` for missing entries.
|
|
126
|
+
Same as `get()` but does **not** trigger a refresh on cache miss or stale entry. Returns `{ value: undefined, async: false }` for missing/expired entries, or the stale value if within the stale window.
|
|
108
127
|
|
|
109
|
-
#### `cache.refresh(...args):
|
|
128
|
+
#### `cache.refresh(...args): CacheResult<V>`
|
|
110
129
|
|
|
111
|
-
|
|
130
|
+
Forces a new fetch via `valueSelector` regardless of cache state. Unlike `get()`, concurrent `refresh()` calls for the same key do **not** deduplicate — each call invokes the `valueSelector`. However, `get()` calls during a pending `refresh()` will return the in-flight promise.
|
|
112
131
|
|
|
113
132
|
#### `cache.delete(...args): void`
|
|
114
133
|
|
|
115
|
-
Remove an entry from
|
|
134
|
+
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.
|
|
116
135
|
|
|
117
136
|
#### `cache.purgeStale(): void`
|
|
118
137
|
|
|
119
|
-
Remove all expired entries from both the in-memory cache and SQLite.
|
|
138
|
+
Remove all expired entries (past `ttl + stale`) from both the in-memory cache and SQLite. Also cleans up stale lock rows older than 1 hour and runs `PRAGMA wal_checkpoint(TRUNCATE)` + `PRAGMA optimize`.
|
|
120
139
|
|
|
121
140
|
#### `cache.close(): void`
|
|
122
141
|
|
|
123
|
-
Close the SQLite database and release resources.
|
|
142
|
+
Close the SQLite database and release resources. Clears all in-flight deduplication. Operations after `close()` throw.
|
|
143
|
+
|
|
144
|
+
#### `cache.stats`
|
|
145
|
+
|
|
146
|
+
Returns runtime statistics:
|
|
147
|
+
|
|
148
|
+
```ts
|
|
149
|
+
{
|
|
150
|
+
lock: { timeout, mean, stddev } | undefined,
|
|
151
|
+
dedupe: { size },
|
|
152
|
+
memory: { size, maxSize, count, maxCount } | undefined,
|
|
153
|
+
database: { location, size } | undefined,
|
|
154
|
+
}
|
|
155
|
+
```
|
|
124
156
|
|
|
125
157
|
## Deduplication
|
|
126
158
|
|
|
127
|
-
Concurrent calls to `get()`
|
|
159
|
+
Concurrent calls to `get()` for the same key share a single in-flight `Promise`. The `valueSelector` is called only once:
|
|
128
160
|
|
|
129
161
|
```ts
|
|
130
162
|
// valueSelector is called once, both promises resolve to the same value
|
|
131
163
|
const [a, b] = await Promise.all([cache.get('key').value, cache.get('key').value])
|
|
132
164
|
```
|
|
133
165
|
|
|
134
|
-
If a fetch fails, the deduplication entry is cleaned up and subsequent calls
|
|
166
|
+
If a fetch fails, the deduplication entry is cleaned up and subsequent calls retry.
|
|
135
167
|
|
|
136
168
|
Calling `cache.delete(key)` while a fetch is in-flight invalidates the deduplication entry. The pending promise still resolves for its callers, but the result is **not** written to the cache.
|
|
137
169
|
|
|
170
|
+
`refresh()` does **not** deduplicate with itself — each call starts a new fetch. However, `get()` calls see the most recent pending promise.
|
|
171
|
+
|
|
138
172
|
## Stale-While-Revalidate
|
|
139
173
|
|
|
140
|
-
When an entry's TTL has expired but is still within the stale window
|
|
174
|
+
When an entry's TTL has expired but is still within the stale window, `get()` returns the stale value synchronously (`async: false`) and triggers a background refresh (when the `valueSelector` is async). If the refresh fails, the stale value is preserved.
|
|
141
175
|
|
|
142
176
|
Once the stale window expires, the entry is purged entirely and the next `get()` returns `async: true`.
|
|
143
177
|
|
|
144
178
|
```
|
|
145
179
|
|--- ttl ---|--- stale ---|
|
|
146
180
|
fresh stale expired
|
|
181
|
+
↓ ↓ ↓
|
|
182
|
+
sync hit sync hit async miss
|
|
183
|
+
+ bg refresh
|
|
147
184
|
```
|
|
148
185
|
|
|
186
|
+
## Cross-Process Locking
|
|
187
|
+
|
|
188
|
+
When multiple processes or threads share the same SQLite database, the lock mechanism prevents redundant `valueSelector` calls. Process A acquires a lock, computes the value, and writes it. Process B sees the lock, waits for the estimated completion time (EMA-based), then reads the value from SQLite.
|
|
189
|
+
|
|
190
|
+
If the lock holder crashes, the lock becomes stale after `3 × lockTimeout` and another process steals it.
|
|
191
|
+
|
|
149
192
|
## Off-Peak Purge
|
|
150
193
|
|
|
151
|
-
All cache instances listen
|
|
194
|
+
All cache instances listen on the `nxt:offPeak` `BroadcastChannel`. When a message is received, `purgeStale()` is called on every active instance, enabling coordinated cleanup during low-traffic periods.
|
|
152
195
|
|
|
153
196
|
## Scripts
|
|
154
197
|
|
|
155
198
|
```sh
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
199
|
+
yarn test # run tests
|
|
200
|
+
yarn test:coverage # run tests with coverage report (90%+ enforced)
|
|
201
|
+
yarn typecheck # type-check without emitting
|
|
202
|
+
yarn build # build for publishing
|
|
160
203
|
```
|
package/lib/index.d.ts
CHANGED
|
@@ -29,9 +29,9 @@ export type CacheResult<V> = {
|
|
|
29
29
|
async: true;
|
|
30
30
|
};
|
|
31
31
|
declare global {
|
|
32
|
-
var __nxt_cache: {
|
|
32
|
+
var __nxt_cache: WeakRef<{
|
|
33
33
|
stats: Cache['stats'];
|
|
34
|
-
}[];
|
|
34
|
+
}>[];
|
|
35
35
|
}
|
|
36
36
|
export declare class Cache<V = unknown, A extends unknown[] = [string]> extends EventEmitter {
|
|
37
37
|
#private;
|
|
@@ -56,6 +56,7 @@ export declare class Cache<V = unknown, A extends unknown[] = [string]> extends
|
|
|
56
56
|
size: number | undefined;
|
|
57
57
|
} | undefined;
|
|
58
58
|
};
|
|
59
|
+
[Symbol.dispose](): void;
|
|
59
60
|
close(): void;
|
|
60
61
|
get(...args: A): CacheResult<V>;
|
|
61
62
|
peek(...args: A): CacheResult<V>;
|
package/lib/index.js
CHANGED
|
@@ -74,7 +74,7 @@ const defaultSerializer = {
|
|
|
74
74
|
|
|
75
75
|
|
|
76
76
|
|
|
77
|
-
|
|
77
|
+
|
|
78
78
|
|
|
79
79
|
|
|
80
80
|
const VERSION = 5
|
|
@@ -98,8 +98,9 @@ export class Cache extends EventEmi
|
|
|
98
98
|
#lockTimeout = 10
|
|
99
99
|
#lockMinTimeout = 1
|
|
100
100
|
#lockMaxTimeout = 1_000
|
|
101
|
-
|
|
101
|
+
#flushHandle = null
|
|
102
102
|
#location
|
|
103
|
+
#databaseTimeout = 20
|
|
103
104
|
#database = null
|
|
104
105
|
#getQuery = null
|
|
105
106
|
#delQuery = null
|
|
@@ -205,9 +206,9 @@ export class Cache extends EventEmi
|
|
|
205
206
|
for (let n = 0; opts?.database !== null && opts?.database !== false; n++) {
|
|
206
207
|
try {
|
|
207
208
|
const maxSize = opts?.database?.maxSize ?? 128 * 1024 * 1024
|
|
208
|
-
|
|
209
|
+
this.#databaseTimeout = opts?.database?.timeout ?? 20
|
|
209
210
|
|
|
210
|
-
this.#database ??= new DatabaseSync(location, { timeout })
|
|
211
|
+
this.#database ??= new DatabaseSync(location, { timeout: this.#databaseTimeout })
|
|
211
212
|
|
|
212
213
|
this.#database.exec(`
|
|
213
214
|
PRAGMA journal_mode = WAL;
|
|
@@ -267,6 +268,8 @@ export class Cache extends EventEmi
|
|
|
267
268
|
this.#pageSizeQuery = this.#database.prepare('PRAGMA page_size')
|
|
268
269
|
break
|
|
269
270
|
} catch (err) {
|
|
271
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 100)
|
|
272
|
+
|
|
270
273
|
if (n >= 16) {
|
|
271
274
|
this.#database?.close()
|
|
272
275
|
this.#database = null
|
|
@@ -292,7 +295,7 @@ export class Cache extends EventEmi
|
|
|
292
295
|
dbs.add(this)
|
|
293
296
|
|
|
294
297
|
globalThis.__nxt_cache ??= []
|
|
295
|
-
globalThis.__nxt_cache.push(this)
|
|
298
|
+
globalThis.__nxt_cache.push(new WeakRef(this))
|
|
296
299
|
}
|
|
297
300
|
|
|
298
301
|
get stats() {
|
|
@@ -324,7 +327,16 @@ export class Cache extends EventEmi
|
|
|
324
327
|
}
|
|
325
328
|
}
|
|
326
329
|
|
|
330
|
+
[Symbol.dispose]() {
|
|
331
|
+
this.close()
|
|
332
|
+
}
|
|
333
|
+
|
|
327
334
|
close() {
|
|
335
|
+
while (this.#flushHandle) {
|
|
336
|
+
clearImmediate(this.#flushHandle)
|
|
337
|
+
this.#flush()
|
|
338
|
+
}
|
|
339
|
+
|
|
328
340
|
this.#closed = true
|
|
329
341
|
this.#dedupe.clear()
|
|
330
342
|
dbs.delete(this)
|
|
@@ -343,7 +355,8 @@ export class Cache extends EventEmi
|
|
|
343
355
|
this.#database?.close()
|
|
344
356
|
this.#database = null
|
|
345
357
|
|
|
346
|
-
|
|
358
|
+
globalThis.__nxt_cache = globalThis.__nxt_cache?.filter((ref) => ref.deref() != null) ?? []
|
|
359
|
+
const idx = globalThis.__nxt_cache?.findIndex((ref) => ref.deref() === this) ?? -1
|
|
347
360
|
if (idx !== -1) {
|
|
348
361
|
globalThis.__nxt_cache.splice(idx, 1)
|
|
349
362
|
}
|
|
@@ -381,14 +394,35 @@ export class Cache extends EventEmi
|
|
|
381
394
|
if (this.#closed) {
|
|
382
395
|
throw new Error('cache is closed')
|
|
383
396
|
}
|
|
397
|
+
this.#memory?.purgeStale(Date.now())
|
|
398
|
+
this.#database?.exec('PRAGMA busy_timeout = 5000')
|
|
384
399
|
try {
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
400
|
+
try {
|
|
401
|
+
this.#purgeStaleQuery?.run(Date.now())
|
|
402
|
+
} catch (err) {
|
|
403
|
+
this.#emitError(err )
|
|
404
|
+
}
|
|
405
|
+
try {
|
|
406
|
+
this.#lockPurgeQuery?.run(Date.now() - 3_600_000)
|
|
407
|
+
} catch (err) {
|
|
408
|
+
this.#emitError(err )
|
|
409
|
+
}
|
|
410
|
+
try {
|
|
411
|
+
this.#database?.exec('PRAGMA wal_checkpoint(TRUNCATE)')
|
|
412
|
+
} catch (err) {
|
|
413
|
+
this.#emitError(err )
|
|
414
|
+
}
|
|
415
|
+
try {
|
|
416
|
+
this.#database?.exec('PRAGMA optimize')
|
|
417
|
+
} catch (err) {
|
|
418
|
+
this.#emitError(err )
|
|
419
|
+
}
|
|
420
|
+
} finally {
|
|
421
|
+
try {
|
|
422
|
+
this.#database?.exec(`PRAGMA busy_timeout = ${this.#databaseTimeout}`)
|
|
423
|
+
} catch (err) {
|
|
424
|
+
this.#emitError(err )
|
|
425
|
+
}
|
|
392
426
|
}
|
|
393
427
|
}
|
|
394
428
|
|
|
@@ -541,12 +575,12 @@ export class Cache extends EventEmi
|
|
|
541
575
|
|
|
542
576
|
const ttlValue = Math.min(MAX_DURATION, this.#ttl(value, key) ?? Infinity)
|
|
543
577
|
if (!Number.isFinite(ttlValue) || ttlValue < 0) {
|
|
544
|
-
throw new TypeError('ttl must be
|
|
578
|
+
throw new TypeError('ttl must be undefined, null, or a non-negative finite number')
|
|
545
579
|
}
|
|
546
580
|
|
|
547
581
|
const staleValue = Math.min(MAX_DURATION, this.#stale(value, key) ?? Infinity)
|
|
548
582
|
if (!Number.isFinite(staleValue) || staleValue < 0) {
|
|
549
|
-
throw new TypeError('stale must be
|
|
583
|
+
throw new TypeError('stale must be undefined, null, or a non-negative finite number')
|
|
550
584
|
}
|
|
551
585
|
|
|
552
586
|
const now = Date.now()
|
|
@@ -567,15 +601,20 @@ export class Cache extends EventEmi
|
|
|
567
601
|
)
|
|
568
602
|
this.#memory?.set(key, entry)
|
|
569
603
|
|
|
570
|
-
if (this.#
|
|
604
|
+
if (!this.#flushHandle) {
|
|
571
605
|
this.#memory?.cork()
|
|
572
|
-
setImmediate(this.#flush)
|
|
606
|
+
this.#flushHandle = setImmediate(this.#flush)
|
|
607
|
+
} else if (this.#setBatch.length > 512) {
|
|
608
|
+
clearImmediate(this.#flushHandle)
|
|
609
|
+
this.#flush()
|
|
573
610
|
}
|
|
574
611
|
|
|
575
612
|
this.#setBatch.push({ key, data, ttl, stale })
|
|
576
613
|
}
|
|
577
614
|
|
|
578
615
|
#flush = () => {
|
|
616
|
+
this.#flushHandle = null
|
|
617
|
+
|
|
579
618
|
if (this.#setBatch.length === 0 || this.#closed || this.#database == null) {
|
|
580
619
|
this.#setBatch.length = 0
|
|
581
620
|
this.#memory?.uncork()
|
|
@@ -632,7 +671,7 @@ export class Cache extends EventEmi
|
|
|
632
671
|
|
|
633
672
|
if (this.#setBatch.length > 0) {
|
|
634
673
|
// If we weren't able to flush the entire batch within the time limit, schedule another flush.
|
|
635
|
-
setImmediate(this.#flush)
|
|
674
|
+
this.#flushHandle = setImmediate(this.#flush)
|
|
636
675
|
} else {
|
|
637
676
|
this.#memory?.uncork()
|
|
638
677
|
}
|
|
@@ -715,24 +754,19 @@ export class Cache extends EventEmi
|
|
|
715
754
|
|
|
716
755
|
#updateLockTimeout(duration ) {
|
|
717
756
|
// EMA of mean and variance (Welford-style), clamped to [10ms, 1s].
|
|
718
|
-
// Timeout = mean
|
|
719
|
-
// deviations to accommodate timing variability.
|
|
757
|
+
// Timeout = mean + 3 * stddev to accommodate timing variability.
|
|
720
758
|
// Winsorize input at mean + 5σ so a single extreme outlier can't blow up the stats.
|
|
759
|
+
// Floor the stddev at 20% of the mean so the window never collapses to zero
|
|
760
|
+
// when variance decays (all observations near the mean).
|
|
721
761
|
const alpha = 0.2
|
|
722
|
-
const
|
|
723
|
-
|
|
724
|
-
this.#lockMean + 5 * Math.sqrt(this.#lockVar),
|
|
725
|
-
this.#lockMaxTimeout,
|
|
726
|
-
)
|
|
762
|
+
const stddev = Math.max(this.#lockMean * 0.2, Math.sqrt(this.#lockVar))
|
|
763
|
+
const clamped = Math.min(duration, this.#lockMean + 5 * stddev, this.#lockMaxTimeout)
|
|
727
764
|
const diff = clamped - this.#lockMean
|
|
728
765
|
this.#lockMean += alpha * diff
|
|
729
766
|
this.#lockVar = (1 - alpha) * (this.#lockVar + alpha * diff * diff)
|
|
730
767
|
this.#lockTimeout = Math.max(
|
|
731
768
|
this.#lockMinTimeout,
|
|
732
|
-
Math.min(
|
|
733
|
-
this.#lockMaxTimeout,
|
|
734
|
-
Math.ceil(this.#lockMean * 1.2 + Math.sqrt(this.#lockVar) * 3),
|
|
735
|
-
),
|
|
769
|
+
Math.min(this.#lockMaxTimeout, Math.ceil(this.#lockMean + Math.sqrt(this.#lockVar) * 3)),
|
|
736
770
|
)
|
|
737
771
|
}
|
|
738
772
|
|
package/lib/memory.js
CHANGED
|
@@ -13,11 +13,6 @@
|
|
|
13
13
|
|
|
14
14
|
|
|
15
15
|
|
|
16
|
-
let fastNow = Date.now()
|
|
17
|
-
setInterval(() => {
|
|
18
|
-
fastNow = Date.now()
|
|
19
|
-
}, 1e3).unref()
|
|
20
|
-
|
|
21
16
|
export class MemoryCache {
|
|
22
17
|
#map = new Map()
|
|
23
18
|
#arr = []
|
|
@@ -52,7 +47,7 @@ export class MemoryCache {
|
|
|
52
47
|
this.#map.set(key, entry)
|
|
53
48
|
entry.key = key
|
|
54
49
|
entry.index = this.#arr.push(entry) - 1
|
|
55
|
-
entry.counter =
|
|
50
|
+
entry.counter = performance.now()
|
|
56
51
|
|
|
57
52
|
this.#size += entry.size ?? 0
|
|
58
53
|
this.#count += 1
|
|
@@ -76,7 +71,7 @@ export class MemoryCache {
|
|
|
76
71
|
get(key ) {
|
|
77
72
|
const entry = this.#map.get(key)
|
|
78
73
|
if (entry != null) {
|
|
79
|
-
entry.counter =
|
|
74
|
+
entry.counter = performance.now()
|
|
80
75
|
}
|
|
81
76
|
return entry
|
|
82
77
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nxtedition/cache",
|
|
3
|
-
"version": "2.1.
|
|
3
|
+
"version": "2.1.9",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"main": "lib/index.js",
|
|
6
6
|
"types": "lib/index.d.ts",
|
|
@@ -30,5 +30,5 @@
|
|
|
30
30
|
"tsd": "^0.33.0",
|
|
31
31
|
"typescript": "^5.9.3"
|
|
32
32
|
},
|
|
33
|
-
"gitHead": "
|
|
33
|
+
"gitHead": "0bc928c692badb55bdf3c6928eea7742b8e17732"
|
|
34
34
|
}
|