@checkstack/cache-utils 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +83 -0
- package/package.json +23 -0
- package/src/cached-scope.test.ts +342 -0
- package/src/cached-scope.ts +213 -0
- package/src/index.ts +1 -0
- package/tsconfig.json +6 -0
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
# @checkstack/cache-utils
|
|
2
|
+
|
|
3
|
+
## 0.2.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- 8d1ef12: ## Per-entity caching with single-flight + safe invalidation across the dashboard hot paths
|
|
8
|
+
|
|
9
|
+
### `@checkstack/cache-api`
|
|
10
|
+
|
|
11
|
+
- **Breaking** for backend implementors: `CacheProvider` now requires `deleteByPrefix(prefix: string): Promise<number>` for family-level invalidation. The in-memory provider implements it; downstream providers (Redis, etc.) must add it before upgrading.
|
|
12
|
+
- `createScopedCache` forwards `deleteByPrefix` and keeps prefixes scoped to the calling plugin.
|
|
13
|
+
|
|
14
|
+
### `@checkstack/cache-utils` (new package)
|
|
15
|
+
|
|
16
|
+
High-level read-through caching helpers built on `CacheProvider`:
|
|
17
|
+
|
|
18
|
+
- `createCachedScope({ cacheManager, pluginId })` returns a scope with `wrap`, `wrapMany`, `invalidate`, and `invalidatePrefix`.
|
|
19
|
+
- **Single-flight**: concurrent cache misses for the same key share one loader.
|
|
20
|
+
- **Per-entity bulk caching** via `wrapMany` so list/bulk RPCs cache by id rather than by the full input shape — overlapping callers share entries and invalidation stays exact.
|
|
21
|
+
- **Race-safe invalidation** via per-key epoch counters: a loader started before a mutation cannot repopulate the cache with stale data after the mutation invalidates it. The mutation invariant is `db.write → cache.invalidate (await) → signals.emit`.
|
|
22
|
+
- Cache failures fall through to the loader so a cache outage cannot break reads.
|
|
23
|
+
|
|
24
|
+
### `@checkstack/backend`
|
|
25
|
+
|
|
26
|
+
- The internal null `CacheProvider` (used when no cache backend is configured) now implements the new `deleteByPrefix` method as a no-op. Patch bump only — no behavior change for existing callers.
|
|
27
|
+
|
|
28
|
+
### `@checkstack/healthcheck-backend`
|
|
29
|
+
|
|
30
|
+
- `getSystemHealthStatus` and `getBulkSystemHealthStatus` now read through a per-system cache (`healthcheck:status:<systemId>`), eliminating N database queries per dashboard refresh for unchanged systems.
|
|
31
|
+
- Mutation paths (configuration CRUD, system associations, satellite ingest, queue-driven check runs, system/satellite removal hooks) invalidate affected keys before broadcasting their signals so frontend refetches always observe fresh data.
|
|
32
|
+
|
|
33
|
+
### `@checkstack/incident-backend`
|
|
34
|
+
|
|
35
|
+
- `listIncidents`, `getIncident`, `getIncidentsForSystem`, and `getBulkIncidentsForSystems` now read through a scoped cache:
|
|
36
|
+
- per-incident at `incident:<id>`
|
|
37
|
+
- per-system at `system:<systemId>`
|
|
38
|
+
- per-filter-shape at `list:<stable-stringify(filters)>` for the few list shapes the dashboard polls
|
|
39
|
+
- Mutations (`createIncident`, `updateIncident`, `addUpdate`, `resolveIncident`, `deleteIncident`) invalidate the incident, every affected system, and every cached list before broadcasting `INCIDENT_UPDATED`.
|
|
40
|
+
- The catalog `systemDeleted` cleanup hook drops that system's cached entries.
|
|
41
|
+
|
|
42
|
+
### `@checkstack/maintenance-backend`
|
|
43
|
+
|
|
44
|
+
- `listMaintenances`, `getMaintenance`, `getMaintenancesForSystem`, and `getBulkMaintenancesForSystems` use the same per-entity / per-system / per-filter-shape pattern as incidents.
|
|
45
|
+
- Mutations (`createMaintenance`, `updateMaintenance`, `addUpdate`, `closeMaintenance`, `deleteMaintenance`) invalidate before broadcasting `MAINTENANCE_UPDATED`.
|
|
46
|
+
|
|
47
|
+
### `@checkstack/catalog-backend`
|
|
48
|
+
|
|
49
|
+
- Topology reads (`getEntities`, `getSystems`, `getSystem`, `getGroups`, `getSystemGroupIds`) cache under the `entity:` family (25s TTL).
|
|
50
|
+
- Views (`getViews`) and per-system contacts (`getSystemContacts`) cache in their own families.
|
|
51
|
+
- System / group / membership mutations drop the entire `entity:` family (every reader joins the same tables); view and contact mutations drop only their respective scopes.
|
|
52
|
+
|
|
53
|
+
### `@checkstack/slo-backend`
|
|
54
|
+
|
|
55
|
+
- `listObjectives`, `getObjective`, `getObjectivesForSystem`, and `getBulkObjectivesForSystems` cache results including the expensive `engine.computeStatus` output.
|
|
56
|
+
- Per-entity caching for the bulk handler so dashboards with overlapping system sets share entries.
|
|
57
|
+
- Mutations (`createObjective`, `updateObjective`, `deleteObjective`) invalidate before broadcasting `SLO_STATUS_CHANGED`.
|
|
58
|
+
|
|
59
|
+
### `@checkstack/anomaly-backend`
|
|
60
|
+
|
|
61
|
+
- New `router-cache.ts` adds a cache scope distinct from the existing detector baseline cache, keyed by stable filter hash.
|
|
62
|
+
- `getAnomalies` and `getAnomalyBaselines` cache through this scope (15s TTL).
|
|
63
|
+
- The detector invalidates the router cache before broadcasting `ANOMALY_STATE_CHANGED` on every state transition (suspicious/anomaly/recovered).
|
|
64
|
+
- Config mutations also invalidate.
|
|
65
|
+
|
|
66
|
+
### `@checkstack/notification-backend`
|
|
67
|
+
|
|
68
|
+
- `getUnreadCount`, `getNotifications`, and `getSubscriptions` cache per-user.
|
|
69
|
+
- `markAsRead`, `deleteNotification`, `notifyUsers`, and `notifyGroups` invalidate every affected user's cache before sending realtime signals to that user.
|
|
70
|
+
- `subscribe` and `unsubscribe` invalidate the user's subscription cache.
|
|
71
|
+
|
|
72
|
+
### `@checkstack/announcement-backend`
|
|
73
|
+
|
|
74
|
+
- `getActiveAnnouncements` caches per-user (or anonymous) and per-`includeDismissed` flag (45s TTL — admin-driven, slowly changing).
|
|
75
|
+
- `listAllAnnouncements` caches under a single key.
|
|
76
|
+
- `dismissAnnouncement` only drops that user's cache; `createAnnouncement`, `updateAnnouncement`, `deleteAnnouncement` drop every user's cache before broadcasting `ANNOUNCEMENT_UPDATED`.
|
|
77
|
+
- The auth `userDeleted` cleanup hook drops that user's cached entries.
|
|
78
|
+
|
|
79
|
+
### Patch Changes
|
|
80
|
+
|
|
81
|
+
- Updated dependencies [8d1ef12]
|
|
82
|
+
- Updated dependencies [8d1ef12]
|
|
83
|
+
- @checkstack/cache-api@0.2.0
|
package/package.json
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@checkstack/cache-utils",
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"checkstack": {
|
|
5
|
+
"type": "tooling"
|
|
6
|
+
},
|
|
7
|
+
"type": "module",
|
|
8
|
+
"main": "src/index.ts",
|
|
9
|
+
"dependencies": {
|
|
10
|
+
"@checkstack/cache-api": "0.1.0"
|
|
11
|
+
},
|
|
12
|
+
"devDependencies": {
|
|
13
|
+
"@checkstack/tsconfig": "0.0.5",
|
|
14
|
+
"@checkstack/scripts": "0.1.2",
|
|
15
|
+
"@types/bun": "^1.0.0",
|
|
16
|
+
"typescript": "^5.0.0"
|
|
17
|
+
},
|
|
18
|
+
"scripts": {
|
|
19
|
+
"typecheck": "tsc --noEmit",
|
|
20
|
+
"lint": "bun run lint:code",
|
|
21
|
+
"lint:code": "eslint . --max-warnings 0"
|
|
22
|
+
}
|
|
23
|
+
}
|
|
@@ -0,0 +1,342 @@
|
|
|
1
|
+
import { describe, expect, it, mock } from "bun:test";
|
|
2
|
+
import type { CacheManager, CacheProvider } from "@checkstack/cache-api";
|
|
3
|
+
import { createCachedScope } from "./cached-scope";
|
|
4
|
+
|
|
5
|
+
function createMemoryProvider(): CacheProvider {
|
|
6
|
+
const store = new Map<string, unknown>();
|
|
7
|
+
return {
|
|
8
|
+
get: async <T>(key: string) => store.get(key) as T | undefined,
|
|
9
|
+
set: async (key, value) => {
|
|
10
|
+
store.set(key, value);
|
|
11
|
+
},
|
|
12
|
+
delete: async (key) => {
|
|
13
|
+
store.delete(key);
|
|
14
|
+
},
|
|
15
|
+
deleteByPrefix: async (prefix) => {
|
|
16
|
+
let removed = 0;
|
|
17
|
+
for (const key of [...store.keys()]) {
|
|
18
|
+
if (key.startsWith(prefix)) {
|
|
19
|
+
store.delete(key);
|
|
20
|
+
removed++;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
return removed;
|
|
24
|
+
},
|
|
25
|
+
has: async (key) => store.has(key),
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function createManager(provider: CacheProvider): CacheManager {
|
|
30
|
+
return {
|
|
31
|
+
getProvider: () => provider,
|
|
32
|
+
getActivePlugin: () => "test",
|
|
33
|
+
getActiveConfig: () => ({}),
|
|
34
|
+
setActiveBackend: async () => {},
|
|
35
|
+
shutdown: async () => {},
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function deferred<T>() {
|
|
40
|
+
let resolve!: (value: T) => void;
|
|
41
|
+
let reject!: (reason: unknown) => void;
|
|
42
|
+
const promise = new Promise<T>((res, rej) => {
|
|
43
|
+
resolve = res;
|
|
44
|
+
reject = rej;
|
|
45
|
+
});
|
|
46
|
+
return { promise, resolve, reject };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
describe("createCachedScope", () => {
|
|
50
|
+
describe("wrap (single-flight)", () => {
|
|
51
|
+
it("returns the loader's value on a cold miss and caches it", async () => {
|
|
52
|
+
const provider = createMemoryProvider();
|
|
53
|
+
const scope = createCachedScope({
|
|
54
|
+
cacheManager: createManager(provider),
|
|
55
|
+
pluginId: "test",
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
const loader = mock(async () => "hello");
|
|
59
|
+
const value = await scope.wrap("k", loader);
|
|
60
|
+
|
|
61
|
+
expect(value).toBe("hello");
|
|
62
|
+
expect(loader).toHaveBeenCalledTimes(1);
|
|
63
|
+
expect(await provider.get<string>("test:k")).toBe("hello");
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it("returns the cached value on subsequent calls without invoking the loader", async () => {
|
|
67
|
+
const provider = createMemoryProvider();
|
|
68
|
+
const scope = createCachedScope({
|
|
69
|
+
cacheManager: createManager(provider),
|
|
70
|
+
pluginId: "test",
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
const loader = mock(async () => "hello");
|
|
74
|
+
await scope.wrap("k", loader);
|
|
75
|
+
const second = await scope.wrap("k", loader);
|
|
76
|
+
|
|
77
|
+
expect(second).toBe("hello");
|
|
78
|
+
expect(loader).toHaveBeenCalledTimes(1);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it("deduplicates concurrent misses for the same key (single-flight)", async () => {
|
|
82
|
+
const provider = createMemoryProvider();
|
|
83
|
+
const scope = createCachedScope({
|
|
84
|
+
cacheManager: createManager(provider),
|
|
85
|
+
pluginId: "test",
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
const gate = deferred<string>();
|
|
89
|
+
const loader = mock(() => gate.promise);
|
|
90
|
+
|
|
91
|
+
const a = scope.wrap("k", loader);
|
|
92
|
+
const b = scope.wrap("k", loader);
|
|
93
|
+
const c = scope.wrap("k", loader);
|
|
94
|
+
|
|
95
|
+
gate.resolve("once");
|
|
96
|
+
const results = await Promise.all([a, b, c]);
|
|
97
|
+
|
|
98
|
+
expect(results).toEqual(["once", "once", "once"]);
|
|
99
|
+
expect(loader).toHaveBeenCalledTimes(1);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it("does not deduplicate across different keys", async () => {
|
|
103
|
+
const provider = createMemoryProvider();
|
|
104
|
+
const scope = createCachedScope({
|
|
105
|
+
cacheManager: createManager(provider),
|
|
106
|
+
pluginId: "test",
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
const loader = mock(async (key: string) => `v:${key}`);
|
|
110
|
+
const [a, b] = await Promise.all([
|
|
111
|
+
scope.wrap("a", () => loader("a")),
|
|
112
|
+
scope.wrap("b", () => loader("b")),
|
|
113
|
+
]);
|
|
114
|
+
|
|
115
|
+
expect(a).toBe("v:a");
|
|
116
|
+
expect(b).toBe("v:b");
|
|
117
|
+
expect(loader).toHaveBeenCalledTimes(2);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it("does not cache loader errors and clears the in-flight slot", async () => {
|
|
121
|
+
const provider = createMemoryProvider();
|
|
122
|
+
const scope = createCachedScope({
|
|
123
|
+
cacheManager: createManager(provider),
|
|
124
|
+
pluginId: "test",
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
const loader = mock(async (): Promise<string> => {
|
|
128
|
+
throw new Error("boom");
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
await expect(scope.wrap("k", loader)).rejects.toThrow("boom");
|
|
132
|
+
// Second call must re-run the loader (no negative caching, no stuck in-flight).
|
|
133
|
+
await expect(scope.wrap("k", loader)).rejects.toThrow("boom");
|
|
134
|
+
expect(loader).toHaveBeenCalledTimes(2);
|
|
135
|
+
expect(await provider.has("test:k")).toBe(false);
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it("falls through to the loader if the cache get throws", async () => {
|
|
139
|
+
const broken: CacheProvider = {
|
|
140
|
+
...createMemoryProvider(),
|
|
141
|
+
get: async () => {
|
|
142
|
+
throw new Error("cache down");
|
|
143
|
+
},
|
|
144
|
+
};
|
|
145
|
+
const scope = createCachedScope({
|
|
146
|
+
cacheManager: createManager(broken),
|
|
147
|
+
pluginId: "test",
|
|
148
|
+
onError: () => {},
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
const loader = mock(async () => "ok");
|
|
152
|
+
const value = await scope.wrap("k", loader);
|
|
153
|
+
expect(value).toBe("ok");
|
|
154
|
+
expect(loader).toHaveBeenCalledTimes(1);
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
it("respects per-call ttlMs over the scope default", async () => {
|
|
158
|
+
const provider = createMemoryProvider();
|
|
159
|
+
const setSpy = mock(provider.set);
|
|
160
|
+
provider.set = setSpy;
|
|
161
|
+
|
|
162
|
+
const scope = createCachedScope({
|
|
163
|
+
cacheManager: createManager(provider),
|
|
164
|
+
pluginId: "test",
|
|
165
|
+
defaultTtlMs: 1000,
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
await scope.wrap("k", async () => "v", { ttlMs: 9999 });
|
|
169
|
+
expect(setSpy).toHaveBeenCalledWith("test:k", "v", 9999);
|
|
170
|
+
});
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
describe("wrapMany", () => {
|
|
174
|
+
it("returns results in input order, caching per id", async () => {
|
|
175
|
+
const provider = createMemoryProvider();
|
|
176
|
+
const scope = createCachedScope({
|
|
177
|
+
cacheManager: createManager(provider),
|
|
178
|
+
pluginId: "hc",
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
const loader = mock(async (id: string) => `status:${id}`);
|
|
182
|
+
const result = await scope.wrapMany(["s1", "s2", "s3"], {
|
|
183
|
+
keyFor: (id) => `status:${id}`,
|
|
184
|
+
loader,
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
expect(result).toEqual(["status:s1", "status:s2", "status:s3"]);
|
|
188
|
+
expect(loader).toHaveBeenCalledTimes(3);
|
|
189
|
+
expect(await provider.get<string>("hc:status:s1")).toBe("status:s1");
|
|
190
|
+
expect(await provider.get<string>("hc:status:s2")).toBe("status:s2");
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
it("only calls the loader for missing ids on subsequent calls", async () => {
|
|
194
|
+
const provider = createMemoryProvider();
|
|
195
|
+
const scope = createCachedScope({
|
|
196
|
+
cacheManager: createManager(provider),
|
|
197
|
+
pluginId: "hc",
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
const loader = mock(async (id: string) => `v:${id}`);
|
|
201
|
+
await scope.wrapMany(["a", "b"], {
|
|
202
|
+
keyFor: (id) => `e:${id}`,
|
|
203
|
+
loader,
|
|
204
|
+
});
|
|
205
|
+
loader.mockClear();
|
|
206
|
+
|
|
207
|
+
const result = await scope.wrapMany(["a", "b", "c"], {
|
|
208
|
+
keyFor: (id) => `e:${id}`,
|
|
209
|
+
loader,
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
expect(result).toEqual(["v:a", "v:b", "v:c"]);
|
|
213
|
+
expect(loader).toHaveBeenCalledTimes(1);
|
|
214
|
+
expect(loader).toHaveBeenCalledWith("c");
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
it("returns [] for an empty id list without touching the cache", async () => {
|
|
218
|
+
const provider = createMemoryProvider();
|
|
219
|
+
const getSpy = mock(provider.get) as CacheProvider["get"];
|
|
220
|
+
provider.get = getSpy;
|
|
221
|
+
const scope = createCachedScope({
|
|
222
|
+
cacheManager: createManager(provider),
|
|
223
|
+
pluginId: "hc",
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
const result = await scope.wrapMany<string, string>([], {
|
|
227
|
+
keyFor: (id) => id,
|
|
228
|
+
loader: async () => "x",
|
|
229
|
+
});
|
|
230
|
+
expect(result).toEqual([]);
|
|
231
|
+
expect(getSpy).not.toHaveBeenCalled();
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
it("deduplicates concurrent loads for the same id within a bulk call", async () => {
|
|
235
|
+
const provider = createMemoryProvider();
|
|
236
|
+
const scope = createCachedScope({
|
|
237
|
+
cacheManager: createManager(provider),
|
|
238
|
+
pluginId: "hc",
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
const loader = mock(async (id: string) => `v:${id}`);
|
|
242
|
+
|
|
243
|
+
const [first, second] = await Promise.all([
|
|
244
|
+
scope.wrapMany(["a", "b"], {
|
|
245
|
+
keyFor: (id) => `e:${id}`,
|
|
246
|
+
loader,
|
|
247
|
+
}),
|
|
248
|
+
scope.wrapMany(["a", "c"], {
|
|
249
|
+
keyFor: (id) => `e:${id}`,
|
|
250
|
+
loader,
|
|
251
|
+
}),
|
|
252
|
+
]);
|
|
253
|
+
|
|
254
|
+
expect(first).toEqual(["v:a", "v:b"]);
|
|
255
|
+
expect(second).toEqual(["v:a", "v:c"]);
|
|
256
|
+
// 'a' should be loaded once across both concurrent bulks.
|
|
257
|
+
expect(loader).toHaveBeenCalledTimes(3);
|
|
258
|
+
});
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
describe("invalidate / invalidatePrefix", () => {
|
|
262
|
+
it("invalidate removes a single key so the next read re-loads", async () => {
|
|
263
|
+
const provider = createMemoryProvider();
|
|
264
|
+
const scope = createCachedScope({
|
|
265
|
+
cacheManager: createManager(provider),
|
|
266
|
+
pluginId: "p",
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
const loader = mock(async () => "v1");
|
|
270
|
+
await scope.wrap("k", loader);
|
|
271
|
+
await scope.invalidate("k");
|
|
272
|
+
|
|
273
|
+
const second = await scope.wrap("k", async () => "v2");
|
|
274
|
+
expect(second).toBe("v2");
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
it("invalidatePrefix removes every key under the prefix and reports the count", async () => {
|
|
278
|
+
const provider = createMemoryProvider();
|
|
279
|
+
const scope = createCachedScope({
|
|
280
|
+
cacheManager: createManager(provider),
|
|
281
|
+
pluginId: "p",
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
await scope.wrap("list:active", async () => "a");
|
|
285
|
+
await scope.wrap("list:resolved", async () => "b");
|
|
286
|
+
await scope.wrap("get:1", async () => "c");
|
|
287
|
+
|
|
288
|
+
const removed = await scope.invalidatePrefix("list:");
|
|
289
|
+
expect(removed).toBe(2);
|
|
290
|
+
expect(await provider.has("p:list:active")).toBe(false);
|
|
291
|
+
expect(await provider.has("p:list:resolved")).toBe(false);
|
|
292
|
+
expect(await provider.has("p:get:1")).toBe(true);
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
it("invalidatePrefix cannot reach keys in other plugin scopes", async () => {
|
|
296
|
+
const provider = createMemoryProvider();
|
|
297
|
+
const scopeA = createCachedScope({
|
|
298
|
+
cacheManager: createManager(provider),
|
|
299
|
+
pluginId: "a",
|
|
300
|
+
});
|
|
301
|
+
const scopeB = createCachedScope({
|
|
302
|
+
cacheManager: createManager(provider),
|
|
303
|
+
pluginId: "b",
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
await scopeA.wrap("x", async () => "1");
|
|
307
|
+
await scopeB.wrap("x", async () => "2");
|
|
308
|
+
|
|
309
|
+
await scopeA.invalidatePrefix("");
|
|
310
|
+
expect(await scopeB.provider.get<string>("x")).toBe("2");
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
it("epoch check prevents an in-flight loader from writing stale data after invalidation", async () => {
|
|
314
|
+
// Race: loader's DB query was issued before the mutation's DB write
|
|
315
|
+
// committed, so the loader will return pre-mutation data. The mutation
|
|
316
|
+
// then runs invalidate. By the time the loader resolves, the cache
|
|
317
|
+
// must NOT be repopulated with the stale value.
|
|
318
|
+
const provider = createMemoryProvider();
|
|
319
|
+
const scope = createCachedScope({
|
|
320
|
+
cacheManager: createManager(provider),
|
|
321
|
+
pluginId: "p",
|
|
322
|
+
});
|
|
323
|
+
|
|
324
|
+
const gate = deferred<string>();
|
|
325
|
+
// Capture epoch synchronously when "DB query is issued" — the loader
|
|
326
|
+
// body runs lazily inside wrap, which captures epoch before issuing it.
|
|
327
|
+
const loader = mock(() => gate.promise);
|
|
328
|
+
|
|
329
|
+
const reading = scope.wrap("k", loader);
|
|
330
|
+
// Let wrap progress past its initial cache-miss check and start the
|
|
331
|
+
// loader. Once the loader is running, simulate the mutation:
|
|
332
|
+
await Promise.resolve();
|
|
333
|
+
await Promise.resolve();
|
|
334
|
+
await scope.invalidate("k");
|
|
335
|
+
gate.resolve("pre-mutation-value");
|
|
336
|
+
await reading;
|
|
337
|
+
|
|
338
|
+
// Cache must not contain the stale value the in-flight loader returned.
|
|
339
|
+
expect(await provider.has("p:k")).toBe(false);
|
|
340
|
+
});
|
|
341
|
+
});
|
|
342
|
+
});
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
import {
|
|
2
|
+
createScopedCache,
|
|
3
|
+
type CacheManager,
|
|
4
|
+
type CacheProvider,
|
|
5
|
+
} from "@checkstack/cache-api";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* High-level cache helper used by routers/services that want to wrap
|
|
9
|
+
* read-through caching around DB queries with safe invalidation.
|
|
10
|
+
*
|
|
11
|
+
* Built on top of {@link CacheProvider} but adds:
|
|
12
|
+
* - single-flight per key (no thundering herd on cache miss)
|
|
13
|
+
* - {@link wrapMany} for per-entity caching of bulk reads
|
|
14
|
+
* - prefix-based bulk invalidation (delegated to the provider)
|
|
15
|
+
* - graceful fallback to the underlying loader if the cache layer throws,
|
|
16
|
+
* so a cache outage cannot take down the read path
|
|
17
|
+
*/
|
|
18
|
+
export interface CachedScope {
|
|
19
|
+
/**
|
|
20
|
+
* Read-through cache for a single key. If the key is cached, returns it.
|
|
21
|
+
* Otherwise calls {@link loader}, stores the result, and returns it.
|
|
22
|
+
*
|
|
23
|
+
* Concurrent callers for the same key share a single in-flight loader
|
|
24
|
+
* promise (single-flight) so a cache miss does not stampede the DB.
|
|
25
|
+
*/
|
|
26
|
+
wrap<T>(
|
|
27
|
+
key: string,
|
|
28
|
+
loader: () => Promise<T>,
|
|
29
|
+
options?: { ttlMs?: number },
|
|
30
|
+
): Promise<T>;
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Per-entity caching for bulk reads. For each id, returns the cached value
|
|
34
|
+
* if present; otherwise loads it via {@link loader} and caches it. Misses
|
|
35
|
+
* are loaded in parallel and concurrent loads for the same id are
|
|
36
|
+
* deduplicated (single-flight).
|
|
37
|
+
*
|
|
38
|
+
* The {@link keyFor} function maps an id to a cache key inside this scope —
|
|
39
|
+
* keep it stable so invalidation and reads agree. Use plain readable keys
|
|
40
|
+
* like `status:${id}` so {@link invalidatePrefix} can target families.
|
|
41
|
+
*/
|
|
42
|
+
wrapMany<Id, T>(
|
|
43
|
+
ids: readonly Id[],
|
|
44
|
+
options: {
|
|
45
|
+
keyFor: (id: Id) => string;
|
|
46
|
+
loader: (id: Id) => Promise<T>;
|
|
47
|
+
ttlMs?: number;
|
|
48
|
+
},
|
|
49
|
+
): Promise<T[]>;
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Invalidate exactly one key. Safe to call from mutation paths.
|
|
53
|
+
* Prefer this over prefix invalidation when you know the affected entity.
|
|
54
|
+
*/
|
|
55
|
+
invalidate(key: string): Promise<void>;
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Invalidate every key in this scope that starts with {@link prefix}.
|
|
59
|
+
* Use sparingly — prefer per-entity {@link invalidate} when possible.
|
|
60
|
+
* Returns the number of keys actually removed.
|
|
61
|
+
*/
|
|
62
|
+
invalidatePrefix(prefix: string): Promise<number>;
|
|
63
|
+
|
|
64
|
+
/** The underlying scoped {@link CacheProvider}, exposed for advanced cases. */
|
|
65
|
+
readonly provider: CacheProvider;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Create a {@link CachedScope} for a plugin. All keys are automatically
|
|
70
|
+
* prefixed with the plugin id (via {@link createScopedCache}) so plugins
|
|
71
|
+
* sharing a backend don't collide and {@link invalidatePrefix} cannot reach
|
|
72
|
+
* keys belonging to other plugins.
|
|
73
|
+
*/
|
|
74
|
+
export function createCachedScope({
|
|
75
|
+
cacheManager,
|
|
76
|
+
pluginId,
|
|
77
|
+
defaultTtlMs,
|
|
78
|
+
onError,
|
|
79
|
+
}: {
|
|
80
|
+
cacheManager: CacheManager;
|
|
81
|
+
pluginId: string;
|
|
82
|
+
defaultTtlMs?: number;
|
|
83
|
+
/**
|
|
84
|
+
* Called when a cache operation throws. Defaults to swallowing the error
|
|
85
|
+
* after the loader's value has been returned — a cache failure must never
|
|
86
|
+
* fail a request. Use this hook to surface metrics/logs.
|
|
87
|
+
*/
|
|
88
|
+
onError?: (op: "get" | "set" | "delete" | "deleteByPrefix", error: unknown) => void;
|
|
89
|
+
}): CachedScope {
|
|
90
|
+
const provider = createScopedCache({
|
|
91
|
+
pluginId,
|
|
92
|
+
provider: cacheManager.getProvider(),
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
const inflight = new Map<string, Promise<unknown>>();
|
|
96
|
+
/**
|
|
97
|
+
* Per-key epoch counter. Incremented on every invalidate so that an
|
|
98
|
+
* in-flight loader started before invalidation cannot write stale data
|
|
99
|
+
* to the cache after invalidation completes. The mutation path therefore
|
|
100
|
+
* truly wins the race even if a reader had already begun a DB query
|
|
101
|
+
* against pre-mutation state.
|
|
102
|
+
*/
|
|
103
|
+
const epochs = new Map<string, number>();
|
|
104
|
+
|
|
105
|
+
const reportError = (
|
|
106
|
+
op: "get" | "set" | "delete" | "deleteByPrefix",
|
|
107
|
+
error: unknown,
|
|
108
|
+
) => {
|
|
109
|
+
if (onError) {
|
|
110
|
+
onError(op, error);
|
|
111
|
+
}
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
function bumpEpoch(key: string): void {
|
|
115
|
+
epochs.set(key, (epochs.get(key) ?? 0) + 1);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
async function wrap<T>(
|
|
119
|
+
key: string,
|
|
120
|
+
loader: () => Promise<T>,
|
|
121
|
+
options?: { ttlMs?: number },
|
|
122
|
+
): Promise<T> {
|
|
123
|
+
try {
|
|
124
|
+
const cached = await provider.get<T>(key);
|
|
125
|
+
if (cached !== undefined) {
|
|
126
|
+
return cached;
|
|
127
|
+
}
|
|
128
|
+
} catch (error) {
|
|
129
|
+
reportError("get", error);
|
|
130
|
+
// fall through to loader
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const existing = inflight.get(key) as Promise<T> | undefined;
|
|
134
|
+
if (existing) {
|
|
135
|
+
return existing;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const ttlMs = options?.ttlMs ?? defaultTtlMs;
|
|
139
|
+
const startEpoch = epochs.get(key) ?? 0;
|
|
140
|
+
const loading = (async () => {
|
|
141
|
+
try {
|
|
142
|
+
const value = await loader();
|
|
143
|
+
if ((epochs.get(key) ?? 0) === startEpoch) {
|
|
144
|
+
try {
|
|
145
|
+
await provider.set(key, value, ttlMs);
|
|
146
|
+
} catch (error) {
|
|
147
|
+
reportError("set", error);
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
// If epoch changed, an invalidation happened during the load —
|
|
151
|
+
// do NOT write the (now stale) value. The caller still receives
|
|
152
|
+
// it (their read began before the mutation), but the next reader
|
|
153
|
+
// will go to the DB and see the post-mutation state.
|
|
154
|
+
return value;
|
|
155
|
+
} finally {
|
|
156
|
+
inflight.delete(key);
|
|
157
|
+
}
|
|
158
|
+
})();
|
|
159
|
+
inflight.set(key, loading);
|
|
160
|
+
return loading;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
async function wrapMany<Id, T>(
|
|
164
|
+
ids: readonly Id[],
|
|
165
|
+
options: {
|
|
166
|
+
keyFor: (id: Id) => string;
|
|
167
|
+
loader: (id: Id) => Promise<T>;
|
|
168
|
+
ttlMs?: number;
|
|
169
|
+
},
|
|
170
|
+
): Promise<T[]> {
|
|
171
|
+
if (ids.length === 0) {
|
|
172
|
+
return [];
|
|
173
|
+
}
|
|
174
|
+
return Promise.all(
|
|
175
|
+
ids.map((id) =>
|
|
176
|
+
wrap(options.keyFor(id), () => options.loader(id), {
|
|
177
|
+
ttlMs: options.ttlMs,
|
|
178
|
+
}),
|
|
179
|
+
),
|
|
180
|
+
);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
async function invalidate(key: string): Promise<void> {
|
|
184
|
+
bumpEpoch(key);
|
|
185
|
+
inflight.delete(key);
|
|
186
|
+
try {
|
|
187
|
+
await provider.delete(key);
|
|
188
|
+
} catch (error) {
|
|
189
|
+
reportError("delete", error);
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
async function invalidatePrefix(prefix: string): Promise<number> {
|
|
194
|
+
for (const key of inflight.keys()) {
|
|
195
|
+
if (key.startsWith(prefix)) {
|
|
196
|
+
inflight.delete(key);
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
for (const key of epochs.keys()) {
|
|
200
|
+
if (key.startsWith(prefix)) {
|
|
201
|
+
bumpEpoch(key);
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
try {
|
|
205
|
+
return await provider.deleteByPrefix(prefix);
|
|
206
|
+
} catch (error) {
|
|
207
|
+
reportError("deleteByPrefix", error);
|
|
208
|
+
return 0;
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
return { wrap, wrapMany, invalidate, invalidatePrefix, provider };
|
|
213
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from "./cached-scope";
|