dialcache 0.1.1
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/LICENSE +21 -0
- package/README.md +357 -0
- package/dist/chunk-2TPHNNE7.js +164 -0
- package/dist/chunk-BF6XTPTS.js +32 -0
- package/dist/chunk-S5VCTJID.js +18 -0
- package/dist/config-B7bGp-Xj.d.cts +205 -0
- package/dist/config-B7bGp-Xj.d.ts +205 -0
- package/dist/index.cjs +1011 -0
- package/dist/index.d.cts +93 -0
- package/dist/index.d.ts +93 -0
- package/dist/index.js +956 -0
- package/dist/node-redis.cjs +329 -0
- package/dist/node-redis.d.cts +52 -0
- package/dist/node-redis.d.ts +52 -0
- package/dist/node-redis.js +127 -0
- package/dist/redis-protocol.cjs +197 -0
- package/dist/redis-protocol.d.cts +10 -0
- package/dist/redis-protocol.d.ts +10 -0
- package/dist/redis-protocol.js +20 -0
- package/dist/valkey-glide.cjs +299 -0
- package/dist/valkey-glide.d.cts +12 -0
- package/dist/valkey-glide.d.ts +12 -0
- package/dist/valkey-glide.js +104 -0
- package/package.json +115 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 Galileo Technologies Inc.
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,357 @@
|
|
|
1
|
+
# DialCache
|
|
2
|
+
|
|
3
|
+
Fine-grained TypeScript caching with explicit enabled contexts, stable key construction, local and Redis TTL caching, runtime rollout controls, request coalescing, Prometheus-ready observability, and Redis watermark-based targeted invalidation.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
pnpm add dialcache
|
|
9
|
+
# Choose a Redis client when using the remote layer:
|
|
10
|
+
pnpm add redis@~4.7.1
|
|
11
|
+
# or
|
|
12
|
+
pnpm add @valkey/valkey-glide@^2.4.2
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
DialCache requires Node.js 20 or Node.js 22 and newer.
|
|
16
|
+
|
|
17
|
+
## Quick start
|
|
18
|
+
|
|
19
|
+
```ts
|
|
20
|
+
import { DialCache, DialCacheKeyConfig } from "dialcache";
|
|
21
|
+
|
|
22
|
+
const dialcache = new DialCache();
|
|
23
|
+
|
|
24
|
+
const getUser = dialcache.cached(
|
|
25
|
+
(userId: string) => db.fetchUser(userId),
|
|
26
|
+
{
|
|
27
|
+
keyType: "user_id",
|
|
28
|
+
useCase: "GetUser",
|
|
29
|
+
cacheKey: (userId) => userId,
|
|
30
|
+
defaultConfig: DialCacheKeyConfig.enabled(60),
|
|
31
|
+
},
|
|
32
|
+
);
|
|
33
|
+
|
|
34
|
+
// Caching is OFF outside an enable() scope (see "Enabled context"), so this runs the fn uncached:
|
|
35
|
+
await getUser("123");
|
|
36
|
+
|
|
37
|
+
// Inside enable(), reads are cached. Enable once at your request boundary (not per call site):
|
|
38
|
+
const user = await dialcache.enable(() => getUser("123"));
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
## Redis-backed TTL cache
|
|
42
|
+
|
|
43
|
+
Register DialCache's native node-redis scripts when creating the client, then pass that client to DialCache:
|
|
44
|
+
|
|
45
|
+
```ts
|
|
46
|
+
import { createClient } from "redis";
|
|
47
|
+
import { DialCache, DialCacheKeyConfig } from "dialcache";
|
|
48
|
+
import { createNodeRedisDialCacheClient, dialcacheRedisScripts } from "dialcache/node-redis";
|
|
49
|
+
|
|
50
|
+
const redisClient = createClient({
|
|
51
|
+
url: process.env.REDIS_URL,
|
|
52
|
+
scripts: dialcacheRedisScripts,
|
|
53
|
+
});
|
|
54
|
+
await redisClient.connect();
|
|
55
|
+
|
|
56
|
+
const dialcache = new DialCache({
|
|
57
|
+
redis: {
|
|
58
|
+
client: createNodeRedisDialCacheClient(redisClient),
|
|
59
|
+
keyPrefix: "dialcache:",
|
|
60
|
+
},
|
|
61
|
+
});
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
The `redis.client` and `redis.createClient` options accept the semantic `DialCacheRedisClient` interface. Node-redis users should register the supplied scripts and wrap their client with `createNodeRedisDialCacheClient` as shown above.
|
|
65
|
+
|
|
66
|
+
Valkey GLIDE users pass an already-created standalone or cluster client to the GLIDE adapter:
|
|
67
|
+
|
|
68
|
+
```ts
|
|
69
|
+
import { GlideClient } from "@valkey/valkey-glide";
|
|
70
|
+
import { DialCache } from "dialcache";
|
|
71
|
+
import { createValkeyGlideDialCacheClient } from "dialcache/valkey-glide";
|
|
72
|
+
|
|
73
|
+
const glideClient = await GlideClient.createClient({
|
|
74
|
+
addresses: [{ host: "127.0.0.1", port: 6379 }],
|
|
75
|
+
});
|
|
76
|
+
const redisClient = createValkeyGlideDialCacheClient(glideClient);
|
|
77
|
+
const dialcache = new DialCache({
|
|
78
|
+
redis: { client: redisClient, keyPrefix: "dialcache:" },
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
function shutdown(): void {
|
|
82
|
+
// Release adapter-owned scripts before closing GLIDE.
|
|
83
|
+
redisClient.dispose();
|
|
84
|
+
glideClient.close();
|
|
85
|
+
}
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
DialCache does not create, connect, or close the underlying Redis client. After outstanding cache operations finish, the GLIDE adapter's `dispose()` method releases its five native `Script` handles; it is idempotent and does not close the wrapped GLIDE connection.
|
|
89
|
+
|
|
90
|
+
When caching is enabled, reads flow through:
|
|
91
|
+
|
|
92
|
+
```text
|
|
93
|
+
local cache -> Redis cache -> fallback function
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
- Local hits return immediately.
|
|
97
|
+
- Local misses try Redis and populate local on a Redis hit.
|
|
98
|
+
- Redis misses call the fallback and write both Redis and local.
|
|
99
|
+
- Redis cache read/write failures are logged, counted in metrics, and fail open; fallback results still return when fallback succeeds. Explicit maintenance calls (`invalidateRemote`, `flushAll`) log/count Redis failures and rethrow them so callers do not assume mutation succeeded.
|
|
100
|
+
- Missing per-layer config disables that layer, records a disabled reason, and falls through to the next layer/fallback.
|
|
101
|
+
|
|
102
|
+
The local layer uses one process-local LRU per `DialCache` instance. It keeps at most 10,000 entries by default across all use cases while retaining each entry's configured local TTL. Set `localMaxSize` to a nonnegative safe integer to change the global entry cap; `0` disables local storage:
|
|
103
|
+
|
|
104
|
+
```ts
|
|
105
|
+
const dialcache = new DialCache({ localMaxSize: 25_000 });
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
The limit counts entries rather than estimating JavaScript object memory. Recently read entries stay resident ahead of less recently used entries when the limit is reached.
|
|
109
|
+
|
|
110
|
+
Node-redis computes each script's SHA, uses `EVALSHA`, and retries with `EVAL` after `NOSCRIPT`. Its cluster client routes scripts by their first key and performs that fallback on the selected shard. The GLIDE adapter uses GLIDE's native `Script` lifecycle and byte decoder; GLIDE routes scripts from their declared keys and the adapter broadcasts `flushAll()` to all cluster primaries. Tracked reads are deliberately routed to primaries so a lagging replica cannot hide an invalidation watermark.
|
|
111
|
+
|
|
112
|
+
You can also provide a lazy factory that returns a script-enabled client:
|
|
113
|
+
|
|
114
|
+
```ts
|
|
115
|
+
const dialcache = new DialCache({
|
|
116
|
+
redis: {
|
|
117
|
+
createClient: async () => {
|
|
118
|
+
const client = createClient({
|
|
119
|
+
url: process.env.REDIS_URL,
|
|
120
|
+
scripts: dialcacheRedisScripts,
|
|
121
|
+
});
|
|
122
|
+
await client.connect();
|
|
123
|
+
return createNodeRedisDialCacheClient(client);
|
|
124
|
+
},
|
|
125
|
+
},
|
|
126
|
+
});
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
The core Redis boundary is the client-agnostic `DialCacheRedisClient` interface. It exchanges serialized values as `string | Buffer` and does not expose client commands or wire encodings. Distinct untracked/tracked read and write Lua sources, the invalidation source, and wire constants are available from `dialcache/redis-protocol`. Custom adapters can use the root-exported `DialCacheRedisPayloadError` and `DialCacheRedisPayloadEncodingError` classes to preserve the standard metrics labels.
|
|
130
|
+
|
|
131
|
+
Redis values use a compact binary frame:
|
|
132
|
+
|
|
133
|
+
```text
|
|
134
|
+
byte 1 format version
|
|
135
|
+
bytes 2-9 Redis-created timestamp in milliseconds (uint64, big-endian)
|
|
136
|
+
byte 10 payload encoding (0 = UTF-8, 1 = raw binary)
|
|
137
|
+
bytes 11... serialized payload
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
Redis's Lua `struct` library packs and unpacks the timestamp. Redis TTL is authoritative, so expiry metadata is not duplicated in the frame. `payload` is produced by the cached function's serializer, or by `JsonSerializer` by default. Custom serializers can return either `string` or `Buffer`; strings are stored as UTF-8 and Buffers are stored byte-for-byte without base64 expansion. Adapters restore the same representation before calling `serializer.load`.
|
|
141
|
+
|
|
142
|
+
## Targeted invalidation and watermarks
|
|
143
|
+
|
|
144
|
+
Mutable Redis-backed use cases can opt into targeted invalidation by setting `trackForInvalidation: true` in the options and calling `dialcache.invalidateRemote(keyType, id)` after writes:
|
|
145
|
+
|
|
146
|
+
```ts
|
|
147
|
+
import { CacheLayer, DialCache, DialCacheKeyConfig } from "dialcache";
|
|
148
|
+
import { createNodeRedisDialCacheClient } from "dialcache/node-redis";
|
|
149
|
+
|
|
150
|
+
const dialcache = new DialCache({ redis: { client: createNodeRedisDialCacheClient(redisClient) } });
|
|
151
|
+
|
|
152
|
+
const getUser = dialcache.cached(
|
|
153
|
+
(userId: string) => db.fetchUser(userId),
|
|
154
|
+
{
|
|
155
|
+
keyType: "user_id",
|
|
156
|
+
useCase: "GetMutableUser",
|
|
157
|
+
cacheKey: (userId) => userId,
|
|
158
|
+
trackForInvalidation: true,
|
|
159
|
+
// Strongly invalidated mutable data should usually disable local cache.
|
|
160
|
+
defaultConfig: new DialCacheKeyConfig({
|
|
161
|
+
ttlSec: { [CacheLayer.REMOTE]: 300 },
|
|
162
|
+
ramp: { [CacheLayer.REMOTE]: 100 },
|
|
163
|
+
}),
|
|
164
|
+
},
|
|
165
|
+
);
|
|
166
|
+
|
|
167
|
+
await updateUser("123", patch);
|
|
168
|
+
await dialcache.invalidateRemote("user_id", "123");
|
|
169
|
+
```
|
|
170
|
+
|
|
171
|
+
Invalidation writes a Redis watermark at `{encodedUrnPrefix:encodedKeyType:encodedId}#watermark`. Tracked Redis cache entries use the same Redis Cluster hash tag, for example `{urn:user_id:123}?locale=en#GetMutableUser:dialcache-frame-v1`, so the value key and watermark key live in the same slot. Key components are percent-encoded before joining so delimiters inside IDs or args cannot collide with delimiters in the key format. Components may not contain `{` or `}` because those characters would corrupt the hash tag.
|
|
172
|
+
|
|
173
|
+
The internal `:dialcache-frame-v1` suffix identifies values written with DialCache's binary protocol. Watermarks are stored as decimal timestamps.
|
|
174
|
+
|
|
175
|
+
A cached Redis value whose Redis-created timestamp is older than or equal to the watermark is treated as stale and refreshed through fallback. `invalidateRemote(keyType, id, futureBufferMs)` sets the watermark to the greater of its existing value and Redis's current time plus the buffer. While that future window is active, fallback results are returned but not written to Redis or local cache.
|
|
176
|
+
|
|
177
|
+
Tracked writes create a baseline watermark and extend its TTL to at least the value TTL plus one minute. Invalidation preserves that lifetime and extends it to cover the future buffer. `DEFAULT_WATERMARK_TTL_SEC` (4 hours) remains a configurable floor rather than a maximum, and reads do not extend watermark lifetime.
|
|
178
|
+
|
|
179
|
+
`futureBufferMs` must be a nonnegative safe integer. Size it to cover the longest interval from invalidation until any fallback that could have read stale source data completes its Redis write. Include source-replication lag, remaining fallback work, `serializer.dump`, Redis client queue/network latency, script execution, and a safety margin. Invalidate after the source mutation commits. The buffer prevents stale fallback results from being cached under those assumptions; it does not itself force the current fallback to read from an authoritative source.
|
|
180
|
+
|
|
181
|
+
Local cache limitation: targeted invalidation is enforced by Redis watermarks. Existing local cache hits are not synchronously invalidated across processes, so strongly invalidated mutable data should disable the local layer (or use very short local TTLs only when stale reads are acceptable).
|
|
182
|
+
|
|
183
|
+
## Runtime config and ramp controls
|
|
184
|
+
|
|
185
|
+
Every cached function can provide a per-use-case `defaultConfig`; a `cacheConfigProvider` can override it at runtime. If the provider returns `null`, DialCache falls back to the cached function's `defaultConfig`. If neither exists, or a layer's TTL/ramp is missing or disabled, only that layer is skipped.
|
|
186
|
+
|
|
187
|
+
`cacheConfigProvider` is called for every enabled cached-function invocation before DialCache checks local or Redis. Keep it cheap, cache any remote/config-store reads inside the provider, and avoid work that would erase the benefit of a cache hit.
|
|
188
|
+
|
|
189
|
+
```ts
|
|
190
|
+
import { CacheLayer, DialCache, DialCacheKeyConfig } from "dialcache";
|
|
191
|
+
|
|
192
|
+
const dialcache = new DialCache({
|
|
193
|
+
cacheConfigProvider: async (key) => {
|
|
194
|
+
if (key.useCase === "GetUser") {
|
|
195
|
+
return new DialCacheKeyConfig({
|
|
196
|
+
ttlSec: { [CacheLayer.LOCAL]: 30, [CacheLayer.REMOTE]: 300 },
|
|
197
|
+
ramp: { [CacheLayer.LOCAL]: 100, [CacheLayer.REMOTE]: 25 },
|
|
198
|
+
});
|
|
199
|
+
}
|
|
200
|
+
return null; // use the cached function's defaultConfig
|
|
201
|
+
},
|
|
202
|
+
rampSampler: ({ key, layer }) => deterministicPercentFor(`${key.urn}:${layer}`),
|
|
203
|
+
});
|
|
204
|
+
```
|
|
205
|
+
|
|
206
|
+
`ramp` values are percentages from 0 to 100. `0` disables the layer, `100` enables it, and intermediate values use `rampSampler`; the default sampler is deterministic by cache key and layer, so the same key is consistently sampled in or out of a partial rollout. Provider errors fail open and execute the fallback function.
|
|
207
|
+
|
|
208
|
+
## Request coalescing
|
|
209
|
+
|
|
210
|
+
When caching is enabled and a call misses local cache, concurrent callers for the same cache key share the same in-flight cache work. With Redis configured, the leader runs the Redis read and, on miss, the fallback/cache write; followers await that result. Local-only misses share the leader's fallback/cache write. This protects Redis and the source of truth from a thundering herd on hot keys.
|
|
211
|
+
|
|
212
|
+
Coalescing only applies after a real cache layer is active. Calls outside `enable()` are true pass-through, and calls where every layer is disabled by missing config, invalid TTL, or ramp are true pass-through.
|
|
213
|
+
|
|
214
|
+
Because coalescing is keyed by `cacheKey`, concurrent calls with the same key share the leader's execution. Any argument ignored by `cacheKey` must be safe to share this way; include inputs such as locale, auth context, or cancellation behavior in the key when they can change the returned value or whether the underlying function should run separately.
|
|
215
|
+
|
|
216
|
+
```ts
|
|
217
|
+
const getUser = dialcache.cached(
|
|
218
|
+
(userId: string) => db.fetchUser(userId),
|
|
219
|
+
{
|
|
220
|
+
keyType: "user_id",
|
|
221
|
+
useCase: "GetUser",
|
|
222
|
+
cacheKey: (userId) => userId,
|
|
223
|
+
defaultConfig: DialCacheKeyConfig.enabled(60),
|
|
224
|
+
},
|
|
225
|
+
);
|
|
226
|
+
```
|
|
227
|
+
|
|
228
|
+
## Enabled context
|
|
229
|
+
|
|
230
|
+
Caching is **off by default** and only active inside a `dialcache.enable(...)` scope. This is deliberate: it lets you turn caching **off in write paths** so a stale read can't be cached around a write. DialCache uses Node `AsyncLocalStorage` to keep enabled state scoped to the current asynchronous call chain.
|
|
231
|
+
|
|
232
|
+
**Enable once at your request boundary** (e.g. a middleware that wraps read-request handling) so individual call sites don't each need it; wrap mutation handlers in `disable()`:
|
|
233
|
+
|
|
234
|
+
```ts
|
|
235
|
+
await dialcache.enable(async () => {
|
|
236
|
+
await getUser("123"); // cached
|
|
237
|
+
|
|
238
|
+
await dialcache.disable(async () => {
|
|
239
|
+
await updateUser("123", patch); // reads here are uncached
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
await getUser("123"); // cached again
|
|
243
|
+
});
|
|
244
|
+
```
|
|
245
|
+
|
|
246
|
+
- Default is disabled — a cached function called **outside** any `enable()` scope simply runs uncached (no error), so wrap your read paths to actually cache.
|
|
247
|
+
- Enabled state is async-scope-local, not process-global.
|
|
248
|
+
- Nested `enable` / `disable` scopes restore the previous behavior when the callback completes.
|
|
249
|
+
|
|
250
|
+
## Keys, ids, and extra dimensions
|
|
251
|
+
|
|
252
|
+
`cached(fn, options)` wraps a function; the wrapped callable has the same parameters and always returns a `Promise`. The cache key comes from a required `cacheKey` selector whose parameters are inferred from `fn`. Return a bare id, or return `{ id, args }` to extract a field or add secondary dimensions:
|
|
253
|
+
|
|
254
|
+
The `cacheKey` selector is the value identity contract. It must include every input dimension that can affect the returned value; otherwise distinct calls can reuse the same cached value or share the same in-flight fallback through request coalescing.
|
|
255
|
+
|
|
256
|
+
```ts
|
|
257
|
+
const searchPosts = dialcache.cached(
|
|
258
|
+
(userId: string, page: number, filter: string) => db.searchPosts(userId, page, filter),
|
|
259
|
+
{
|
|
260
|
+
keyType: "user_id",
|
|
261
|
+
useCase: "SearchPosts",
|
|
262
|
+
cacheKey: (userId, page, filter) => ({ id: userId, args: { page, filter } }),
|
|
263
|
+
defaultConfig: DialCacheKeyConfig.enabled(60),
|
|
264
|
+
},
|
|
265
|
+
);
|
|
266
|
+
await dialcache.enable(() => searchPosts("u1", 2, "active"));
|
|
267
|
+
```
|
|
268
|
+
|
|
269
|
+
- **`keyType` + `id` is the invalidation unit for tracked Redis entries.** `dialcache.invalidateRemote("user_id", "123")` writes one watermark for that user; any `trackForInvalidation` Redis entry with the same `keyType` and `id` is refreshed across all `args` variants when Redis is read. Existing local hits follow the local-cache limitation above, and untracked Redis entries do not consult the watermark. `useCase` identifies the individual cache (it's the metrics label and part of the stored key).
|
|
270
|
+
- **`args` are part of the cache key** — different `args` produce different entries — but invalidation is by `id` only.
|
|
271
|
+
- **Non-key inputs** (for example a db handle) are simply parameters the `cacheKey` selector ignores. They still reach `fn` for non-coalesced executions, but concurrent same-key cache misses share the leader's execution, so do not ignore values like `AbortSignal`, auth context, locale, or other request-scoped inputs unless sharing one result is correct.
|
|
272
|
+
- **Methods:** pass `obj.method.bind(obj)` (or `(...a) => obj.method(...a)`) — a bare `obj.method` reference loses `this`.
|
|
273
|
+
|
|
274
|
+
## Metrics
|
|
275
|
+
|
|
276
|
+
DialCache registers Prometheus metrics by default via `prom-client`:
|
|
277
|
+
|
|
278
|
+
| Metric | Type | Labels | Description |
|
|
279
|
+
| --- | --- | --- | --- |
|
|
280
|
+
| `dialcache_request_counter` | Counter | `use_case`, `key_type`, `layer` | Cache-layer requests that reached an enabled layer |
|
|
281
|
+
| `dialcache_miss_counter` | Counter | `use_case`, `key_type`, `layer` | Cache misses |
|
|
282
|
+
| `dialcache_disabled_counter` | Counter | `use_case`, `key_type`, `layer`, `reason` | Cache skips (`context`, `missing_config`, `invalid_ttl`, `ramped_down`, `config_error`) |
|
|
283
|
+
| `dialcache_error_counter` | Counter | `use_case`, `key_type`, `layer`, `error`, `in_fallback` | Cache/fallback errors, with `in_fallback` separating cache plumbing failures from application fallback failures |
|
|
284
|
+
| `dialcache_invalidation_counter` | Counter | `key_type`, `layer` | Invalidation calls for the layers touched today |
|
|
285
|
+
| `dialcache_coalesced_counter` | Counter | `use_case`, `key_type` | Requests that awaited active in-flight cache work |
|
|
286
|
+
| `dialcache_get_timer` | Histogram | `use_case`, `key_type`, `layer` | Cache get latency in seconds |
|
|
287
|
+
| `dialcache_fallback_timer` | Histogram | `use_case`, `key_type`, `layer` | Time spent in the underlying function |
|
|
288
|
+
| `dialcache_serialization_timer` | Histogram | `use_case`, `key_type`, `layer`, `operation` | Redis serializer dump/load latency |
|
|
289
|
+
| `dialcache_size_histogram` | Histogram | `use_case`, `key_type`, `layer` | Serialized Redis payload size in bytes |
|
|
290
|
+
|
|
291
|
+
The `layer` label is usually `local` or `remote`. Disabled-context, key-construction, and config-provider failures use `noop` because no cache layer was reached.
|
|
292
|
+
|
|
293
|
+
Use a custom registry or prefix when embedding DialCache in an app with its own metrics endpoint:
|
|
294
|
+
|
|
295
|
+
```ts
|
|
296
|
+
import { Registry } from "prom-client";
|
|
297
|
+
import { DialCache } from "dialcache";
|
|
298
|
+
|
|
299
|
+
const registry = new Registry();
|
|
300
|
+
const dialcache = new DialCache({
|
|
301
|
+
metricsRegistry: registry,
|
|
302
|
+
metricsPrefix: "myapp_", // myapp_dialcache_request_counter, etc.
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
app.get("/metrics", async (_req, res) => {
|
|
306
|
+
res.type(registry.contentType).send(await registry.metrics());
|
|
307
|
+
});
|
|
308
|
+
```
|
|
309
|
+
|
|
310
|
+
For non-Prometheus telemetry, inject a `DialCacheMetricsAdapter` through `new DialCache({ metrics })`. Pass `metrics: false` to disable metrics entirely. DialCache reuses existing collectors in a registry so repeated instances with the same prefix do not throw duplicate-registration errors.
|
|
311
|
+
|
|
312
|
+
## Current scope
|
|
313
|
+
|
|
314
|
+
Included:
|
|
315
|
+
|
|
316
|
+
- Local TTL/LRU cache with a global entry-count bound
|
|
317
|
+
- Redis TTL cache
|
|
318
|
+
- Local → Redis → fallback read-through chain
|
|
319
|
+
- Lazy Redis client factory support
|
|
320
|
+
- Lua-backed Redis reads and writes with Redis-generated timestamps
|
|
321
|
+
- Versioned binary Redis frames for UTF-8 and Buffer serializer output
|
|
322
|
+
- Native node-redis script registration with automatic `NOSCRIPT` recovery
|
|
323
|
+
- Native Valkey GLIDE adapter with explicit script disposal and automatic script-cache recovery
|
|
324
|
+
- Standalone Redis, Valkey, and Redis Cluster support
|
|
325
|
+
- JSON and custom serializer support for Redis values
|
|
326
|
+
- Duplicate and reserved use-case validation
|
|
327
|
+
- `invalidateRemote` for Redis watermark invalidation and `flushAll` across configured layers
|
|
328
|
+
- Fail-open behavior for key/config/cache read-write errors; maintenance mutations surface failures
|
|
329
|
+
- Runtime config provider with fallback to cached-function `defaultConfig`
|
|
330
|
+
- Per-layer TTL and ramp controls
|
|
331
|
+
- Deterministic default ramp sampler with injectable override hooks
|
|
332
|
+
- Missing config disables only the relevant layer and falls through
|
|
333
|
+
- Prometheus metrics with duplicate-registration safety
|
|
334
|
+
- Custom metrics adapter/registry/prefix hooks
|
|
335
|
+
- Cache-vs-fallback error classification through the `in_fallback` label
|
|
336
|
+
- Serialization latency and cached payload size metrics for Redis values
|
|
337
|
+
- Logger injection for cache operational failures
|
|
338
|
+
- `trackForInvalidation` on cached functions
|
|
339
|
+
- `invalidateRemote(keyType, id, futureBufferMs)` Redis watermark API
|
|
340
|
+
- Redis Cluster hash-tagged value/watermark keys for invalidation-tracked entries
|
|
341
|
+
- Dynamically extended watermark TTL with a configurable `DEFAULT_WATERMARK_TTL_SEC` floor
|
|
342
|
+
- Future-buffer behavior that avoids cache writes during active invalidation windows
|
|
343
|
+
- Request coalescing for active cache work after local misses
|
|
344
|
+
|
|
345
|
+
Not included yet:
|
|
346
|
+
|
|
347
|
+
- Framework middleware helpers/integrations
|
|
348
|
+
- `cachedObject`
|
|
349
|
+
- Expanded examples
|
|
350
|
+
|
|
351
|
+
## Releasing
|
|
352
|
+
|
|
353
|
+
Publishing is driven manually from the `Release` workflow in GitHub Actions. Run it from `main`; the workflow rejects any other ref or a stale main commit. It calculates the next patch version from the highest stable `vX.Y.Z` tag, using the `package.json` version as the seed when no release tag exists. For example, the current `0.1.0` seed produces the first `v0.1.1` release, followed by `v0.1.2`.
|
|
354
|
+
|
|
355
|
+
Every release is a patch bump. Semantic PR-title prefixes such as `feat:`, `fix:`, and `docs:` remain in the generated release notes but do not change the version magnitude. After validating, building, and package-testing the release artifact, the workflow creates a draft GitHub release, publishes the public npm package with provenance, and publishes the GitHub release only after npm succeeds.
|
|
356
|
+
|
|
357
|
+
The first publish requires a granular npm token in the repository's `NPM_TOKEN` secret because npm trusted publishing can only be configured after the package exists. After the bootstrap release, configure `lan17/DialCache` and `release.yaml` as the package's trusted GitHub publisher, then remove the long-lived token.
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
// src/internal/redis-scripts.ts
|
|
2
|
+
var REDIS_FRAME_VERSION = 1;
|
|
3
|
+
var REDIS_ENCODING_UTF8 = 0;
|
|
4
|
+
var REDIS_ENCODING_BINARY = 1;
|
|
5
|
+
var WATERMARK_TTL_MARGIN_MS = 6e4;
|
|
6
|
+
var PARSE_WATERMARK_LUA = String.raw`local function parse_watermark(raw)
|
|
7
|
+
if not string.match(raw, "^%d+$") and not string.match(raw, "^%d+%.%d+$") then
|
|
8
|
+
return nil
|
|
9
|
+
end
|
|
10
|
+
local value = tonumber(raw)
|
|
11
|
+
if not value or value >= math.huge then
|
|
12
|
+
return nil
|
|
13
|
+
end
|
|
14
|
+
return value
|
|
15
|
+
end`;
|
|
16
|
+
var CEIL_FINITE_NUMBER_LUA = String.raw`local function ceil_finite_number(raw)
|
|
17
|
+
local value = tonumber(raw)
|
|
18
|
+
if not value or value ~= value or value >= math.huge or value <= -math.huge then
|
|
19
|
+
return nil
|
|
20
|
+
end
|
|
21
|
+
return math.ceil(value)
|
|
22
|
+
end`;
|
|
23
|
+
var READ_FRAME_LUA = String.raw`local value = redis.call("GET", KEYS[1])
|
|
24
|
+
if not value or string.len(value) < 10 then
|
|
25
|
+
return false
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
if string.byte(value, 1) ~= ${REDIS_FRAME_VERSION} then
|
|
29
|
+
return false
|
|
30
|
+
end`;
|
|
31
|
+
var RETURN_PAYLOAD_LUA = String.raw`return string.sub(value, 10)`;
|
|
32
|
+
var VALIDATE_WRITE_ARGUMENTS_LUA = String.raw`local cache_ttl_ms = ceil_finite_number(ARGV[1])
|
|
33
|
+
local encoding = tonumber(ARGV[2])
|
|
34
|
+
if not cache_ttl_ms or cache_ttl_ms <= 0 then
|
|
35
|
+
return redis.error_reply("ERR invalid DialCache TTL")
|
|
36
|
+
end
|
|
37
|
+
if not encoding or (encoding ~= ${REDIS_ENCODING_UTF8} and encoding ~= ${REDIS_ENCODING_BINARY}) then
|
|
38
|
+
return redis.error_reply("ERR invalid DialCache payload encoding")
|
|
39
|
+
end`;
|
|
40
|
+
var REDIS_TIME_LUA = String.raw`local redis_time = redis.call("TIME")
|
|
41
|
+
local now_ms = tonumber(redis_time[1]) * 1000 + math.floor(tonumber(redis_time[2]) / 1000)`;
|
|
42
|
+
var WRITE_FRAME_LUA = String.raw`local frame = string.char(${REDIS_FRAME_VERSION})
|
|
43
|
+
.. struct.pack(">I8", now_ms)
|
|
44
|
+
.. string.char(encoding)
|
|
45
|
+
.. ARGV[3]
|
|
46
|
+
redis.call("SET", KEYS[1], frame, "PX", cache_ttl_ms)`;
|
|
47
|
+
var READ_CACHE_SCRIPT = [READ_FRAME_LUA, RETURN_PAYLOAD_LUA].join("\n\n");
|
|
48
|
+
var READ_TRACKED_CACHE_SCRIPT = [
|
|
49
|
+
PARSE_WATERMARK_LUA,
|
|
50
|
+
READ_FRAME_LUA,
|
|
51
|
+
String.raw`local raw_watermark = redis.call("GET", KEYS[2])
|
|
52
|
+
if not raw_watermark then
|
|
53
|
+
return false
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
local watermark = parse_watermark(raw_watermark)
|
|
57
|
+
if not watermark then
|
|
58
|
+
return false
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
local created_at = struct.unpack(">I8", string.sub(value, 2, 9))
|
|
62
|
+
if created_at <= watermark then
|
|
63
|
+
return false
|
|
64
|
+
end`,
|
|
65
|
+
RETURN_PAYLOAD_LUA
|
|
66
|
+
].join("\n\n");
|
|
67
|
+
var WRITE_CACHE_SCRIPT = [
|
|
68
|
+
CEIL_FINITE_NUMBER_LUA,
|
|
69
|
+
VALIDATE_WRITE_ARGUMENTS_LUA,
|
|
70
|
+
REDIS_TIME_LUA,
|
|
71
|
+
WRITE_FRAME_LUA,
|
|
72
|
+
"return 1"
|
|
73
|
+
].join("\n\n");
|
|
74
|
+
var WRITE_TRACKED_CACHE_SCRIPT = [
|
|
75
|
+
PARSE_WATERMARK_LUA,
|
|
76
|
+
CEIL_FINITE_NUMBER_LUA,
|
|
77
|
+
VALIDATE_WRITE_ARGUMENTS_LUA,
|
|
78
|
+
String.raw`local watermark_ttl_floor_ms = ceil_finite_number(ARGV[4])
|
|
79
|
+
if not watermark_ttl_floor_ms or watermark_ttl_floor_ms <= 0 then
|
|
80
|
+
return redis.error_reply("ERR invalid DialCache watermark TTL")
|
|
81
|
+
end`,
|
|
82
|
+
REDIS_TIME_LUA,
|
|
83
|
+
String.raw`local raw_watermark = redis.call("GET", KEYS[2])
|
|
84
|
+
local watermark = 0
|
|
85
|
+
if raw_watermark then
|
|
86
|
+
watermark = parse_watermark(raw_watermark)
|
|
87
|
+
if not watermark then
|
|
88
|
+
return redis.error_reply("ERR invalid DialCache watermark")
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
if watermark >= now_ms then
|
|
93
|
+
return 0
|
|
94
|
+
end`,
|
|
95
|
+
WRITE_FRAME_LUA,
|
|
96
|
+
String.raw`local desired_ttl_ms = math.max(watermark_ttl_floor_ms, cache_ttl_ms + ${WATERMARK_TTL_MARGIN_MS})
|
|
97
|
+
if not raw_watermark then
|
|
98
|
+
redis.call("SET", KEYS[2], "0", "PX", desired_ttl_ms)
|
|
99
|
+
else
|
|
100
|
+
local current_ttl_ms = redis.call("PTTL", KEYS[2])
|
|
101
|
+
if current_ttl_ms == -2 then
|
|
102
|
+
redis.call("SET", KEYS[2], raw_watermark, "PX", desired_ttl_ms)
|
|
103
|
+
elseif current_ttl_ms ~= -1 and current_ttl_ms < desired_ttl_ms then
|
|
104
|
+
redis.call("PEXPIRE", KEYS[2], desired_ttl_ms)
|
|
105
|
+
end
|
|
106
|
+
end`,
|
|
107
|
+
"return 1"
|
|
108
|
+
].join("\n\n");
|
|
109
|
+
var INVALIDATE_CACHE_SCRIPT = [
|
|
110
|
+
PARSE_WATERMARK_LUA,
|
|
111
|
+
CEIL_FINITE_NUMBER_LUA,
|
|
112
|
+
String.raw`local future_buffer_ms = ceil_finite_number(ARGV[1])
|
|
113
|
+
local watermark_ttl_floor_ms = ceil_finite_number(ARGV[2])
|
|
114
|
+
if not future_buffer_ms or future_buffer_ms < 0 then
|
|
115
|
+
return redis.error_reply("ERR invalid DialCache future buffer")
|
|
116
|
+
end
|
|
117
|
+
if not watermark_ttl_floor_ms or watermark_ttl_floor_ms <= 0 then
|
|
118
|
+
return redis.error_reply("ERR invalid DialCache watermark TTL")
|
|
119
|
+
end`,
|
|
120
|
+
REDIS_TIME_LUA,
|
|
121
|
+
String.raw`local proposed_watermark = now_ms + future_buffer_ms
|
|
122
|
+
local raw_watermark = redis.call("GET", KEYS[1])
|
|
123
|
+
local current_watermark = 0
|
|
124
|
+
|
|
125
|
+
if raw_watermark then
|
|
126
|
+
local parsed_watermark = parse_watermark(raw_watermark)
|
|
127
|
+
if parsed_watermark then
|
|
128
|
+
current_watermark = parsed_watermark
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
local watermark = math.ceil(math.max(current_watermark, proposed_watermark))
|
|
133
|
+
local current_ttl_ms = -2
|
|
134
|
+
if raw_watermark then
|
|
135
|
+
current_ttl_ms = redis.call("PTTL", KEYS[1])
|
|
136
|
+
end
|
|
137
|
+
local desired_ttl_ms = math.max(
|
|
138
|
+
watermark_ttl_floor_ms,
|
|
139
|
+
future_buffer_ms + ${WATERMARK_TTL_MARGIN_MS},
|
|
140
|
+
watermark - now_ms + ${WATERMARK_TTL_MARGIN_MS}
|
|
141
|
+
)
|
|
142
|
+
if current_ttl_ms > desired_ttl_ms then
|
|
143
|
+
desired_ttl_ms = current_ttl_ms
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
local encoded_watermark = string.format("%.0f", watermark)
|
|
147
|
+
if current_ttl_ms == -1 then
|
|
148
|
+
redis.call("SET", KEYS[1], encoded_watermark)
|
|
149
|
+
else
|
|
150
|
+
redis.call("SET", KEYS[1], encoded_watermark, "PX", desired_ttl_ms)
|
|
151
|
+
end`,
|
|
152
|
+
"return 1"
|
|
153
|
+
].join("\n\n");
|
|
154
|
+
|
|
155
|
+
export {
|
|
156
|
+
REDIS_FRAME_VERSION,
|
|
157
|
+
REDIS_ENCODING_UTF8,
|
|
158
|
+
REDIS_ENCODING_BINARY,
|
|
159
|
+
READ_CACHE_SCRIPT,
|
|
160
|
+
READ_TRACKED_CACHE_SCRIPT,
|
|
161
|
+
WRITE_CACHE_SCRIPT,
|
|
162
|
+
WRITE_TRACKED_CACHE_SCRIPT,
|
|
163
|
+
INVALIDATE_CACHE_SCRIPT
|
|
164
|
+
};
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import {
|
|
2
|
+
DialCacheRedisPayloadEncodingError,
|
|
3
|
+
DialCacheRedisPayloadError
|
|
4
|
+
} from "./chunk-S5VCTJID.js";
|
|
5
|
+
import {
|
|
6
|
+
REDIS_ENCODING_BINARY,
|
|
7
|
+
REDIS_ENCODING_UTF8
|
|
8
|
+
} from "./chunk-2TPHNNE7.js";
|
|
9
|
+
|
|
10
|
+
// src/internal/redis-payload.ts
|
|
11
|
+
function redisPayloadEncoding(value) {
|
|
12
|
+
return Buffer.isBuffer(value) ? REDIS_ENCODING_BINARY : REDIS_ENCODING_UTF8;
|
|
13
|
+
}
|
|
14
|
+
function decodeRedisPayload(raw) {
|
|
15
|
+
if (raw.length === 0) {
|
|
16
|
+
throw new DialCacheRedisPayloadError("Invalid DialCache Redis payload");
|
|
17
|
+
}
|
|
18
|
+
const encoding = raw[0];
|
|
19
|
+
const payload = raw.subarray(1);
|
|
20
|
+
if (encoding === REDIS_ENCODING_UTF8) {
|
|
21
|
+
return payload.toString("utf8");
|
|
22
|
+
}
|
|
23
|
+
if (encoding === REDIS_ENCODING_BINARY) {
|
|
24
|
+
return payload;
|
|
25
|
+
}
|
|
26
|
+
throw new DialCacheRedisPayloadEncodingError("Invalid DialCache Redis payload encoding");
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export {
|
|
30
|
+
redisPayloadEncoding,
|
|
31
|
+
decodeRedisPayload
|
|
32
|
+
};
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
// src/redis-client.ts
|
|
2
|
+
var DialCacheRedisPayloadError = class extends Error {
|
|
3
|
+
constructor(message) {
|
|
4
|
+
super(message);
|
|
5
|
+
this.name = "DialCacheRedisPayloadError";
|
|
6
|
+
}
|
|
7
|
+
};
|
|
8
|
+
var DialCacheRedisPayloadEncodingError = class extends Error {
|
|
9
|
+
constructor(message) {
|
|
10
|
+
super(message);
|
|
11
|
+
this.name = "DialCacheRedisPayloadEncodingError";
|
|
12
|
+
}
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
export {
|
|
16
|
+
DialCacheRedisPayloadError,
|
|
17
|
+
DialCacheRedisPayloadEncodingError
|
|
18
|
+
};
|