@checkstack/backend 0.9.0 → 0.9.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/CHANGELOG.md +130 -0
- package/package.json +14 -14
- 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
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,135 @@
|
|
|
1
1
|
# @checkstack/backend
|
|
2
2
|
|
|
3
|
+
## 0.9.1
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- aa89bc5: Replace the bespoke `registerInfrastructureTab()` registry with a standard
|
|
8
|
+
slot-extension contract (`InfrastructureTabsSlot` from
|
|
9
|
+
`@checkstack/infrastructure-common`). Plugins now contribute infrastructure
|
|
10
|
+
tabs via `createSlotExtension`, depending only on the slot owner.
|
|
11
|
+
|
|
12
|
+
The slot system in `@checkstack/frontend-api` gains a second type parameter
|
|
13
|
+
on `createSlot<TContext, TMetadata>` so extensions can declare typed static
|
|
14
|
+
metadata at registration time (label, icon, access rules, ordering for the
|
|
15
|
+
infrastructure tab bar). A new `useSlotExtensions(slot)` hook returns typed
|
|
16
|
+
extensions and subscribes to plugin lifecycle changes.
|
|
17
|
+
|
|
18
|
+
Each tab body now stacks a **Runtime** sub-section (live state, read-only)
|
|
19
|
+
on top of a **Configuration** sub-section (settings, gated by `canUpdate`).
|
|
20
|
+
|
|
21
|
+
**Queue runtime panel.** Surfaces aggregated counts (pending / processing /
|
|
22
|
+
completed / failed) plus three sub-tabs of recent jobs: **Active**, **Recent
|
|
23
|
+
failed** (with the failure message), and **Recent completed** (with
|
|
24
|
+
duration). Job payloads are deliberately not surfaced — they may carry
|
|
25
|
+
secrets and need a separate manage-access gate to be shown.
|
|
26
|
+
|
|
27
|
+
To support this, `Queue<T>` gains a required `listJobs(opts)` method
|
|
28
|
+
returning `JobSummary[]` (no payloads), and `QueueStats` gains a
|
|
29
|
+
`scope: "instance" | "cluster"` field. The in-memory queue keeps rolling
|
|
30
|
+
ring buffers (200 entries) for completed/failed history and tracks active
|
|
31
|
+
jobs by id; BullMQ uses native `getJobs`. `QueueManager.listJobs` aggregates
|
|
32
|
+
across queues and sorts (most-recent-first for terminal states, FIFO for
|
|
33
|
+
active/waiting/delayed).
|
|
34
|
+
|
|
35
|
+
**Cache runtime panel.** Lists the top N entries by size (or by recency) so
|
|
36
|
+
operators can debug a cache filling up. Values are deliberately omitted —
|
|
37
|
+
PII / secret risk. Backends opt in via an optional `listEntries?` method on
|
|
38
|
+
`CacheProvider`; non-supporting backends return `{ supported: false }` and
|
|
39
|
+
the UI renders a "not supported by this backend" hint. The in-memory cache
|
|
40
|
+
implements it using its existing per-entry byte tracking.
|
|
41
|
+
|
|
42
|
+
`CacheStats` also gains `scope: "instance" | "cluster"`.
|
|
43
|
+
|
|
44
|
+
**Multi-instance scope warning.** A new `<InstanceScopeBanner>` component in
|
|
45
|
+
`@checkstack/ui` renders a yellow banner above any runtime panel whose
|
|
46
|
+
backend reports `scope: "instance"` — i.e. in-memory queue or cache running
|
|
47
|
+
in a horizontally scaled deployment. The banner explains the metrics are
|
|
48
|
+
local to the responding replica and recommends switching to a clustered
|
|
49
|
+
backend (Redis-backed queue / cache) for cluster-wide visibility.
|
|
50
|
+
|
|
51
|
+
**Bug fix — stable cache provider proxy.** `CacheManagerImpl.getProvider()`
|
|
52
|
+
now returns a single stable proxy that delegates to whatever provider is
|
|
53
|
+
currently active. Previously, consumers of `createCachedScope` (and any
|
|
54
|
+
direct `cacheManager.getProvider()` caller) captured the active provider
|
|
55
|
+
reference at plugin-init time. After any `setActiveBackend` call — including
|
|
56
|
+
saving the same memory config in the new Cache tab, which reconstructs the
|
|
57
|
+
in-memory cache — those scopes wrote to an orphaned old provider while the
|
|
58
|
+
runtime panel read stats from the new (empty) one, making the runtime panel
|
|
59
|
+
appear to report 0 keys. With the proxy, all consumers share a single stable
|
|
60
|
+
identity and writes always land in the active provider.
|
|
61
|
+
|
|
62
|
+
**Bytes tracking on the in-memory cache.** `InMemoryCache.getStats().sizeBytes`
|
|
63
|
+
now returns a running approximation (UTF-8 bytes of the key plus
|
|
64
|
+
`v8.serialize(value).byteLength`, with a JSON fallback) that's kept in sync
|
|
65
|
+
across all eviction paths. Treat the number as a sanity gauge; it doesn't
|
|
66
|
+
include `Map` per-entry overhead.
|
|
67
|
+
|
|
68
|
+
**Pagination.** Both `Queue<T>.listJobs` and `CacheProvider.listEntries?`
|
|
69
|
+
are offset-paginated. Inputs gain an `offset: number`; outputs change to
|
|
70
|
+
`{ items, total: number | null, hasMore: boolean }`. `total` is nullable
|
|
71
|
+
so backends that can't compute it cheaply still paginate via `hasMore`.
|
|
72
|
+
The UI uses the existing `<Pagination>` component with a 25-row default
|
|
73
|
+
page size. `QueueManager.listJobs` aggregates by over-fetching
|
|
74
|
+
`[0, offset+limit)` per queue, merge-sorting, then slicing the window —
|
|
75
|
+
optimal for the single-queue case, acceptable for the multi-queue case
|
|
76
|
+
within the UI's reasonable page-depth bounds. BullMQ uses native offset
|
|
77
|
+
ranges via `getJobs(types, start, end)` plus `getJobCounts` for `total`.
|
|
78
|
+
|
|
79
|
+
**Pending tab.** The Queue runtime panel exposes a virtual `"pending"`
|
|
80
|
+
state (waiting ∪ delayed, FIFO). It's now the default sub-tab, since
|
|
81
|
+
"what's queued up?" is the most common question. Per-row state is shown
|
|
82
|
+
when viewing the combined list.
|
|
83
|
+
|
|
84
|
+
**Recurring schedules visible under Pending.** Cron- and interval-based
|
|
85
|
+
recurring jobs (e.g. healthchecks) are surfaced under Pending/Delayed
|
|
86
|
+
between fires, with a `nextRunAt` countdown column and a "(recurring)"
|
|
87
|
+
label. `JobSummary` gains optional `nextRunAt: Date` and `recurring:
|
|
88
|
+
boolean` fields. The in-memory queue synthesises these rows from its
|
|
89
|
+
`recurringJobs` registry; BullMQ already materialises the next fire of
|
|
90
|
+
each scheduler as a delayed job and we now surface its trigger time and
|
|
91
|
+
the `repeatJobKey`-derived `recurring` flag.
|
|
92
|
+
|
|
93
|
+
**Bug fix — drop hook emits with no listeners.** `EventBus.emit` no
|
|
94
|
+
longer enqueues a job when zero listeners (distributed or instance-local)
|
|
95
|
+
are registered for the hook. Previously, hooks like
|
|
96
|
+
`core.plugin.initialized` — emitted on every plugin init but subscribed
|
|
97
|
+
to by nothing in the core repo — accumulated one waiting job per emit
|
|
98
|
+
forever. The in-memory queue's `processNext` short-circuits when there
|
|
99
|
+
are zero consumer groups, so its post-loop cleanup never ran for these
|
|
100
|
+
orphaned jobs. The fix drops the emit at the source and logs a debug
|
|
101
|
+
line. Note: in distributed deployments using a Redis-backed queue, this
|
|
102
|
+
means a subscriber on another replica won't receive an event if no
|
|
103
|
+
replica that emits it has a local listener. Plugins needing cross-process
|
|
104
|
+
delivery must register their listener on every replica that should
|
|
105
|
+
receive the hook.
|
|
106
|
+
|
|
107
|
+
**Breaking notes (treated as minor under beta semantics)**:
|
|
108
|
+
|
|
109
|
+
- `@checkstack/infrastructure-common` removes `registerInfrastructureTab`
|
|
110
|
+
and `getInfrastructureTabs`; former callers must register an extension
|
|
111
|
+
into `InfrastructureTabsSlot`.
|
|
112
|
+
- `@checkstack/queue-api`'s `Queue<T>` interface requires the new
|
|
113
|
+
`listJobs(opts)` method returning `ListJobsResult` (paginated). Both
|
|
114
|
+
bundled queue backends (memory, BullMQ) are updated; out-of-tree
|
|
115
|
+
implementations will need to add it.
|
|
116
|
+
- `QueueStats` and `CacheStats` add a required `scope` field.
|
|
117
|
+
- `CacheProvider.listEntries?` (when implemented) now returns
|
|
118
|
+
`ListEntriesResult` instead of `CacheEntrySummary[]`.
|
|
119
|
+
- `JobState` adds a `"pending"` variant.
|
|
120
|
+
|
|
121
|
+
- Updated dependencies [42abfff]
|
|
122
|
+
- Updated dependencies [aa89bc5]
|
|
123
|
+
- @checkstack/common@0.9.0
|
|
124
|
+
- @checkstack/queue-api@0.3.0
|
|
125
|
+
- @checkstack/cache-api@0.3.0
|
|
126
|
+
- @checkstack/api-docs-common@0.1.12
|
|
127
|
+
- @checkstack/auth-common@0.6.6
|
|
128
|
+
- @checkstack/backend-api@0.15.1
|
|
129
|
+
- @checkstack/pluginmanager-common@0.2.1
|
|
130
|
+
- @checkstack/signal-backend@0.2.4
|
|
131
|
+
- @checkstack/signal-common@0.2.2
|
|
132
|
+
|
|
3
133
|
## 0.9.0
|
|
4
134
|
|
|
5
135
|
### Minor Changes
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@checkstack/backend",
|
|
3
|
-
"version": "0.9.
|
|
3
|
+
"version": "0.9.1",
|
|
4
4
|
"license": "Elastic-2.0",
|
|
5
5
|
"checkstack": {
|
|
6
6
|
"type": "backend"
|
|
@@ -14,16 +14,16 @@
|
|
|
14
14
|
"lint:code": "eslint . --max-warnings 0"
|
|
15
15
|
},
|
|
16
16
|
"dependencies": {
|
|
17
|
-
"@checkstack/api-docs-common": "0.1.
|
|
18
|
-
"@checkstack/auth-common": "0.6.
|
|
19
|
-
"@checkstack/backend-api": "0.
|
|
20
|
-
"@checkstack/common": "0.
|
|
21
|
-
"@checkstack/drizzle-helper": "0.0.
|
|
22
|
-
"@checkstack/cache-api": "0.2.
|
|
23
|
-
"@checkstack/queue-api": "0.2.
|
|
24
|
-
"@checkstack/signal-backend": "0.2.
|
|
25
|
-
"@checkstack/signal-common": "0.2.
|
|
26
|
-
"@checkstack/pluginmanager-common": "0.
|
|
17
|
+
"@checkstack/api-docs-common": "0.1.11",
|
|
18
|
+
"@checkstack/auth-common": "0.6.5",
|
|
19
|
+
"@checkstack/backend-api": "0.15.0",
|
|
20
|
+
"@checkstack/common": "0.8.0",
|
|
21
|
+
"@checkstack/drizzle-helper": "0.0.5",
|
|
22
|
+
"@checkstack/cache-api": "0.2.4",
|
|
23
|
+
"@checkstack/queue-api": "0.2.18",
|
|
24
|
+
"@checkstack/signal-backend": "0.2.3",
|
|
25
|
+
"@checkstack/signal-common": "0.2.1",
|
|
26
|
+
"@checkstack/pluginmanager-common": "0.2.0",
|
|
27
27
|
"@hono/zod-validator": "^0.7.6",
|
|
28
28
|
"@orpc/client": "^1.13.14",
|
|
29
29
|
"@orpc/contract": "^1.13.14",
|
|
@@ -44,9 +44,9 @@
|
|
|
44
44
|
"@types/pg": "^8.11.0",
|
|
45
45
|
"@types/bun": "latest",
|
|
46
46
|
"@types/semver": "^7.5.0",
|
|
47
|
-
"@checkstack/tsconfig": "0.0.
|
|
48
|
-
"@checkstack/scripts": "0.
|
|
49
|
-
"@checkstack/test-utils-backend": "0.1.
|
|
47
|
+
"@checkstack/tsconfig": "0.0.7",
|
|
48
|
+
"@checkstack/scripts": "0.3.0",
|
|
49
|
+
"@checkstack/test-utils-backend": "0.1.24",
|
|
50
50
|
"drizzle-kit": "^0.31.10"
|
|
51
51
|
}
|
|
52
52
|
}
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach } from "bun:test";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
import { CacheManagerImpl } from "./cache-manager";
|
|
4
|
+
import { CachePluginRegistryImpl } from "./cache-plugin-registry";
|
|
5
|
+
import type { CachePlugin, CacheProvider, CacheStats } from "@checkstack/cache-api";
|
|
6
|
+
import type { ConfigService, Logger } from "@checkstack/backend-api";
|
|
7
|
+
import { createMockLogger } from "@checkstack/test-utils-backend";
|
|
8
|
+
|
|
9
|
+
class FakeInMemoryCache implements CacheProvider {
|
|
10
|
+
store = new Map<string, unknown>();
|
|
11
|
+
hits = 0;
|
|
12
|
+
misses = 0;
|
|
13
|
+
|
|
14
|
+
async get<T>(key: string): Promise<T | undefined> {
|
|
15
|
+
if (this.store.has(key)) {
|
|
16
|
+
this.hits++;
|
|
17
|
+
return this.store.get(key) as T;
|
|
18
|
+
}
|
|
19
|
+
this.misses++;
|
|
20
|
+
return undefined;
|
|
21
|
+
}
|
|
22
|
+
async set<T>(key: string, value: T): Promise<void> {
|
|
23
|
+
this.store.set(key, value);
|
|
24
|
+
}
|
|
25
|
+
async delete(key: string): Promise<void> {
|
|
26
|
+
this.store.delete(key);
|
|
27
|
+
}
|
|
28
|
+
async deleteByPrefix(): Promise<number> {
|
|
29
|
+
return 0;
|
|
30
|
+
}
|
|
31
|
+
async has(key: string): Promise<boolean> {
|
|
32
|
+
return this.store.has(key);
|
|
33
|
+
}
|
|
34
|
+
async getStats(): Promise<CacheStats> {
|
|
35
|
+
return {
|
|
36
|
+
keyCount: this.store.size,
|
|
37
|
+
sizeBytes: null,
|
|
38
|
+
hits: this.hits,
|
|
39
|
+
misses: this.misses,
|
|
40
|
+
scope: "instance",
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const memoryConfigSchema = z.record(z.string(), z.unknown());
|
|
46
|
+
|
|
47
|
+
const createMemoryPlugin = (
|
|
48
|
+
factory: () => FakeInMemoryCache,
|
|
49
|
+
): CachePlugin<unknown> => ({
|
|
50
|
+
id: "memory",
|
|
51
|
+
displayName: "Memory",
|
|
52
|
+
configVersion: 1,
|
|
53
|
+
configSchema: memoryConfigSchema,
|
|
54
|
+
createProvider: () => factory(),
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
class StubConfigService implements ConfigService {
|
|
58
|
+
store = new Map<string, unknown>();
|
|
59
|
+
async set<T>(configId: string, _s: unknown, _v: number, data: T) {
|
|
60
|
+
this.store.set(configId, data);
|
|
61
|
+
}
|
|
62
|
+
async get<T>(configId: string): Promise<T | undefined> {
|
|
63
|
+
return this.store.get(configId) as T | undefined;
|
|
64
|
+
}
|
|
65
|
+
async getRedacted<T>(configId: string): Promise<Partial<T> | undefined> {
|
|
66
|
+
return this.store.get(configId) as Partial<T> | undefined;
|
|
67
|
+
}
|
|
68
|
+
async delete(configId: string): Promise<void> {
|
|
69
|
+
this.store.delete(configId);
|
|
70
|
+
}
|
|
71
|
+
async list(): Promise<string[]> {
|
|
72
|
+
return [...this.store.keys()];
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
describe("CacheManagerImpl provider proxy", () => {
|
|
77
|
+
let registry: CachePluginRegistryImpl;
|
|
78
|
+
let configService: StubConfigService;
|
|
79
|
+
let logger: Logger;
|
|
80
|
+
let manager: CacheManagerImpl;
|
|
81
|
+
let instances: FakeInMemoryCache[];
|
|
82
|
+
|
|
83
|
+
beforeEach(() => {
|
|
84
|
+
registry = new CachePluginRegistryImpl();
|
|
85
|
+
configService = new StubConfigService();
|
|
86
|
+
logger = createMockLogger() as unknown as Logger;
|
|
87
|
+
instances = [];
|
|
88
|
+
registry.register(
|
|
89
|
+
createMemoryPlugin(() => {
|
|
90
|
+
const inst = new FakeInMemoryCache();
|
|
91
|
+
instances.push(inst);
|
|
92
|
+
return inst;
|
|
93
|
+
}),
|
|
94
|
+
);
|
|
95
|
+
manager = new CacheManagerImpl(registry, configService, logger);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it("returns the same proxy reference across calls", async () => {
|
|
99
|
+
const a = manager.getProvider();
|
|
100
|
+
const b = manager.getProvider();
|
|
101
|
+
expect(a).toBe(b);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it("delegates writes to the active provider after loadConfiguration", async () => {
|
|
105
|
+
const provider = manager.getProvider();
|
|
106
|
+
await manager.loadConfiguration();
|
|
107
|
+
await provider.set("foo", "bar");
|
|
108
|
+
expect(instances[0].store.get("foo")).toBe("bar");
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it("keeps a captured proxy reference live across setActiveBackend", async () => {
|
|
112
|
+
await manager.loadConfiguration();
|
|
113
|
+
const captured = manager.getProvider();
|
|
114
|
+
await captured.set("before", 1);
|
|
115
|
+
|
|
116
|
+
await manager.setActiveBackend("memory", {
|
|
117
|
+
maxEntries: 5000,
|
|
118
|
+
sweepIntervalMs: 30_000,
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
// The captured reference must now write to the NEW active provider,
|
|
122
|
+
// not the orphaned old one.
|
|
123
|
+
await captured.set("after", 2);
|
|
124
|
+
|
|
125
|
+
const oldInstance = instances[0];
|
|
126
|
+
const newInstance = instances.at(-1)!;
|
|
127
|
+
expect(oldInstance).not.toBe(newInstance);
|
|
128
|
+
expect(oldInstance.store.has("after")).toBe(false);
|
|
129
|
+
expect(newInstance.store.has("after")).toBe(true);
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it("getStats reads from the currently active provider", async () => {
|
|
133
|
+
await manager.loadConfiguration();
|
|
134
|
+
const provider = manager.getProvider();
|
|
135
|
+
await provider.set("k1", "v1");
|
|
136
|
+
await provider.get("k1"); // hit
|
|
137
|
+
await provider.get("missing"); // miss
|
|
138
|
+
|
|
139
|
+
const stats = await provider.getStats!();
|
|
140
|
+
expect(stats.keyCount).toBe(1);
|
|
141
|
+
expect(stats.hits).toBe(1);
|
|
142
|
+
expect(stats.misses).toBe(1);
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
it("getStats returns all-null when active provider has no getStats impl", async () => {
|
|
146
|
+
// Plugin whose provider doesn't implement getStats.
|
|
147
|
+
const minimalPlugin: CachePlugin<unknown> = {
|
|
148
|
+
id: "noStats",
|
|
149
|
+
displayName: "No stats",
|
|
150
|
+
configVersion: 1,
|
|
151
|
+
configSchema: memoryConfigSchema,
|
|
152
|
+
createProvider: (): CacheProvider => ({
|
|
153
|
+
get: async () => undefined,
|
|
154
|
+
set: async () => {},
|
|
155
|
+
delete: async () => {},
|
|
156
|
+
deleteByPrefix: async () => 0,
|
|
157
|
+
has: async () => false,
|
|
158
|
+
}),
|
|
159
|
+
};
|
|
160
|
+
registry.register(minimalPlugin);
|
|
161
|
+
|
|
162
|
+
await manager.setActiveBackend("noStats", {});
|
|
163
|
+
const stats = await manager.getProvider().getStats!();
|
|
164
|
+
expect(stats).toEqual({
|
|
165
|
+
keyCount: null,
|
|
166
|
+
sizeBytes: null,
|
|
167
|
+
hits: null,
|
|
168
|
+
misses: null,
|
|
169
|
+
scope: "instance",
|
|
170
|
+
});
|
|
171
|
+
});
|
|
172
|
+
});
|
|
@@ -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
|
*/
|