@checkstack/backend 0.9.0 → 0.10.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 +195 -0
- package/package.json +14 -14
- package/src/index.ts +7 -2
- package/src/openapi-router.ts +38 -19
- package/src/plugin-manager/api-router.ts +268 -125
- package/src/plugin-manager/plugin-loader.ts +17 -2
- package/src/services/cache-manager.test.ts +172 -0
- package/src/services/cache-manager.ts +67 -14
- package/src/services/event-bus.test.ts +52 -0
- package/src/services/event-bus.ts +27 -1
- package/src/services/queue-manager.ts +77 -2
- package/src/services/queue-proxy.ts +7 -0
|
@@ -1,4 +1,10 @@
|
|
|
1
|
-
import type {
|
|
1
|
+
import type {
|
|
2
|
+
CacheManager,
|
|
3
|
+
CacheProvider,
|
|
4
|
+
CacheStats,
|
|
5
|
+
ListEntriesOptions,
|
|
6
|
+
ListEntriesResult,
|
|
7
|
+
} from "@checkstack/cache-api";
|
|
2
8
|
import type { CachePluginRegistryImpl } from "./cache-plugin-registry";
|
|
3
9
|
import type { Logger, ConfigService } from "@checkstack/backend-api";
|
|
4
10
|
import { z } from "zod";
|
|
@@ -18,6 +24,10 @@ type ActiveCachePointer = z.infer<typeof activeCachePointerSchema>;
|
|
|
18
24
|
/**
|
|
19
25
|
* A no-op CacheProvider used before any backend is configured.
|
|
20
26
|
* All operations are safe to call but behave as if the cache is empty.
|
|
27
|
+
*
|
|
28
|
+
* Held only as the initial value of `activeProvider` until
|
|
29
|
+
* `loadConfiguration()` runs; consumers always interact with the stable
|
|
30
|
+
* proxy below, so they pick up the real provider as soon as it's ready.
|
|
21
31
|
*/
|
|
22
32
|
const nullProvider: CacheProvider = {
|
|
23
33
|
// eslint-disable-next-line unicorn/no-useless-undefined
|
|
@@ -31,8 +41,18 @@ const nullProvider: CacheProvider = {
|
|
|
31
41
|
/**
|
|
32
42
|
* CacheManagerImpl handles cache provider lifecycle and backend switching.
|
|
33
43
|
*
|
|
34
|
-
*
|
|
35
|
-
*
|
|
44
|
+
* `getProvider()` returns a single stable proxy whose methods delegate to
|
|
45
|
+
* whichever provider is currently active. This means:
|
|
46
|
+
*
|
|
47
|
+
* - Plugins that capture the proxy at init time (e.g. via
|
|
48
|
+
* `createCachedScope`) keep functioning across backend switches and
|
|
49
|
+
* don't write into orphaned providers.
|
|
50
|
+
* - The Infrastructure runtime panel reads stats from the same instance
|
|
51
|
+
* plugins write to — no split-brain.
|
|
52
|
+
*
|
|
53
|
+
* The earlier "no proxy pattern needed since cache is stateless key/value"
|
|
54
|
+
* comment was wrong: in-memory backends are stateful and replacing the
|
|
55
|
+
* active reference would orphan their state.
|
|
36
56
|
*/
|
|
37
57
|
export class CacheManagerImpl implements CacheManager {
|
|
38
58
|
private activePluginId: string = "memory";
|
|
@@ -42,12 +62,49 @@ export class CacheManagerImpl implements CacheManager {
|
|
|
42
62
|
};
|
|
43
63
|
private configVersion: number = 0;
|
|
44
64
|
private activeProvider: CacheProvider = nullProvider;
|
|
65
|
+
private readonly providerProxy: CacheProvider;
|
|
45
66
|
|
|
46
67
|
constructor(
|
|
47
68
|
private registry: CachePluginRegistryImpl,
|
|
48
69
|
private configService: ConfigService,
|
|
49
70
|
private logger: Logger,
|
|
50
|
-
) {
|
|
71
|
+
) {
|
|
72
|
+
this.providerProxy = this.createProviderProxy();
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
private createProviderProxy(): CacheProvider {
|
|
76
|
+
return {
|
|
77
|
+
get: <T>(key: string) => this.activeProvider.get<T>(key),
|
|
78
|
+
set: <T>(key: string, value: T, ttlMs?: number) =>
|
|
79
|
+
this.activeProvider.set<T>(key, value, ttlMs),
|
|
80
|
+
delete: (key: string) => this.activeProvider.delete(key),
|
|
81
|
+
deleteByPrefix: (prefix: string) =>
|
|
82
|
+
this.activeProvider.deleteByPrefix(prefix),
|
|
83
|
+
has: (key: string) => this.activeProvider.has(key),
|
|
84
|
+
getStats: async (): Promise<CacheStats> => {
|
|
85
|
+
const provider = this.activeProvider;
|
|
86
|
+
if (provider.getStats) {
|
|
87
|
+
return provider.getStats();
|
|
88
|
+
}
|
|
89
|
+
return {
|
|
90
|
+
keyCount: null,
|
|
91
|
+
sizeBytes: null,
|
|
92
|
+
hits: null,
|
|
93
|
+
misses: null,
|
|
94
|
+
scope: "instance",
|
|
95
|
+
};
|
|
96
|
+
},
|
|
97
|
+
listEntries: async (
|
|
98
|
+
opts: ListEntriesOptions,
|
|
99
|
+
): Promise<ListEntriesResult> => {
|
|
100
|
+
const provider = this.activeProvider;
|
|
101
|
+
if (!provider.listEntries) {
|
|
102
|
+
return { items: [], total: 0, hasMore: false };
|
|
103
|
+
}
|
|
104
|
+
return provider.listEntries(opts);
|
|
105
|
+
},
|
|
106
|
+
};
|
|
107
|
+
}
|
|
51
108
|
|
|
52
109
|
async loadConfiguration(): Promise<void> {
|
|
53
110
|
try {
|
|
@@ -83,11 +140,10 @@ export class CacheManagerImpl implements CacheManager {
|
|
|
83
140
|
);
|
|
84
141
|
}
|
|
85
142
|
|
|
86
|
-
// Initialize the active provider
|
|
87
143
|
this.initializeProvider();
|
|
88
144
|
} catch (error) {
|
|
89
145
|
this.logger.error("Failed to load cache configuration", error);
|
|
90
|
-
// Continue with defaults — nullProvider is already set
|
|
146
|
+
// Continue with defaults — nullProvider is already set behind the proxy.
|
|
91
147
|
}
|
|
92
148
|
}
|
|
93
149
|
|
|
@@ -113,8 +169,12 @@ export class CacheManagerImpl implements CacheManager {
|
|
|
113
169
|
}
|
|
114
170
|
}
|
|
115
171
|
|
|
172
|
+
/**
|
|
173
|
+
* Returns the stable proxy. The same instance is returned for the
|
|
174
|
+
* lifetime of the manager, so callers can safely capture it once.
|
|
175
|
+
*/
|
|
116
176
|
getProvider(): CacheProvider {
|
|
117
|
-
return this.
|
|
177
|
+
return this.providerProxy;
|
|
118
178
|
}
|
|
119
179
|
|
|
120
180
|
getActivePlugin(): string {
|
|
@@ -126,21 +186,17 @@ export class CacheManagerImpl implements CacheManager {
|
|
|
126
186
|
}
|
|
127
187
|
|
|
128
188
|
async setActiveBackend(pluginId: string, config: unknown): Promise<void> {
|
|
129
|
-
// 1. Validate plugin exists
|
|
130
189
|
const newPlugin = this.registry.getPlugin(pluginId);
|
|
131
190
|
if (!newPlugin) {
|
|
132
191
|
throw new Error(`Cache plugin '${pluginId}' not found`);
|
|
133
192
|
}
|
|
134
193
|
|
|
135
|
-
// 2. Validate config against schema
|
|
136
194
|
newPlugin.configSchema.parse(config);
|
|
137
195
|
|
|
138
|
-
// 3. Create new provider (acts as connection test)
|
|
139
196
|
this.logger.info("🔍 Testing new cache provider...");
|
|
140
197
|
let newProvider: CacheProvider;
|
|
141
198
|
try {
|
|
142
199
|
newProvider = newPlugin.createProvider(config, this.logger);
|
|
143
|
-
// Quick smoke test
|
|
144
200
|
await newProvider.set("__test__", true, 1000);
|
|
145
201
|
await newProvider.delete("__test__");
|
|
146
202
|
this.logger.info("✅ Cache provider test successful");
|
|
@@ -150,20 +206,17 @@ export class CacheManagerImpl implements CacheManager {
|
|
|
150
206
|
throw new Error(`Failed to create cache provider: ${message}`);
|
|
151
207
|
}
|
|
152
208
|
|
|
153
|
-
// 4. Stop old provider if it has a stop method
|
|
154
209
|
const oldProvider = this.activeProvider;
|
|
155
210
|
if ("stop" in oldProvider && typeof oldProvider.stop === "function") {
|
|
156
211
|
await (oldProvider as { stop: () => Promise<void> }).stop();
|
|
157
212
|
}
|
|
158
213
|
|
|
159
|
-
// 5. Switch to new provider
|
|
160
214
|
const oldPluginId = this.activePluginId;
|
|
161
215
|
this.activePluginId = pluginId;
|
|
162
216
|
this.activeConfig = config;
|
|
163
217
|
this.configVersion++;
|
|
164
218
|
this.activeProvider = newProvider;
|
|
165
219
|
|
|
166
|
-
// 6. Persist configuration
|
|
167
220
|
await this.configService.set(
|
|
168
221
|
pluginId,
|
|
169
222
|
newPlugin.configSchema,
|
|
@@ -465,5 +465,57 @@ describe("EventBus", () => {
|
|
|
465
465
|
`No local listeners for hook: ${testHook.id}`
|
|
466
466
|
);
|
|
467
467
|
});
|
|
468
|
+
|
|
469
|
+
it("should drop emit (not enqueue) when no listeners are registered", async () => {
|
|
470
|
+
const testHook = createHook<{ value: number }>(
|
|
471
|
+
"test.emit.no.listeners",
|
|
472
|
+
);
|
|
473
|
+
|
|
474
|
+
// Capture queue creation: getQueue is invoked lazily on enqueue.
|
|
475
|
+
const before = (mockQueueManager as any).getQueue?.mock?.calls?.length ?? 0;
|
|
476
|
+
|
|
477
|
+
await eventBus.emit(testHook, { value: 1 });
|
|
478
|
+
await eventBus.emit(testHook, { value: 2 });
|
|
479
|
+
|
|
480
|
+
const after = (mockQueueManager as any).getQueue?.mock?.calls?.length ?? 0;
|
|
481
|
+
// No queue should be created and no enqueue should occur for an
|
|
482
|
+
// entirely unsubscribed hook — otherwise jobs would pile up forever.
|
|
483
|
+
expect(after).toBe(before);
|
|
484
|
+
|
|
485
|
+
expect(mockLogger.debug).toHaveBeenCalledWith(
|
|
486
|
+
`Dropped hook ${testHook.id}: no listeners registered`,
|
|
487
|
+
);
|
|
488
|
+
});
|
|
489
|
+
|
|
490
|
+
it("should enqueue when at least one distributed listener is registered", async () => {
|
|
491
|
+
const testHook = createHook<{ value: number }>(
|
|
492
|
+
"test.emit.with.listener",
|
|
493
|
+
);
|
|
494
|
+
const received: number[] = [];
|
|
495
|
+
await eventBus.subscribe("test-plugin", testHook, async (payload) => {
|
|
496
|
+
received.push(payload.value);
|
|
497
|
+
});
|
|
498
|
+
|
|
499
|
+
await eventBus.emit(testHook, { value: 7 });
|
|
500
|
+
// Allow the mock queue to deliver synchronously.
|
|
501
|
+
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
502
|
+
expect(received).toContain(7);
|
|
503
|
+
});
|
|
504
|
+
|
|
505
|
+
it("should enqueue when at least one instance-local listener is registered", async () => {
|
|
506
|
+
const testHook = createHook<{ value: number }>(
|
|
507
|
+
"test.emit.with.local.listener",
|
|
508
|
+
);
|
|
509
|
+
await eventBus.subscribe("test-plugin", testHook, async () => {}, {
|
|
510
|
+
mode: "instance-local",
|
|
511
|
+
});
|
|
512
|
+
|
|
513
|
+
// emit (distributed) should still enqueue because a local listener
|
|
514
|
+
// exists — drops are based on absence of *any* listener.
|
|
515
|
+
await eventBus.emit(testHook, { value: 1 });
|
|
516
|
+
expect(mockLogger.debug).not.toHaveBeenCalledWith(
|
|
517
|
+
`Dropped hook ${testHook.id}: no listeners registered`,
|
|
518
|
+
);
|
|
519
|
+
});
|
|
468
520
|
});
|
|
469
521
|
});
|
|
@@ -185,9 +185,35 @@ export class EventBus implements IEventBus {
|
|
|
185
185
|
}
|
|
186
186
|
|
|
187
187
|
/**
|
|
188
|
-
* Emit a hook
|
|
188
|
+
* Emit a hook.
|
|
189
|
+
*
|
|
190
|
+
* Skips the underlying queue enqueue when no listener has been
|
|
191
|
+
* registered for the hook in this process. Without this guard, hooks
|
|
192
|
+
* with no subscribers (e.g. `core.plugin.initialized` when no plugin
|
|
193
|
+
* has registered a listener) would accumulate jobs in the in-memory
|
|
194
|
+
* queue forever — they'd be enqueued, no consumer group exists to
|
|
195
|
+
* process them, and `processNext` short-circuits before its cleanup
|
|
196
|
+
* pass when `consumerGroups.size === 0`.
|
|
197
|
+
*
|
|
198
|
+
* Note: this checks listeners in the *local process*. In distributed
|
|
199
|
+
* deployments with a Redis-backed queue, a subscriber on another
|
|
200
|
+
* replica would never see the event under this rule. Callers that
|
|
201
|
+
* need cross-process delivery must therefore ensure at least one
|
|
202
|
+
* listener registers on every replica that should receive the hook.
|
|
189
203
|
*/
|
|
190
204
|
async emit<T>(hook: Hook<T>, payload: T): Promise<void> {
|
|
205
|
+
const hasDistributedListeners =
|
|
206
|
+
(this.listeners.get(hook.id)?.length ?? 0) > 0;
|
|
207
|
+
const hasLocalListeners =
|
|
208
|
+
(this.localListeners.get(hook.id)?.length ?? 0) > 0;
|
|
209
|
+
|
|
210
|
+
if (!hasDistributedListeners && !hasLocalListeners) {
|
|
211
|
+
this.logger.debug(
|
|
212
|
+
`Dropped hook ${hook.id}: no listeners registered`,
|
|
213
|
+
);
|
|
214
|
+
return;
|
|
215
|
+
}
|
|
216
|
+
|
|
191
217
|
let channel = this.queueChannels.get(hook.id);
|
|
192
218
|
|
|
193
219
|
// Create channel lazily if not exists
|
|
@@ -4,6 +4,9 @@ import type {
|
|
|
4
4
|
SwitchResult,
|
|
5
5
|
RecurringJobInfo,
|
|
6
6
|
QueueStats,
|
|
7
|
+
JobSummary,
|
|
8
|
+
ListJobsOptions,
|
|
9
|
+
ListJobsResult,
|
|
7
10
|
} from "@checkstack/queue-api";
|
|
8
11
|
import type { QueuePluginRegistryImpl } from "./queue-plugin-registry";
|
|
9
12
|
import type { Logger, ConfigService } from "@checkstack/backend-api";
|
|
@@ -276,7 +279,11 @@ export class QueueManagerImpl implements QueueManager {
|
|
|
276
279
|
}
|
|
277
280
|
|
|
278
281
|
async getAggregatedStats(): Promise<QueueStats> {
|
|
279
|
-
|
|
282
|
+
// The narrowest scope wins: if any queue reports `instance`, the
|
|
283
|
+
// aggregate cannot claim cluster-wide accuracy.
|
|
284
|
+
let scope: "instance" | "cluster" = "cluster";
|
|
285
|
+
let sawAny = false;
|
|
286
|
+
const aggregated: Omit<QueueStats, "scope"> = {
|
|
280
287
|
pending: 0,
|
|
281
288
|
processing: 0,
|
|
282
289
|
completed: 0,
|
|
@@ -294,13 +301,81 @@ export class QueueManagerImpl implements QueueManager {
|
|
|
294
301
|
aggregated.completed += stats.completed;
|
|
295
302
|
aggregated.failed += stats.failed;
|
|
296
303
|
aggregated.consumerGroups += stats.consumerGroups;
|
|
304
|
+
if (stats.scope === "instance") {
|
|
305
|
+
scope = "instance";
|
|
306
|
+
}
|
|
307
|
+
sawAny = true;
|
|
308
|
+
}
|
|
309
|
+
} catch {
|
|
310
|
+
// Queue may not be initialized yet
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
return { ...aggregated, scope: sawAny ? scope : "instance" };
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
/**
|
|
318
|
+
* Aggregate `listJobs` across every queue proxy. For deployments with a
|
|
319
|
+
* single queue (the common case) this is a straight pass-through; with
|
|
320
|
+
* multiple queues we over-fetch [0, offset+limit) per queue, merge-sort
|
|
321
|
+
* by the same key the per-queue impls use, then slice the requested
|
|
322
|
+
* window. Sort key:
|
|
323
|
+
* - completed/failed → finishedAt desc (most-recent first)
|
|
324
|
+
* - active → startedAt asc (oldest still-running first)
|
|
325
|
+
* - waiting/delayed → enqueuedAt asc (FIFO)
|
|
326
|
+
*/
|
|
327
|
+
async listJobs(opts: ListJobsOptions): Promise<ListJobsResult> {
|
|
328
|
+
const limit = Math.max(0, opts.limit);
|
|
329
|
+
const offset = Math.max(0, opts.offset);
|
|
330
|
+
if (limit === 0) {
|
|
331
|
+
return { items: [], total: 0, hasMore: false };
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
const fetchUpTo = offset + limit;
|
|
335
|
+
const merged: JobSummary[] = [];
|
|
336
|
+
let totalSum = 0;
|
|
337
|
+
let totalIsNull = false;
|
|
338
|
+
|
|
339
|
+
for (const proxy of this.queueProxies.values()) {
|
|
340
|
+
try {
|
|
341
|
+
const delegate = proxy.getDelegate();
|
|
342
|
+
if (delegate) {
|
|
343
|
+
const part = await delegate.listJobs({
|
|
344
|
+
state: opts.state,
|
|
345
|
+
offset: 0,
|
|
346
|
+
limit: fetchUpTo,
|
|
347
|
+
});
|
|
348
|
+
merged.push(...part.items);
|
|
349
|
+
if (part.total === null) {
|
|
350
|
+
totalIsNull = true;
|
|
351
|
+
} else {
|
|
352
|
+
totalSum += part.total;
|
|
353
|
+
}
|
|
297
354
|
}
|
|
298
355
|
} catch {
|
|
299
356
|
// Queue may not be initialized yet
|
|
300
357
|
}
|
|
301
358
|
}
|
|
302
359
|
|
|
303
|
-
|
|
360
|
+
const sortKey = (j: JobSummary): number => {
|
|
361
|
+
if (opts.state === "completed" || opts.state === "failed") {
|
|
362
|
+
return -(j.finishedAt?.getTime() ?? 0);
|
|
363
|
+
}
|
|
364
|
+
if (opts.state === "active") {
|
|
365
|
+
return j.startedAt?.getTime() ?? 0;
|
|
366
|
+
}
|
|
367
|
+
return j.enqueuedAt.getTime();
|
|
368
|
+
};
|
|
369
|
+
merged.sort((a, b) => sortKey(a) - sortKey(b));
|
|
370
|
+
|
|
371
|
+
const items = merged.slice(offset, offset + limit);
|
|
372
|
+
const total = totalIsNull ? null : totalSum;
|
|
373
|
+
const hasMore =
|
|
374
|
+
total === null
|
|
375
|
+
? merged.length > offset + items.length
|
|
376
|
+
: offset + items.length < total;
|
|
377
|
+
|
|
378
|
+
return { items, total, hasMore };
|
|
304
379
|
}
|
|
305
380
|
|
|
306
381
|
async listAllRecurringJobs(): Promise<RecurringJobInfo[]> {
|
|
@@ -5,6 +5,8 @@ import type {
|
|
|
5
5
|
QueueStats,
|
|
6
6
|
RecurringJobDetails,
|
|
7
7
|
RecurringSchedule,
|
|
8
|
+
ListJobsOptions,
|
|
9
|
+
ListJobsResult,
|
|
8
10
|
} from "@checkstack/queue-api";
|
|
9
11
|
import { rootLogger } from "../logger";
|
|
10
12
|
|
|
@@ -173,6 +175,11 @@ export class QueueProxy<T = unknown> implements Queue<T> {
|
|
|
173
175
|
return delegate.getStats();
|
|
174
176
|
}
|
|
175
177
|
|
|
178
|
+
async listJobs(opts: ListJobsOptions): Promise<ListJobsResult> {
|
|
179
|
+
const delegate = this.ensureDelegate();
|
|
180
|
+
return delegate.listJobs(opts);
|
|
181
|
+
}
|
|
182
|
+
|
|
176
183
|
/**
|
|
177
184
|
* Get subscription count (for testing/debugging)
|
|
178
185
|
*/
|