@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.
@@ -1,4 +1,10 @@
1
- import type { CacheManager, CacheProvider } from "@checkstack/cache-api";
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
- * Simpler than QueueManagerImpl no proxy pattern needed since cache is
35
- * stateless key/value. The active provider is replaced atomically on backend switch.
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.activeProvider;
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
- const aggregated: QueueStats = {
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
- return aggregated;
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
  */