@gobing-ai/ts-infra 0.3.9 → 0.3.11
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/README.md +96 -22
- package/dist/api-client.d.ts +44 -1
- package/dist/api-client.d.ts.map +1 -1
- package/dist/api-client.js +144 -22
- package/dist/application/index.d.ts.map +1 -1
- package/dist/application/index.js +3 -3
- package/dist/application/plugins/builtins.d.ts.map +1 -1
- package/dist/application/plugins/builtins.js +9 -7
- package/dist/application/types.d.ts +1 -1
- package/dist/application-node.d.ts +18 -2
- package/dist/application-node.d.ts.map +1 -1
- package/dist/application-node.js +12 -7
- package/dist/event-bus/event-bus.d.ts +22 -2
- package/dist/event-bus/event-bus.d.ts.map +1 -1
- package/dist/event-bus/event-bus.js +63 -3
- package/dist/event-bus/index.d.ts +1 -1
- package/dist/event-bus/index.d.ts.map +1 -1
- package/dist/event-bus/types.d.ts +18 -0
- package/dist/event-bus/types.d.ts.map +1 -1
- package/dist/events.d.ts +5 -2
- package/dist/events.d.ts.map +1 -1
- package/dist/events.js +5 -2
- package/dist/index.d.ts +2 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/dist/job-queue/db-job-queue.d.ts +5 -1
- package/dist/job-queue/db-job-queue.d.ts.map +1 -1
- package/dist/job-queue/db-job-queue.js +55 -3
- package/dist/job-queue/types.d.ts +8 -0
- package/dist/job-queue/types.d.ts.map +1 -1
- package/dist/scheduler/action.js +1 -1
- package/dist/scheduler/node.d.ts.map +1 -1
- package/dist/scheduler/node.js +21 -11
- package/dist/scheduler/wrap-handler.js +1 -1
- package/dist/telemetry/metrics.d.ts.map +1 -1
- package/dist/telemetry/metrics.js +21 -1
- package/dist/telemetry/sdk.d.ts +7 -2
- package/dist/telemetry/sdk.d.ts.map +1 -1
- package/dist/telemetry/tracing.d.ts.map +1 -1
- package/dist/telemetry/tracing.js +18 -2
- package/package.json +5 -5
- package/src/api-client.ts +212 -24
- package/src/application/index.ts +4 -3
- package/src/application/plugins/builtins.ts +9 -7
- package/src/application/types.ts +1 -1
- package/src/application-node.ts +33 -10
- package/src/event-bus/event-bus.ts +72 -5
- package/src/event-bus/index.ts +1 -0
- package/src/event-bus/types.ts +19 -0
- package/src/events.ts +5 -2
- package/src/index.ts +9 -1
- package/src/job-queue/db-job-queue.ts +59 -4
- package/src/job-queue/types.ts +9 -0
- package/src/scheduler/action.ts +1 -1
- package/src/scheduler/node.ts +20 -12
- package/src/scheduler/wrap-handler.ts +1 -1
- package/src/telemetry/metrics.ts +20 -1
- package/src/telemetry/sdk.ts +7 -2
- package/src/telemetry/tracing.ts +20 -2
|
@@ -1,8 +1,9 @@
|
|
|
1
|
-
import type { JobQueue } from '../job-queue/types';
|
|
1
|
+
import type { JobHandler, JobQueue } from '../job-queue/types';
|
|
2
2
|
import { getLogger, type Logger } from '../logger';
|
|
3
3
|
import { getEventbusEmitsTotal, getEventbusErrorsTotal } from '../telemetry/metrics';
|
|
4
4
|
import type {
|
|
5
5
|
AsyncEnqueuedDetail,
|
|
6
|
+
AsyncEventJobPayload,
|
|
6
7
|
BusLifecycleEvents,
|
|
7
8
|
EmitDoneDetail,
|
|
8
9
|
EventMap,
|
|
@@ -23,22 +24,25 @@ function busLogger(): Logger {
|
|
|
23
24
|
export class EventBus<TEvents extends EventMap> {
|
|
24
25
|
private readonly syncHandlers = new Map<keyof TEvents, Set<TEvents[keyof TEvents]>>();
|
|
25
26
|
private readonly asyncHandlers = new Map<keyof TEvents, Set<TEvents[keyof TEvents]>>();
|
|
26
|
-
private readonly asyncHandlerIds = new
|
|
27
|
+
private readonly asyncHandlerIds = new Map<TEvents[keyof TEvents], string>();
|
|
28
|
+
private readonly asyncHandlersById = new Map<string, TEvents[keyof TEvents]>();
|
|
27
29
|
private readonly jobQueue: JobQueue | null;
|
|
28
30
|
private readonly lifecycleBus: EventBus<BusLifecycleEvents> | null;
|
|
31
|
+
private readonly logger: Logger | undefined;
|
|
29
32
|
private nextAsyncHandlerId = 0;
|
|
30
|
-
|
|
31
33
|
constructor(opts?: {
|
|
32
34
|
jobQueue?: JobQueue;
|
|
33
35
|
lifecycleBus?: EventBus<BusLifecycleEvents>;
|
|
36
|
+
logger?: Logger;
|
|
34
37
|
}) {
|
|
35
38
|
this.jobQueue = opts?.jobQueue ?? null;
|
|
36
39
|
this.lifecycleBus = opts?.lifecycleBus ?? null;
|
|
40
|
+
this.logger = opts?.logger;
|
|
37
41
|
}
|
|
38
42
|
|
|
39
43
|
on<K extends keyof TEvents>(event: K, handler: TEvents[K], opts?: SubscribeOptions): void {
|
|
40
44
|
if (opts?.async) {
|
|
41
|
-
this.registerAsync(event, handler);
|
|
45
|
+
this.registerAsync(event, handler, opts.name);
|
|
42
46
|
} else {
|
|
43
47
|
this.registerSync(event, handler);
|
|
44
48
|
}
|
|
@@ -68,16 +72,23 @@ export class EventBus<TEvents extends EventMap> {
|
|
|
68
72
|
if (asyncSet.size === 0) {
|
|
69
73
|
this.asyncHandlers.delete(event);
|
|
70
74
|
}
|
|
75
|
+
this.releaseAsyncIdIfUnsubscribed(handler);
|
|
71
76
|
}
|
|
72
77
|
}
|
|
73
78
|
|
|
74
79
|
removeAllListeners<K extends keyof TEvents>(event?: K): void {
|
|
75
80
|
if (event !== undefined) {
|
|
76
81
|
this.syncHandlers.delete(event);
|
|
82
|
+
const asyncSet = this.asyncHandlers.get(event);
|
|
77
83
|
this.asyncHandlers.delete(event);
|
|
84
|
+
if (asyncSet) {
|
|
85
|
+
for (const handler of asyncSet) this.releaseAsyncIdIfUnsubscribed(handler);
|
|
86
|
+
}
|
|
78
87
|
} else {
|
|
79
88
|
this.syncHandlers.clear();
|
|
80
89
|
this.asyncHandlers.clear();
|
|
90
|
+
this.asyncHandlerIds.clear();
|
|
91
|
+
this.asyncHandlersById.clear();
|
|
81
92
|
}
|
|
82
93
|
}
|
|
83
94
|
|
|
@@ -88,6 +99,12 @@ export class EventBus<TEvents extends EventMap> {
|
|
|
88
99
|
let asyncCount = 0;
|
|
89
100
|
let errors = 0;
|
|
90
101
|
|
|
102
|
+
this.logger?.debug('event.emit', {
|
|
103
|
+
event: eventName,
|
|
104
|
+
syncHandlers: this.syncHandlers.get(event)?.size ?? 0,
|
|
105
|
+
asyncHandlers: this.asyncHandlers.get(event)?.size ?? 0,
|
|
106
|
+
});
|
|
107
|
+
|
|
91
108
|
const syncSet = this.syncHandlers.get(event);
|
|
92
109
|
if (syncSet) {
|
|
93
110
|
syncCount = syncSet.size;
|
|
@@ -179,13 +196,22 @@ export class EventBus<TEvents extends EventMap> {
|
|
|
179
196
|
set.add(handler);
|
|
180
197
|
}
|
|
181
198
|
|
|
182
|
-
private registerAsync<K extends keyof TEvents>(event: K, handler: TEvents[K]): void {
|
|
199
|
+
private registerAsync<K extends keyof TEvents>(event: K, handler: TEvents[K], name?: string): void {
|
|
183
200
|
let set = this.asyncHandlers.get(event);
|
|
184
201
|
if (!set) {
|
|
185
202
|
set = new Set();
|
|
186
203
|
this.asyncHandlers.set(event, set);
|
|
187
204
|
}
|
|
188
205
|
set.add(handler);
|
|
206
|
+
|
|
207
|
+
if (!this.asyncHandlerIds.has(handler)) {
|
|
208
|
+
const id = name ?? `handler-${++this.nextAsyncHandlerId}`;
|
|
209
|
+
if (this.asyncHandlersById.has(id)) {
|
|
210
|
+
throw new Error(`Duplicate async handler name: "${id}"`);
|
|
211
|
+
}
|
|
212
|
+
this.asyncHandlerIds.set(handler, id);
|
|
213
|
+
this.asyncHandlersById.set(id, handler);
|
|
214
|
+
}
|
|
189
215
|
}
|
|
190
216
|
|
|
191
217
|
private getAsyncHandlerId(handler: TEvents[keyof TEvents]): string {
|
|
@@ -194,9 +220,50 @@ export class EventBus<TEvents extends EventMap> {
|
|
|
194
220
|
|
|
195
221
|
const id = `handler-${++this.nextAsyncHandlerId}`;
|
|
196
222
|
this.asyncHandlerIds.set(handler, id);
|
|
223
|
+
this.asyncHandlersById.set(id, handler);
|
|
197
224
|
return id;
|
|
198
225
|
}
|
|
199
226
|
|
|
227
|
+
/** Drop a handler's id mappings once it is subscribed to no event at all. */
|
|
228
|
+
private releaseAsyncIdIfUnsubscribed(handler: TEvents[keyof TEvents]): void {
|
|
229
|
+
for (const set of this.asyncHandlers.values()) {
|
|
230
|
+
if (set.has(handler)) return;
|
|
231
|
+
}
|
|
232
|
+
const id = this.asyncHandlerIds.get(handler);
|
|
233
|
+
if (id !== undefined) {
|
|
234
|
+
this.asyncHandlerIds.delete(handler);
|
|
235
|
+
this.asyncHandlersById.delete(id);
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* Bridge for queue-backed async dispatch: returns a `JobHandler` to
|
|
241
|
+
* register on a queue consumer for each async event's job type. It
|
|
242
|
+
* dispatches the enqueued `{ event, args, handlerId }` payload back to the
|
|
243
|
+
* matching async handler on THIS bus instance.
|
|
244
|
+
*
|
|
245
|
+
* Use named async subscriptions (`SubscribeOptions.name`) when jobs may be
|
|
246
|
+
* consumed after a process restart — anonymous handler ids are
|
|
247
|
+
* process-local and not stable across restarts.
|
|
248
|
+
*
|
|
249
|
+
* @throws when the payload references an unknown handler id, so the job
|
|
250
|
+
* lands in the queue's retry/fail path instead of vanishing silently.
|
|
251
|
+
*/
|
|
252
|
+
createJobHandler(): JobHandler<AsyncEventJobPayload> {
|
|
253
|
+
return async (job) => {
|
|
254
|
+
const { event, args, handlerId } = job.payload;
|
|
255
|
+
const handler = this.asyncHandlersById.get(handlerId);
|
|
256
|
+
if (!handler) {
|
|
257
|
+
throw new Error(`No async handler registered for id "${handlerId}" (event "${event}")`);
|
|
258
|
+
}
|
|
259
|
+
// Serialization round-trips args through unknown[]; the handler type
|
|
260
|
+
// is `TEvents[K] & ((...args: unknown[]) => void)` — both satisfy the
|
|
261
|
+
// same constraint. Use unknown => unknown to bridge the covariance gap.
|
|
262
|
+
type AnyHandler = (...args: unknown[]) => unknown;
|
|
263
|
+
await (handler as unknown as AnyHandler)(...args);
|
|
264
|
+
};
|
|
265
|
+
}
|
|
266
|
+
|
|
200
267
|
private publishEmitDone(detail: EmitDoneDetail): void {
|
|
201
268
|
if (this.lifecycleBus) {
|
|
202
269
|
try {
|
package/src/event-bus/index.ts
CHANGED
package/src/event-bus/types.ts
CHANGED
|
@@ -9,6 +9,25 @@ export type EventMap = Record<string, (...args: never[]) => void>;
|
|
|
9
9
|
export interface SubscribeOptions {
|
|
10
10
|
/** When true, the handler is dispatched through the async handler path. */
|
|
11
11
|
async?: boolean;
|
|
12
|
+
/**
|
|
13
|
+
* Stable identifier for an async handler. Used as the `handlerId` in jobs
|
|
14
|
+
* enqueued through an injected `JobQueue`, so a queue consumer (see
|
|
15
|
+
* `EventBus.createJobHandler`) can dispatch jobs back to this handler —
|
|
16
|
+
* including after a process restart. Anonymous async handlers get a
|
|
17
|
+
* process-local generated id that is NOT stable across restarts.
|
|
18
|
+
* Must be unique per bus instance.
|
|
19
|
+
*/
|
|
20
|
+
name?: string;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Payload shape the bus enqueues for queue-backed async handlers. */
|
|
24
|
+
export interface AsyncEventJobPayload {
|
|
25
|
+
/** Event name the handler was subscribed to. */
|
|
26
|
+
event: string;
|
|
27
|
+
/** Arguments the event was emitted with. */
|
|
28
|
+
args: unknown[];
|
|
29
|
+
/** Async handler id (subscription `name` or a generated id). */
|
|
30
|
+
handlerId: string;
|
|
12
31
|
}
|
|
13
32
|
|
|
14
33
|
// ── Lifecycle events ────────────────────────────────────────────────
|
package/src/events.ts
CHANGED
|
@@ -2,8 +2,11 @@
|
|
|
2
2
|
* Infrastructure-level event definitions for ts-infra observability.
|
|
3
3
|
*
|
|
4
4
|
* Mirrors the package-local pattern in `@gobing-ai/ts-ai-runner` (`events.ts`):
|
|
5
|
-
*
|
|
6
|
-
*
|
|
5
|
+
* these maps define the infra-level event *contract*. ts-infra itself currently
|
|
6
|
+
* emits only `queue.stats` (`QueueStatsAction`) and `scheduler.job.executed`
|
|
7
|
+
* (`wrapScheduledHandler`); the remaining events are contracts for the consuming
|
|
8
|
+
* app or higher-level wiring to emit on the same bus. Application-domain events
|
|
9
|
+
* (history import, HTTP server, …) belong to the consuming app, not this
|
|
7
10
|
* library. Process events belong to `@gobing-ai/ts-runtime` (the owner of
|
|
8
11
|
* `ProcessExecutor`) and are intentionally not re-exported here.
|
|
9
12
|
*
|
package/src/index.ts
CHANGED
|
@@ -1,8 +1,16 @@
|
|
|
1
1
|
// API Client
|
|
2
|
-
export {
|
|
2
|
+
export {
|
|
3
|
+
APIClient,
|
|
4
|
+
type APIClientConfig,
|
|
5
|
+
APIError,
|
|
6
|
+
type RawHttpResponse,
|
|
7
|
+
type RawRequestOptions,
|
|
8
|
+
type RequestOptions,
|
|
9
|
+
} from './api-client';
|
|
3
10
|
|
|
4
11
|
// Event Bus
|
|
5
12
|
export {
|
|
13
|
+
type AsyncEventJobPayload,
|
|
6
14
|
attachDefaultObservers,
|
|
7
15
|
attachFileObserver,
|
|
8
16
|
attachLogObserver,
|
|
@@ -1,4 +1,7 @@
|
|
|
1
1
|
import type { QueueJobDao, QueueJobRecord, QueueStats } from '@gobing-ai/ts-db';
|
|
2
|
+
import type { EventBus } from '../event-bus/event-bus';
|
|
3
|
+
import type { QueueEvents } from '../events';
|
|
4
|
+
import { getLogger, type Logger } from '../logger';
|
|
2
5
|
import {
|
|
3
6
|
getQueueJobCompletedTotal,
|
|
4
7
|
getQueueJobEnqueuedTotal,
|
|
@@ -8,19 +11,34 @@ import {
|
|
|
8
11
|
import { addSpanAttributes, traceAsync } from '../telemetry/tracing';
|
|
9
12
|
import type { EnqueueOptions, Job, JobHandler, JobQueue, QueueConsumer, QueueConsumerConfig } from './types';
|
|
10
13
|
|
|
14
|
+
let _queueLogger: Logger | undefined;
|
|
15
|
+
function queueLogger(): Logger {
|
|
16
|
+
if (!_queueLogger) _queueLogger = getLogger('job-queue');
|
|
17
|
+
return _queueLogger;
|
|
18
|
+
}
|
|
19
|
+
|
|
11
20
|
/** DB-backed job queue implementation over `@gobing-ai/ts-db`'s `QueueJobDao`. */
|
|
12
21
|
export class DBJobQueue<T = unknown> implements JobQueue<T> {
|
|
13
|
-
constructor(
|
|
22
|
+
constructor(
|
|
23
|
+
readonly dao: QueueJobDao,
|
|
24
|
+
private readonly events?: EventBus<QueueEvents>,
|
|
25
|
+
) {}
|
|
14
26
|
|
|
15
27
|
async enqueue(type: string, payload: T, options?: EnqueueOptions): Promise<string> {
|
|
16
28
|
const id = await this.dao.enqueue(type, payload, options);
|
|
17
29
|
getQueueJobEnqueuedTotal().add(1, { type });
|
|
30
|
+
await this.events?.emit('queue.job.enqueued', { jobId: id, type });
|
|
18
31
|
return id;
|
|
19
32
|
}
|
|
20
33
|
|
|
21
34
|
async enqueueBatch(jobs: Array<{ type: string; payload: T } & EnqueueOptions>): Promise<string[]> {
|
|
22
35
|
const ids = await this.dao.enqueueBatch(jobs);
|
|
23
36
|
getQueueJobEnqueuedTotal().add(jobs.length);
|
|
37
|
+
if (this.events) {
|
|
38
|
+
for (const [index, jobId] of ids.entries()) {
|
|
39
|
+
await this.events.emit('queue.job.enqueued', { jobId, type: jobs[index]?.type ?? 'unknown' });
|
|
40
|
+
}
|
|
41
|
+
}
|
|
24
42
|
return ids;
|
|
25
43
|
}
|
|
26
44
|
|
|
@@ -39,6 +57,7 @@ export class DBQueueConsumer<T = unknown> implements QueueConsumer<T> {
|
|
|
39
57
|
private readonly baseDelay: number;
|
|
40
58
|
private readonly maxDelay: number;
|
|
41
59
|
private readonly drainTimeoutMs: number;
|
|
60
|
+
private readonly events: EventBus<QueueEvents> | undefined;
|
|
42
61
|
private timer: ReturnType<typeof setTimeout> | null = null;
|
|
43
62
|
private running = false;
|
|
44
63
|
private inFlight = 0;
|
|
@@ -54,6 +73,7 @@ export class DBQueueConsumer<T = unknown> implements QueueConsumer<T> {
|
|
|
54
73
|
this.baseDelay = config.baseDelay ?? 1_000;
|
|
55
74
|
this.maxDelay = config.maxDelay ?? 60_000;
|
|
56
75
|
this.drainTimeoutMs = config.drainTimeoutMs ?? 30_000;
|
|
76
|
+
this.events = config.events;
|
|
57
77
|
}
|
|
58
78
|
|
|
59
79
|
register(type: string, handler: JobHandler<T>): void {
|
|
@@ -64,9 +84,11 @@ export class DBQueueConsumer<T = unknown> implements QueueConsumer<T> {
|
|
|
64
84
|
if (this.running) return;
|
|
65
85
|
this.running = true;
|
|
66
86
|
this.schedule(0);
|
|
87
|
+
await this.events?.emit('queue.consumer.started');
|
|
67
88
|
}
|
|
68
89
|
|
|
69
90
|
async stop(): Promise<void> {
|
|
91
|
+
const wasRunning = this.running;
|
|
70
92
|
this.running = false;
|
|
71
93
|
if (this.timer !== null) {
|
|
72
94
|
clearTimeout(this.timer);
|
|
@@ -77,6 +99,7 @@ export class DBQueueConsumer<T = unknown> implements QueueConsumer<T> {
|
|
|
77
99
|
while (this.inFlight > 0 && Date.now() < deadline) {
|
|
78
100
|
await sleep(10);
|
|
79
101
|
}
|
|
102
|
+
if (wasRunning) await this.events?.emit('queue.consumer.stopped');
|
|
80
103
|
}
|
|
81
104
|
|
|
82
105
|
async stats(): Promise<QueueStats> {
|
|
@@ -122,13 +145,28 @@ export class DBQueueConsumer<T = unknown> implements QueueConsumer<T> {
|
|
|
122
145
|
if (!this.running) return;
|
|
123
146
|
try {
|
|
124
147
|
await this.processOnce();
|
|
148
|
+
} catch (error) {
|
|
149
|
+
// The timer fires poll() as a floating promise — a DAO/processing error
|
|
150
|
+
// must be contained here or it becomes an unhandled rejection that can
|
|
151
|
+
// kill the process. Log and let the next cycle retry.
|
|
152
|
+
queueLogger().error('queue poll cycle failed', {
|
|
153
|
+
error: error instanceof Error ? error.message : String(error),
|
|
154
|
+
});
|
|
125
155
|
} finally {
|
|
126
156
|
if (this.running) this.schedule(this.pollInterval);
|
|
127
157
|
}
|
|
128
158
|
}
|
|
129
159
|
|
|
130
160
|
private async processJob(record: QueueJobRecord): Promise<void> {
|
|
131
|
-
|
|
161
|
+
let job: Job<T>;
|
|
162
|
+
try {
|
|
163
|
+
job = toJob<T>(record);
|
|
164
|
+
} catch (error) {
|
|
165
|
+
// Corrupt payload — the job can never parse; route it through the
|
|
166
|
+
// retry/fail path instead of rejecting the whole batch.
|
|
167
|
+
await this.failOrRetry(record, error);
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
132
170
|
return traceAsync('queue.job.process', async () => {
|
|
133
171
|
addSpanAttributes({
|
|
134
172
|
'queue.job_id': job.id,
|
|
@@ -148,6 +186,7 @@ export class DBQueueConsumer<T = unknown> implements QueueConsumer<T> {
|
|
|
148
186
|
await this.dao.markCompleted(job.id);
|
|
149
187
|
getQueueJobCompletedTotal().add(1, { type: job.type });
|
|
150
188
|
getQueueJobProcessingDuration().record(performance.now() - startMs, { type: job.type });
|
|
189
|
+
await this.events?.emit('queue.job.completed', { jobId: job.id, type: job.type });
|
|
151
190
|
} catch (error) {
|
|
152
191
|
getQueueJobProcessingDuration().record(performance.now() - startMs, { type: job.type });
|
|
153
192
|
await this.failOrRetry(job, error);
|
|
@@ -155,17 +194,33 @@ export class DBQueueConsumer<T = unknown> implements QueueConsumer<T> {
|
|
|
155
194
|
});
|
|
156
195
|
}
|
|
157
196
|
|
|
158
|
-
private async failOrRetry(
|
|
197
|
+
private async failOrRetry(
|
|
198
|
+
job: Pick<Job<T>, 'id' | 'type' | 'attempts' | 'maxRetries'>,
|
|
199
|
+
error: unknown,
|
|
200
|
+
): Promise<void> {
|
|
159
201
|
const attempts = job.attempts + 1;
|
|
160
202
|
const message = error instanceof Error ? error.message : String(error);
|
|
161
203
|
if (attempts >= job.maxRetries) {
|
|
162
204
|
await this.dao.markFailed(job.id, attempts, message);
|
|
163
205
|
getQueueJobFailedTotal().add(1, { type: job.type });
|
|
206
|
+
await this.events?.emit('queue.job.failed', {
|
|
207
|
+
jobId: job.id,
|
|
208
|
+
type: job.type,
|
|
209
|
+
error: message,
|
|
210
|
+
attempt: attempts,
|
|
211
|
+
});
|
|
164
212
|
return;
|
|
165
213
|
}
|
|
166
214
|
|
|
167
215
|
const delay = Math.min(this.maxDelay, this.baseDelay * 2 ** Math.max(0, attempts - 1));
|
|
168
|
-
|
|
216
|
+
const nextRetryAt = Date.now() + delay;
|
|
217
|
+
await this.dao.markForRetry(job.id, attempts, message, nextRetryAt);
|
|
218
|
+
await this.events?.emit('queue.job.retrying', {
|
|
219
|
+
jobId: job.id,
|
|
220
|
+
type: job.type,
|
|
221
|
+
attempt: attempts,
|
|
222
|
+
nextRetryAt,
|
|
223
|
+
});
|
|
169
224
|
}
|
|
170
225
|
}
|
|
171
226
|
|
package/src/job-queue/types.ts
CHANGED
|
@@ -5,6 +5,9 @@
|
|
|
5
5
|
* `DBJobQueue` and `DBQueueConsumer`.
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
+
import type { EventBus } from '../event-bus/event-bus';
|
|
9
|
+
import type { QueueEvents } from '../events';
|
|
10
|
+
|
|
8
11
|
/** A queued job with status tracking, retry metadata, and timestamps. */
|
|
9
12
|
export interface Job<T = unknown> {
|
|
10
13
|
id: string;
|
|
@@ -54,6 +57,12 @@ export interface QueueConsumerConfig {
|
|
|
54
57
|
baseDelay?: number;
|
|
55
58
|
maxDelay?: number;
|
|
56
59
|
drainTimeoutMs?: number;
|
|
60
|
+
/**
|
|
61
|
+
* Optional bus for queue lifecycle events: `queue.consumer.started` /
|
|
62
|
+
* `queue.consumer.stopped` and `queue.job.completed` / `queue.job.failed` /
|
|
63
|
+
* `queue.job.retrying`. Omitting it leaves the consumer silent (default).
|
|
64
|
+
*/
|
|
65
|
+
events?: EventBus<QueueEvents>;
|
|
57
66
|
}
|
|
58
67
|
|
|
59
68
|
/** Consumer interface for the job queue — register handlers and control the processing loop. */
|
package/src/scheduler/action.ts
CHANGED
|
@@ -110,7 +110,7 @@ export class QueueStatsAction implements SchedulerAction {
|
|
|
110
110
|
const dao = await this.daoProvider();
|
|
111
111
|
const stats = await dao.getStats();
|
|
112
112
|
logger.info('Queue stats snapshot', { action: this.name, ...stats });
|
|
113
|
-
this.systemBus?.emit('queue.stats', stats);
|
|
113
|
+
await this.systemBus?.emit('queue.stats', stats);
|
|
114
114
|
}
|
|
115
115
|
}
|
|
116
116
|
|
package/src/scheduler/node.ts
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
* Node.js scheduler adapter using a simple setInterval-based approach.
|
|
3
3
|
* No external cron library dependency — cron expressions are parsed minimally.
|
|
4
4
|
*/
|
|
5
|
+
import { getLogger } from '../logger';
|
|
5
6
|
import {
|
|
6
7
|
getSchedulerJobDuration,
|
|
7
8
|
getSchedulerJobExecutedTotal,
|
|
@@ -13,21 +14,28 @@ import type { ScheduledAction, SchedulerAdapter } from './types';
|
|
|
13
14
|
function parseInterval(cron: string): number {
|
|
14
15
|
// Support simple patterns: "* * * * *" (every minute), "*/5 * * * *" (every 5 min)
|
|
15
16
|
// Also support direct ms strings like "60000"
|
|
16
|
-
const
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
17
|
+
const trimmed = cron.trim();
|
|
18
|
+
const num = Number(trimmed);
|
|
19
|
+
if (trimmed !== '' && !Number.isNaN(num)) {
|
|
20
|
+
// Guard against 0/negative intervals — setInterval would spin hot.
|
|
21
|
+
if (num > 0) return num;
|
|
22
|
+
} else {
|
|
23
|
+
const parts = trimmed.split(/\s+/);
|
|
24
|
+
if (parts.length === 5 && parts[0] === '*') {
|
|
25
|
+
return 60_000; // every minute
|
|
26
|
+
}
|
|
23
27
|
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
+
// */N pattern
|
|
29
|
+
const match = parts[0]?.match(/^\*\/(\d+)$/);
|
|
30
|
+
if (match && Number(match[1]) > 0) {
|
|
31
|
+
return Number(match[1]) * 60_000;
|
|
32
|
+
}
|
|
28
33
|
}
|
|
29
34
|
|
|
30
|
-
|
|
35
|
+
// Real cron field expressions ("0 3 * * *") are NOT supported — running
|
|
36
|
+
// them every minute silently would badly misfire, so warn on fallback.
|
|
37
|
+
getLogger('scheduler.node').warn('Unsupported cron expression — falling back to a 60s interval', { cron });
|
|
38
|
+
return 60_000;
|
|
31
39
|
}
|
|
32
40
|
|
|
33
41
|
interface ScheduledEntry {
|
|
@@ -43,7 +43,7 @@ export function wrapScheduledHandler(
|
|
|
43
43
|
const durationMs = Math.round(performance.now() - startTime);
|
|
44
44
|
addSpanAttributes({ 'scheduler.duration_ms': durationMs });
|
|
45
45
|
|
|
46
|
-
systemBus?.emit('scheduler.job.executed', {
|
|
46
|
+
void systemBus?.emit('scheduler.job.executed', {
|
|
47
47
|
name,
|
|
48
48
|
durationMs,
|
|
49
49
|
...(execError !== undefined ? { error: execError } : {}),
|
package/src/telemetry/metrics.ts
CHANGED
|
@@ -2,7 +2,8 @@
|
|
|
2
2
|
* OpenTelemetry metrics — lazy-initialized instruments.
|
|
3
3
|
* All degrade to no-ops when telemetry is disabled.
|
|
4
4
|
*/
|
|
5
|
-
import { type Counter, type Histogram, metrics } from '@opentelemetry/api';
|
|
5
|
+
import { type Counter, createNoopMeter, type Histogram, metrics } from '@opentelemetry/api';
|
|
6
|
+
import { getResolvedConfig } from './sdk';
|
|
6
7
|
|
|
7
8
|
export type { Counter, Histogram } from '@opentelemetry/api';
|
|
8
9
|
|
|
@@ -25,7 +26,24 @@ function getMeter() {
|
|
|
25
26
|
|
|
26
27
|
const instruments: Record<string, Counter | Histogram | undefined> = {};
|
|
27
28
|
|
|
29
|
+
// Master switch (`TelemetryConfig.enabled`): when explicitly disabled, getters
|
|
30
|
+
// return shared no-op instruments WITHOUT touching the cache, so a later
|
|
31
|
+
// re-enable rebuilds real instruments against the live meter.
|
|
32
|
+
let _noopCounter: Counter | undefined;
|
|
33
|
+
let _noopHistogram: Histogram | undefined;
|
|
34
|
+
|
|
35
|
+
function noopCounter(): Counter {
|
|
36
|
+
if (!_noopCounter) _noopCounter = createNoopMeter().createCounter('noop');
|
|
37
|
+
return _noopCounter;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function noopHistogram(): Histogram {
|
|
41
|
+
if (!_noopHistogram) _noopHistogram = createNoopMeter().createHistogram('noop');
|
|
42
|
+
return _noopHistogram;
|
|
43
|
+
}
|
|
44
|
+
|
|
28
45
|
function getOrCreateCounter(key: string, name: string, description: string, unit = '{operation}'): Counter {
|
|
46
|
+
if (!getResolvedConfig().enabled) return noopCounter();
|
|
29
47
|
if (!instruments[key]) {
|
|
30
48
|
instruments[key] = getMeter().createCounter(name, { description, unit });
|
|
31
49
|
}
|
|
@@ -33,6 +51,7 @@ function getOrCreateCounter(key: string, name: string, description: string, unit
|
|
|
33
51
|
}
|
|
34
52
|
|
|
35
53
|
function getOrCreateHistogram(key: string, name: string, description: string, unit = 'ms'): Histogram {
|
|
54
|
+
if (!getResolvedConfig().enabled) return noopHistogram();
|
|
36
55
|
if (!instruments[key]) {
|
|
37
56
|
instruments[key] = getMeter().createHistogram(name, { description, unit });
|
|
38
57
|
}
|
package/src/telemetry/sdk.ts
CHANGED
|
@@ -16,7 +16,11 @@ import { type Tracer, trace } from '@opentelemetry/api';
|
|
|
16
16
|
* environment, and debug-level DB statement capture.
|
|
17
17
|
*/
|
|
18
18
|
export interface TelemetryConfig {
|
|
19
|
-
/**
|
|
19
|
+
/**
|
|
20
|
+
* Master switch — when false, infra-created spans and metric instruments
|
|
21
|
+
* degrade to no-ops even if a global OTel provider is registered.
|
|
22
|
+
* Explicitly injected tracer ports (ADR-009 addendum) are unaffected.
|
|
23
|
+
*/
|
|
20
24
|
enabled: boolean;
|
|
21
25
|
/** Logical service name emitted on every span. */
|
|
22
26
|
serviceName: string;
|
|
@@ -29,7 +33,8 @@ export interface TelemetryConfig {
|
|
|
29
33
|
* attribute. SQL text is redacted — parameter values, literals, and
|
|
30
34
|
* identifiers are stripped before capture.
|
|
31
35
|
*
|
|
32
|
-
* Default: `false`.
|
|
36
|
+
* Default: `false`. Set via config — ts-infra core never reads env vars
|
|
37
|
+
* (ADR-011); map an env var to this flag in your bootstrap if desired.
|
|
33
38
|
*/
|
|
34
39
|
dbStatementDebug: boolean;
|
|
35
40
|
}
|
package/src/telemetry/tracing.ts
CHANGED
|
@@ -2,8 +2,22 @@
|
|
|
2
2
|
* High-level tracing helpers for application code.
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
|
-
import { context, type Span, type SpanOptions, type Tracer, trace } from '@opentelemetry/api';
|
|
6
|
-
import { getTracer } from './sdk';
|
|
5
|
+
import { context, INVALID_SPAN_CONTEXT, type Span, type SpanOptions, type Tracer, trace } from '@opentelemetry/api';
|
|
6
|
+
import { getResolvedConfig, getTracer } from './sdk';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Master switch (`TelemetryConfig.enabled`): when explicitly disabled, infra
|
|
10
|
+
* helpers bypass the global provider entirely. An explicitly injected tracer
|
|
11
|
+
* (ADR-009 addendum structural port) is honored regardless — the caller opted in.
|
|
12
|
+
*/
|
|
13
|
+
function isSuppressed(tracer: Tracer | undefined): boolean {
|
|
14
|
+
return tracer === undefined && !getResolvedConfig().enabled;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** A non-recording span satisfying the `Span` interface — every method is a no-op. */
|
|
18
|
+
function nonRecordingSpan(): Span {
|
|
19
|
+
return trace.wrapSpanContext(INVALID_SPAN_CONTEXT);
|
|
20
|
+
}
|
|
7
21
|
|
|
8
22
|
/**
|
|
9
23
|
* Execute an async function within an active OTel span.
|
|
@@ -15,6 +29,8 @@ export async function traceAsync<T>(
|
|
|
15
29
|
options?: SpanOptions,
|
|
16
30
|
tracer?: Tracer,
|
|
17
31
|
): Promise<T> {
|
|
32
|
+
if (isSuppressed(tracer)) return fn(nonRecordingSpan());
|
|
33
|
+
|
|
18
34
|
const resolvedTracer = tracer ?? getTracer();
|
|
19
35
|
return resolvedTracer.startActiveSpan(name, options ?? {}, async (span) => {
|
|
20
36
|
try {
|
|
@@ -33,6 +49,8 @@ export async function traceAsync<T>(
|
|
|
33
49
|
* The span is automatically ended and its status set on error.
|
|
34
50
|
*/
|
|
35
51
|
export function traceSync<T>(name: string, fn: (span: Span) => T, options?: SpanOptions, tracer?: Tracer): T {
|
|
52
|
+
if (isSuppressed(tracer)) return fn(nonRecordingSpan());
|
|
53
|
+
|
|
36
54
|
const resolvedTracer = tracer ?? getTracer();
|
|
37
55
|
return resolvedTracer.startActiveSpan(name, options ?? {}, (span) => {
|
|
38
56
|
try {
|