@nxtedition/cache 2.1.6 → 2.1.8
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 +4 -2
- package/lib/index.js +68 -23
- 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
|
@@ -7,6 +7,7 @@ export interface DatabaseOptions {
|
|
|
7
7
|
}
|
|
8
8
|
export interface LockOptions {
|
|
9
9
|
minTimeout?: number;
|
|
10
|
+
maxTimeout?: number;
|
|
10
11
|
}
|
|
11
12
|
export interface Serializer<V> {
|
|
12
13
|
serialize: (value: V) => Buffer | Uint8Array | string;
|
|
@@ -28,9 +29,9 @@ export type CacheResult<V> = {
|
|
|
28
29
|
async: true;
|
|
29
30
|
};
|
|
30
31
|
declare global {
|
|
31
|
-
var __nxt_cache: {
|
|
32
|
+
var __nxt_cache: WeakRef<{
|
|
32
33
|
stats: Cache['stats'];
|
|
33
|
-
}[];
|
|
34
|
+
}>[];
|
|
34
35
|
}
|
|
35
36
|
export declare class Cache<V = unknown, A extends unknown[] = [string]> extends EventEmitter {
|
|
36
37
|
#private;
|
|
@@ -55,6 +56,7 @@ export declare class Cache<V = unknown, A extends unknown[] = [string]> extends
|
|
|
55
56
|
size: number | undefined;
|
|
56
57
|
} | undefined;
|
|
57
58
|
};
|
|
59
|
+
[Symbol.dispose](): void;
|
|
58
60
|
close(): void;
|
|
59
61
|
get(...args: A): CacheResult<V>;
|
|
60
62
|
peek(...args: A): CacheResult<V>;
|
package/lib/index.js
CHANGED
|
@@ -39,6 +39,7 @@ const dbs = new Set ()
|
|
|
39
39
|
|
|
40
40
|
|
|
41
41
|
|
|
42
|
+
|
|
42
43
|
|
|
43
44
|
|
|
44
45
|
|
|
@@ -73,7 +74,7 @@ const defaultSerializer = {
|
|
|
73
74
|
|
|
74
75
|
|
|
75
76
|
|
|
76
|
-
|
|
77
|
+
|
|
77
78
|
|
|
78
79
|
|
|
79
80
|
const VERSION = 5
|
|
@@ -97,8 +98,9 @@ export class Cache extends EventEmi
|
|
|
97
98
|
#lockTimeout = 10
|
|
98
99
|
#lockMinTimeout = 1
|
|
99
100
|
#lockMaxTimeout = 1_000
|
|
100
|
-
|
|
101
|
+
#flushHandle = null
|
|
101
102
|
#location
|
|
103
|
+
#databaseTimeout = 20
|
|
102
104
|
#database = null
|
|
103
105
|
#getQuery = null
|
|
104
106
|
#delQuery = null
|
|
@@ -177,6 +179,12 @@ export class Cache extends EventEmi
|
|
|
177
179
|
this.#lockTimeout = this.#lockMinTimeout
|
|
178
180
|
this.#lockMean = this.#lockTimeout / 1.2
|
|
179
181
|
}
|
|
182
|
+
if (opts.lock.maxTimeout !== undefined) {
|
|
183
|
+
if (typeof opts.lock.maxTimeout !== 'number') {
|
|
184
|
+
throw new TypeError('lock.maxTimeout must be a number')
|
|
185
|
+
}
|
|
186
|
+
this.#lockMaxTimeout = Math.max(this.#lockMinTimeout, opts.lock.maxTimeout)
|
|
187
|
+
}
|
|
180
188
|
}
|
|
181
189
|
|
|
182
190
|
if (opts?.serializer !== undefined) {
|
|
@@ -198,9 +206,9 @@ export class Cache extends EventEmi
|
|
|
198
206
|
for (let n = 0; opts?.database !== null && opts?.database !== false; n++) {
|
|
199
207
|
try {
|
|
200
208
|
const maxSize = opts?.database?.maxSize ?? 128 * 1024 * 1024
|
|
201
|
-
|
|
209
|
+
this.#databaseTimeout = opts?.database?.timeout ?? 20
|
|
202
210
|
|
|
203
|
-
this.#database ??= new DatabaseSync(location, { timeout })
|
|
211
|
+
this.#database ??= new DatabaseSync(location, { timeout: this.#databaseTimeout })
|
|
204
212
|
|
|
205
213
|
this.#database.exec(`
|
|
206
214
|
PRAGMA journal_mode = WAL;
|
|
@@ -260,6 +268,8 @@ export class Cache extends EventEmi
|
|
|
260
268
|
this.#pageSizeQuery = this.#database.prepare('PRAGMA page_size')
|
|
261
269
|
break
|
|
262
270
|
} catch (err) {
|
|
271
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 100)
|
|
272
|
+
|
|
263
273
|
if (n >= 16) {
|
|
264
274
|
this.#database?.close()
|
|
265
275
|
this.#database = null
|
|
@@ -285,7 +295,7 @@ export class Cache extends EventEmi
|
|
|
285
295
|
dbs.add(this)
|
|
286
296
|
|
|
287
297
|
globalThis.__nxt_cache ??= []
|
|
288
|
-
globalThis.__nxt_cache.push(this)
|
|
298
|
+
globalThis.__nxt_cache.push(new WeakRef(this))
|
|
289
299
|
}
|
|
290
300
|
|
|
291
301
|
get stats() {
|
|
@@ -317,7 +327,16 @@ export class Cache extends EventEmi
|
|
|
317
327
|
}
|
|
318
328
|
}
|
|
319
329
|
|
|
330
|
+
[Symbol.dispose]() {
|
|
331
|
+
this.close()
|
|
332
|
+
}
|
|
333
|
+
|
|
320
334
|
close() {
|
|
335
|
+
while (this.#flushHandle) {
|
|
336
|
+
clearImmediate(this.#flushHandle)
|
|
337
|
+
this.#flush()
|
|
338
|
+
}
|
|
339
|
+
|
|
321
340
|
this.#closed = true
|
|
322
341
|
this.#dedupe.clear()
|
|
323
342
|
dbs.delete(this)
|
|
@@ -336,7 +355,8 @@ export class Cache extends EventEmi
|
|
|
336
355
|
this.#database?.close()
|
|
337
356
|
this.#database = null
|
|
338
357
|
|
|
339
|
-
|
|
358
|
+
globalThis.__nxt_cache = globalThis.__nxt_cache?.filter((ref) => ref.deref() != null) ?? []
|
|
359
|
+
const idx = globalThis.__nxt_cache?.findIndex((ref) => ref.deref() === this) ?? -1
|
|
340
360
|
if (idx !== -1) {
|
|
341
361
|
globalThis.__nxt_cache.splice(idx, 1)
|
|
342
362
|
}
|
|
@@ -374,14 +394,35 @@ export class Cache extends EventEmi
|
|
|
374
394
|
if (this.#closed) {
|
|
375
395
|
throw new Error('cache is closed')
|
|
376
396
|
}
|
|
397
|
+
this.#memory?.purgeStale(Date.now())
|
|
398
|
+
this.#database?.exec('PRAGMA busy_timeout = 5000')
|
|
377
399
|
try {
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
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
|
+
}
|
|
385
426
|
}
|
|
386
427
|
}
|
|
387
428
|
|
|
@@ -534,12 +575,12 @@ export class Cache extends EventEmi
|
|
|
534
575
|
|
|
535
576
|
const ttlValue = Math.min(MAX_DURATION, this.#ttl(value, key) ?? Infinity)
|
|
536
577
|
if (!Number.isFinite(ttlValue) || ttlValue < 0) {
|
|
537
|
-
throw new TypeError('ttl must be
|
|
578
|
+
throw new TypeError('ttl must be undefined, null, or a non-negative finite number')
|
|
538
579
|
}
|
|
539
580
|
|
|
540
581
|
const staleValue = Math.min(MAX_DURATION, this.#stale(value, key) ?? Infinity)
|
|
541
582
|
if (!Number.isFinite(staleValue) || staleValue < 0) {
|
|
542
|
-
throw new TypeError('stale must be
|
|
583
|
+
throw new TypeError('stale must be undefined, null, or a non-negative finite number')
|
|
543
584
|
}
|
|
544
585
|
|
|
545
586
|
const now = Date.now()
|
|
@@ -560,15 +601,20 @@ export class Cache extends EventEmi
|
|
|
560
601
|
)
|
|
561
602
|
this.#memory?.set(key, entry)
|
|
562
603
|
|
|
563
|
-
if (this.#
|
|
604
|
+
if (!this.#flushHandle) {
|
|
564
605
|
this.#memory?.cork()
|
|
565
|
-
setImmediate(this.#flush)
|
|
606
|
+
this.#flushHandle = setImmediate(this.#flush)
|
|
607
|
+
} else if (this.#setBatch.length > 512) {
|
|
608
|
+
clearImmediate(this.#flushHandle)
|
|
609
|
+
this.#flush()
|
|
566
610
|
}
|
|
567
611
|
|
|
568
612
|
this.#setBatch.push({ key, data, ttl, stale })
|
|
569
613
|
}
|
|
570
614
|
|
|
571
615
|
#flush = () => {
|
|
616
|
+
this.#flushHandle = null
|
|
617
|
+
|
|
572
618
|
if (this.#setBatch.length === 0 || this.#closed || this.#database == null) {
|
|
573
619
|
this.#setBatch.length = 0
|
|
574
620
|
this.#memory?.uncork()
|
|
@@ -625,7 +671,7 @@ export class Cache extends EventEmi
|
|
|
625
671
|
|
|
626
672
|
if (this.#setBatch.length > 0) {
|
|
627
673
|
// If we weren't able to flush the entire batch within the time limit, schedule another flush.
|
|
628
|
-
setImmediate(this.#flush)
|
|
674
|
+
this.#flushHandle = setImmediate(this.#flush)
|
|
629
675
|
} else {
|
|
630
676
|
this.#memory?.uncork()
|
|
631
677
|
}
|
|
@@ -711,12 +757,11 @@ export class Cache extends EventEmi
|
|
|
711
757
|
// Timeout = mean * 1.2 + 3 * stddev: 20% base margin plus 3 standard
|
|
712
758
|
// deviations to accommodate timing variability.
|
|
713
759
|
// Winsorize input at mean + 5σ so a single extreme outlier can't blow up the stats.
|
|
760
|
+
// Floor the stddev at half the mean so the window never collapses to zero
|
|
761
|
+
// when variance decays (all observations near the mean).
|
|
714
762
|
const alpha = 0.2
|
|
715
|
-
const
|
|
716
|
-
|
|
717
|
-
this.#lockMean + 5 * Math.sqrt(this.#lockVar),
|
|
718
|
-
this.#lockMaxTimeout,
|
|
719
|
-
)
|
|
763
|
+
const stddev = Math.max(this.#lockMean * 0.5, Math.sqrt(this.#lockVar))
|
|
764
|
+
const clamped = Math.min(duration, this.#lockMean + 5 * stddev, this.#lockMaxTimeout)
|
|
720
765
|
const diff = clamped - this.#lockMean
|
|
721
766
|
this.#lockMean += alpha * diff
|
|
722
767
|
this.#lockVar = (1 - alpha) * (this.#lockVar + alpha * diff * diff)
|
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.8",
|
|
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": "20b962a02ddc295425538b02f1fef1b7b418af70"
|
|
34
34
|
}
|