@c9up/echo 0.1.13 → 0.1.15
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/dist/CacheManager.d.ts.map +1 -1
- package/dist/CacheManager.js +20 -3
- package/dist/CacheManager.js.map +1 -1
- package/dist/EchoProvider.d.ts +1 -0
- package/dist/EchoProvider.d.ts.map +1 -1
- package/dist/EchoProvider.js +9 -1
- package/dist/EchoProvider.js.map +1 -1
- package/dist/StoreManager.d.ts +18 -0
- package/dist/StoreManager.d.ts.map +1 -1
- package/dist/StoreManager.js +18 -2
- package/dist/StoreManager.js.map +1 -1
- package/dist/augmentations.d.ts +29 -0
- package/dist/augmentations.d.ts.map +1 -0
- package/dist/augmentations.js +18 -0
- package/dist/augmentations.js.map +1 -0
- package/dist/drivers/MemoryDriver.d.ts +1 -1
- package/dist/drivers/MemoryDriver.d.ts.map +1 -1
- package/dist/drivers/MemoryDriver.js +41 -1
- package/dist/drivers/MemoryDriver.js.map +1 -1
- package/dist/drivers/TieredDriver.d.ts +17 -0
- package/dist/drivers/TieredDriver.d.ts.map +1 -1
- package/dist/drivers/TieredDriver.js +88 -17
- package/dist/drivers/TieredDriver.js.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/quasar.d.ts +17 -0
- package/dist/quasar.d.ts.map +1 -1
- package/dist/quasar.js +77 -0
- package/dist/quasar.js.map +1 -1
- package/dist/types.d.ts +12 -1
- package/dist/types.d.ts.map +1 -1
- package/package.json +5 -3
- package/src/CacheManager.ts +29 -4
- package/src/EchoProvider.ts +10 -3
- package/src/StoreManager.ts +22 -3
- package/src/augmentations.ts +33 -0
- package/src/drivers/MemoryDriver.ts +39 -1
- package/src/drivers/TieredDriver.ts +94 -17
- package/src/index.ts +2 -0
- package/src/quasar.ts +101 -0
- package/src/types.ts +12 -1
|
@@ -19,6 +19,23 @@ import type {
|
|
|
19
19
|
export interface BusMessage {
|
|
20
20
|
type: "delete" | "clear";
|
|
21
21
|
keys: string[];
|
|
22
|
+
/**
|
|
23
|
+
* The tier that published it.
|
|
24
|
+
*
|
|
25
|
+
* A pub/sub bus delivers to every subscriber INCLUDING the publisher —
|
|
26
|
+
* Redis does, and Redis pub/sub is what this is for. Without a sender to
|
|
27
|
+
* recognise, every `set` published a delete, received it back, and dropped
|
|
28
|
+
* the L1 copy it had just written: L1 held nothing after any write, and
|
|
29
|
+
* every read went to L2. Upstream draws the same line one layer down, where
|
|
30
|
+
* each bus transport stamps its own id and skips what it sent
|
|
31
|
+
* (`@boringnode/bus`, `transports/memory.js`: `if (busId === this.#id)
|
|
32
|
+
* continue`).
|
|
33
|
+
*
|
|
34
|
+
* Optional, and a message without one is acted on: a bus that predates this
|
|
35
|
+
* field keeps working, at worst doing the redundant local invalidation it
|
|
36
|
+
* already did.
|
|
37
|
+
*/
|
|
38
|
+
senderId?: string;
|
|
22
39
|
}
|
|
23
40
|
|
|
24
41
|
/** Duck-typed pub/sub bus for cross-instance L1 invalidation (e.g. Redis pub/sub). */
|
|
@@ -68,16 +85,42 @@ async function writeEntry(
|
|
|
68
85
|
await driver.set(key, value, options.ttlSeconds);
|
|
69
86
|
}
|
|
70
87
|
|
|
88
|
+
/**
|
|
89
|
+
* Run one tier's operation, capturing a rejection instead of letting it escape.
|
|
90
|
+
*
|
|
91
|
+
* The two tiers are attempted independently on purpose: awaiting L1 first meant
|
|
92
|
+
* a local driver that threw — a Redis L1 whose socket just dropped, one
|
|
93
|
+
* mid-shutdown — aborted the call before the SHARED tier was touched, so a
|
|
94
|
+
* `delete` left the copy every other instance reads. Upstream reaches the same
|
|
95
|
+
* end by never awaiting L1 at all (`this.l1?.set(...)` with no await, then
|
|
96
|
+
* `await this.l2?.set(...)`). The failure is still reported, after both tiers
|
|
97
|
+
* have had their turn.
|
|
98
|
+
*/
|
|
99
|
+
async function settle<T>(
|
|
100
|
+
run: () => Promise<T>,
|
|
101
|
+
): Promise<{ ok: true; value: T } | { ok: false; error: unknown }> {
|
|
102
|
+
try {
|
|
103
|
+
return { ok: true, value: await run() };
|
|
104
|
+
} catch (error) {
|
|
105
|
+
return { ok: false, error };
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
71
109
|
export class TieredDriver implements TaggableDriver {
|
|
72
110
|
#l1: CacheDriver;
|
|
73
111
|
#l2: CacheDriver;
|
|
74
112
|
#bus: CacheBus | undefined;
|
|
113
|
+
/** This tier, as a bus sender. */
|
|
114
|
+
readonly #id = crypto.randomUUID();
|
|
75
115
|
|
|
76
116
|
constructor(options: TieredDriverOptions) {
|
|
77
117
|
this.#l1 = options.l1;
|
|
78
118
|
this.#l2 = options.l2;
|
|
79
119
|
this.#bus = options.bus;
|
|
80
120
|
this.#bus?.subscribe((message) => {
|
|
121
|
+
// Our own invalidation, come back round the bus. Acting on it would
|
|
122
|
+
// undo the write that sent it.
|
|
123
|
+
if (message.senderId === this.#id) return;
|
|
81
124
|
// Peer invalidation: only the local L1 needs clearing (L2 is shared).
|
|
82
125
|
if (message.type === "clear") {
|
|
83
126
|
this.#invalidate("flush", () => this.#l1.flush());
|
|
@@ -157,22 +200,46 @@ export class TieredDriver implements TaggableDriver {
|
|
|
157
200
|
value: unknown,
|
|
158
201
|
options: DriverSetOptions,
|
|
159
202
|
): Promise<void> {
|
|
160
|
-
await writeEntry(this.#l1, key, value, options);
|
|
161
|
-
await writeEntry(this.#l2, key, value, options);
|
|
162
|
-
|
|
203
|
+
const l1 = await settle(() => writeEntry(this.#l1, key, value, options));
|
|
204
|
+
const l2 = await settle(() => writeEntry(this.#l2, key, value, options));
|
|
205
|
+
// Peers are told only when the shared write landed. Telling them to drop
|
|
206
|
+
// their L1 for a value that never reached L2 sends every one of them to a
|
|
207
|
+
// tier that does not have it. Upstream gates the same publish on the same
|
|
208
|
+
// thing (`if (this.l2 && l2Success || !this.l2)`).
|
|
209
|
+
if (l2.ok) {
|
|
210
|
+
await this.#bus?.publish({
|
|
211
|
+
type: "delete",
|
|
212
|
+
keys: [key],
|
|
213
|
+
senderId: this.#id,
|
|
214
|
+
});
|
|
215
|
+
}
|
|
216
|
+
if (!l1.ok) throw l1.error;
|
|
217
|
+
if (!l2.ok) throw l2.error;
|
|
163
218
|
}
|
|
164
219
|
|
|
165
220
|
async delete(key: string): Promise<boolean> {
|
|
166
|
-
const l1 = await this.#l1.delete(key);
|
|
167
|
-
const l2 = await this.#l2.delete(key);
|
|
168
|
-
|
|
169
|
-
|
|
221
|
+
const l1 = await settle(() => this.#l1.delete(key));
|
|
222
|
+
const l2 = await settle(() => this.#l2.delete(key));
|
|
223
|
+
if (l2.ok) {
|
|
224
|
+
await this.#bus?.publish({
|
|
225
|
+
type: "delete",
|
|
226
|
+
keys: [key],
|
|
227
|
+
senderId: this.#id,
|
|
228
|
+
});
|
|
229
|
+
}
|
|
230
|
+
if (!l1.ok) throw l1.error;
|
|
231
|
+
if (!l2.ok) throw l2.error;
|
|
232
|
+
return l1.value || l2.value;
|
|
170
233
|
}
|
|
171
234
|
|
|
172
235
|
async flush(): Promise<void> {
|
|
173
|
-
await this.#l1.flush();
|
|
174
|
-
await this.#l2.flush();
|
|
175
|
-
|
|
236
|
+
const l1 = await settle(() => this.#l1.flush());
|
|
237
|
+
const l2 = await settle(() => this.#l2.flush());
|
|
238
|
+
if (l2.ok) {
|
|
239
|
+
await this.#bus?.publish({ type: "clear", keys: [], senderId: this.#id });
|
|
240
|
+
}
|
|
241
|
+
if (!l1.ok) throw l1.error;
|
|
242
|
+
if (!l2.ok) throw l2.error;
|
|
176
243
|
}
|
|
177
244
|
|
|
178
245
|
async has(key: string): Promise<boolean> {
|
|
@@ -196,11 +263,15 @@ export class TieredDriver implements TaggableDriver {
|
|
|
196
263
|
"Echo: TieredDriver.deleteByTag requires both tiers to be taggable",
|
|
197
264
|
);
|
|
198
265
|
}
|
|
199
|
-
await l1.deleteByTag(tags);
|
|
200
|
-
await l2.deleteByTag(tags);
|
|
266
|
+
const local = await settle(() => l1.deleteByTag(tags));
|
|
267
|
+
const shared = await settle(() => l2.deleteByTag(tags));
|
|
201
268
|
// Peers can't map tags → keys locally; broadcast a clear so their L1 drops
|
|
202
269
|
// any tagged copies (conservative but correct).
|
|
203
|
-
|
|
270
|
+
if (shared.ok) {
|
|
271
|
+
await this.#bus?.publish({ type: "clear", keys: [], senderId: this.#id });
|
|
272
|
+
}
|
|
273
|
+
if (!local.ok) throw local.error;
|
|
274
|
+
if (!shared.ok) throw shared.error;
|
|
204
275
|
}
|
|
205
276
|
|
|
206
277
|
/** @deprecated alias of {@link deleteByTag}. */
|
|
@@ -209,13 +280,19 @@ export class TieredDriver implements TaggableDriver {
|
|
|
209
280
|
}
|
|
210
281
|
/** Prune both layers (bentocache `prune`). */
|
|
211
282
|
async prune(): Promise<void> {
|
|
212
|
-
await this.#l1.prune?.();
|
|
213
|
-
await this.#l2.prune?.();
|
|
283
|
+
const l1 = await settle(async () => this.#l1.prune?.());
|
|
284
|
+
const l2 = await settle(async () => this.#l2.prune?.());
|
|
285
|
+
if (!l1.ok) throw l1.error;
|
|
286
|
+
if (!l2.ok) throw l2.error;
|
|
214
287
|
}
|
|
215
288
|
|
|
216
289
|
/** Release both layers (bentocache `disconnect`). */
|
|
217
290
|
async disconnect(): Promise<void> {
|
|
218
|
-
|
|
219
|
-
|
|
291
|
+
// Both are released even when the first refuses: a tier left connected
|
|
292
|
+
// because its neighbour threw is a socket nobody closes.
|
|
293
|
+
const l1 = await settle(async () => this.#l1.disconnect?.());
|
|
294
|
+
const l2 = await settle(async () => this.#l2.disconnect?.());
|
|
295
|
+
if (!l1.ok) throw l1.error;
|
|
296
|
+
if (!l2.ok) throw l2.error;
|
|
220
297
|
}
|
|
221
298
|
}
|
package/src/index.ts
CHANGED
package/src/quasar.ts
CHANGED
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
*/
|
|
12
12
|
|
|
13
13
|
import type { RedisClient } from "./drivers/RedisDriver.js";
|
|
14
|
+
import type { BusMessage, CacheBus } from "./drivers/TieredDriver.js";
|
|
14
15
|
|
|
15
16
|
/** The slice of quasar's manager this needs: a connection, by name. */
|
|
16
17
|
interface ConnectionSource {
|
|
@@ -87,3 +88,103 @@ export function quasarConnection(name?: string): () => Promise<RedisClient> {
|
|
|
87
88
|
return connection;
|
|
88
89
|
};
|
|
89
90
|
}
|
|
91
|
+
|
|
92
|
+
/** The slice of quasar's manager a pub/sub bus needs. */
|
|
93
|
+
interface PubSubSource {
|
|
94
|
+
publish(channel: string, message: string): unknown;
|
|
95
|
+
subscribe(channel: string, handler: (message: string) => void): unknown;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function isPubSubSource(value: unknown): value is PubSubSource {
|
|
99
|
+
return (
|
|
100
|
+
typeof value === "object" &&
|
|
101
|
+
value !== null &&
|
|
102
|
+
typeof Reflect.get(value, "publish") === "function" &&
|
|
103
|
+
typeof Reflect.get(value, "subscribe") === "function"
|
|
104
|
+
);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Resolve quasar's manager, loading it on first use.
|
|
109
|
+
*
|
|
110
|
+
* Shared by the connection resolver and the bus: echo declares quasar an
|
|
111
|
+
* optional peer and never imports it statically, so an application that caches
|
|
112
|
+
* in memory neither installs it nor pays for it.
|
|
113
|
+
*/
|
|
114
|
+
async function loadQuasar(context: string): Promise<unknown> {
|
|
115
|
+
const specifier = "@c9up/quasar/services/main";
|
|
116
|
+
try {
|
|
117
|
+
const loaded = await import(/* @vite-ignore */ specifier);
|
|
118
|
+
return isConnectionSource(loaded) || isPubSubSource(loaded)
|
|
119
|
+
? loaded
|
|
120
|
+
: Reflect.get(Object(loaded), "default");
|
|
121
|
+
} catch (cause) {
|
|
122
|
+
throw new Error(
|
|
123
|
+
`Echo: ${context}, but @c9up/quasar is not installed.\n pnpm add @c9up/quasar`,
|
|
124
|
+
{ cause },
|
|
125
|
+
);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Redis pub/sub as a cache bus, for keeping each instance's L1 in step.
|
|
131
|
+
*
|
|
132
|
+
* A two-layer store without one is wrong the moment a second instance exists:
|
|
133
|
+
* each process keeps serving its own L1 copy of a key another process has
|
|
134
|
+
* already deleted, and nothing ever tells it. That is what the bus is for, and
|
|
135
|
+
* it is why `useBus` sits next to `useL2Layer` in the generated config.
|
|
136
|
+
*
|
|
137
|
+
* Quasar opens the subscriber socket lazily and on its own connection — Redis
|
|
138
|
+
* puts a subscribed client into a mode where it accepts nothing else, so a
|
|
139
|
+
* connection that both publishes and listens needs two.
|
|
140
|
+
*/
|
|
141
|
+
export function quasarBus(options?: {
|
|
142
|
+
connection?: string;
|
|
143
|
+
channel?: string;
|
|
144
|
+
}): CacheBus {
|
|
145
|
+
const channel = options?.channel ?? "echo::invalidate";
|
|
146
|
+
let ready: Promise<PubSubSource> | undefined;
|
|
147
|
+
|
|
148
|
+
const manager = (): Promise<PubSubSource> => {
|
|
149
|
+
ready ??= loadQuasar(
|
|
150
|
+
`the cache bus asks for the "${options?.connection ?? "default"}" quasar connection`,
|
|
151
|
+
).then((loaded) => {
|
|
152
|
+
const source = isConnectionSource(loaded)
|
|
153
|
+
? loaded.connection(options?.connection)
|
|
154
|
+
: loaded;
|
|
155
|
+
if (!isPubSubSource(source)) {
|
|
156
|
+
throw new Error(
|
|
157
|
+
"Echo: the quasar connection does not expose publish()/subscribe()",
|
|
158
|
+
);
|
|
159
|
+
}
|
|
160
|
+
return source;
|
|
161
|
+
});
|
|
162
|
+
return ready;
|
|
163
|
+
};
|
|
164
|
+
|
|
165
|
+
return {
|
|
166
|
+
async publish(message: BusMessage): Promise<void> {
|
|
167
|
+
const source = await manager();
|
|
168
|
+
await source.publish(channel, JSON.stringify(message));
|
|
169
|
+
},
|
|
170
|
+
subscribe(handler: (message: BusMessage) => void): void {
|
|
171
|
+
// Not awaited: `subscribe` is synchronous in the bus contract, and the
|
|
172
|
+
// socket opens on quasar's own schedule. A failure to reach Redis must
|
|
173
|
+
// not take down the store — a bus that is down costs staleness, and
|
|
174
|
+
// throwing here would cost every cache read.
|
|
175
|
+
void manager()
|
|
176
|
+
.then((source) =>
|
|
177
|
+
source.subscribe(channel, (raw: string) => {
|
|
178
|
+
try {
|
|
179
|
+
handler(JSON.parse(raw) as BusMessage);
|
|
180
|
+
} catch {
|
|
181
|
+
/* a malformed frame is not a reason to stop listening */
|
|
182
|
+
}
|
|
183
|
+
}),
|
|
184
|
+
)
|
|
185
|
+
.catch(() => {
|
|
186
|
+
/* reported by the first publish, which does surface its error */
|
|
187
|
+
});
|
|
188
|
+
},
|
|
189
|
+
};
|
|
190
|
+
}
|
package/src/types.ts
CHANGED
|
@@ -163,5 +163,16 @@ export interface CacheEventMap {
|
|
|
163
163
|
* (ream's emitter, Node's EventEmitter, mitt, …). echo never imports one.
|
|
164
164
|
*/
|
|
165
165
|
export interface CacheEmitter {
|
|
166
|
-
|
|
166
|
+
/**
|
|
167
|
+
* NAMED DEVIATION from `@adonisjs/events`, which declares
|
|
168
|
+
* `emit(): Promise<void>` on its own class. This is a DUCK-TYPE of an
|
|
169
|
+
* emitter echo does not own, so it must also accept a synchronous one — a
|
|
170
|
+
* Node `EventEmitter` returns `boolean`, for instance.
|
|
171
|
+
*
|
|
172
|
+
* `unknown` rather than `void`, because `void` ACCEPTS a
|
|
173
|
+
* promise-returning function and then reads as if there were nothing to
|
|
174
|
+
* handle: that is what hid an Adonis emitter's rejection here. Same choice
|
|
175
|
+
* warden's `AuthManager` already made.
|
|
176
|
+
*/
|
|
177
|
+
emit(event: string, payload: unknown): unknown;
|
|
167
178
|
}
|