pg-boss 12.35.1 → 12.37.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.
@@ -0,0 +1,65 @@
1
+ import type * as types from './types.ts';
2
+ export declare const ATTR: {
3
+ readonly messagingSystem: "messaging.system";
4
+ readonly operationName: "messaging.operation.name";
5
+ readonly operationType: "messaging.operation.type";
6
+ readonly destinationName: "messaging.destination.name";
7
+ readonly messageId: "messaging.message.id";
8
+ readonly batchMessageCount: "messaging.batch.message_count";
9
+ readonly errorType: "error.type";
10
+ readonly dbNamespace: "db.namespace";
11
+ readonly schema: "pgboss.schema";
12
+ readonly retryCount: "pgboss.job.retry_count";
13
+ readonly jobState: "pgboss.job.state";
14
+ };
15
+ export declare const METRIC: {
16
+ readonly operationDuration: "messaging.client.operation.duration";
17
+ readonly sentMessages: "messaging.client.sent.messages";
18
+ readonly consumedMessages: "messaging.client.consumed.messages";
19
+ readonly processDuration: "messaging.process.duration";
20
+ readonly queueJobs: "pgboss.queue.jobs";
21
+ };
22
+ export declare const MESSAGING_SYSTEM = "pg-boss";
23
+ export declare const INSTRUMENTATION_SCOPE = "pg-boss";
24
+ export type TraceCarrier = Record<string, string>;
25
+ interface CarrierJob {
26
+ id: string;
27
+ retryCount?: number;
28
+ }
29
+ type QueueSnapshot = () => Record<string, types.QueueResult> | null;
30
+ declare class Telemetry {
31
+ #private;
32
+ readonly enabled: boolean;
33
+ constructor(options: types.OpenTelemetryOptions | undefined, schema: string, queues: QueueSnapshot);
34
+ setDatabase(database: string): void;
35
+ observeQueues(): void;
36
+ unobserveQueues(): void;
37
+ refreshInstruments(): void;
38
+ /**
39
+ * Runs a job-creating operation in a PRODUCER span and hands `fn` the trace context to store on
40
+ * each job it inserts, or null when there is nothing to propagate. `attempted` is the number of
41
+ * jobs it creates itself, counted whether or not they land. A function instead counts from the
42
+ * result, and counts nothing when `fn` throws.
43
+ */
44
+ send<T>(operation: string, destination: string | null, attempted: number | ((result: T) => number), fn: (carrier: TraceCarrier | null) => Promise<T>, idsOf?: (result: T) => string[] | null): Promise<T>;
45
+ /**
46
+ * Wraps a fetch() the application made itself in a CLIENT `receive` span, linked to the send of
47
+ * every job it returned.
48
+ */
49
+ receive<J extends CarrierJob>(destination: string, fn: () => Promise<J[]>, carrierOf: (job: J) => TraceCarrier | null | undefined): Promise<J[]>;
50
+ consumed(destination: string, count: number): void;
51
+ /**
52
+ * Runs a worker's handler over a batch in a CONSUMER `process` span. A batch of one continues the
53
+ * trace its send() started; a larger batch starts a trace of its own and links to every send.
54
+ *
55
+ * `fn` resolves with the error the batch failed with, or undefined when it completed, because the
56
+ * worker settles a failed batch itself rather than letting the error escape.
57
+ */
58
+ process<J extends CarrierJob>(destination: string, jobs: J[], carrierOf: (job: J) => TraceCarrier | null | undefined, fn: () => Promise<unknown>): Promise<void>;
59
+ /**
60
+ * Wraps a call that settles or removes jobs (complete, fail, cancel, deleteJob) in a CLIENT span.
61
+ */
62
+ settle<T>(operation: string, destination: string, ids: string[], fn: () => Promise<T>): Promise<T>;
63
+ }
64
+ export default Telemetry;
65
+ //# sourceMappingURL=telemetry.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"telemetry.d.ts","sourceRoot":"","sources":["../src/telemetry.ts"],"names":[],"mappings":"AAyBA,OAAO,KAAK,KAAK,KAAK,MAAM,YAAY,CAAA;AAMxC,eAAO,MAAM,IAAI;;;;;;;;;;;;CAYP,CAAA;AAEV,eAAO,MAAM,MAAM;;;;;;CAMT,CAAA;AAEV,eAAO,MAAM,gBAAgB,YAAY,CAAA;AACzC,eAAO,MAAM,qBAAqB,YAAY,CAAA;AAQ9C,MAAM,MAAM,YAAY,GAAG,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAA;AAUjD,UAAU,UAAU;IAClB,EAAE,EAAE,MAAM,CAAA;IACV,UAAU,CAAC,EAAE,MAAM,CAAA;CACpB;AAED,KAAK,aAAa,GAAG,MAAM,MAAM,CAAC,MAAM,EAAE,KAAK,CAAC,WAAW,CAAC,GAAG,IAAI,CAAA;AA8BnE,cAAM,SAAS;;IACb,QAAQ,CAAC,OAAO,EAAE,OAAO,CAAA;gBAYZ,OAAO,EAAE,KAAK,CAAC,oBAAoB,YAAK,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE,aAAa;IAa5F,WAAW,CAAE,QAAQ,EAAE,MAAM;IA+E7B,aAAa;IAMb,eAAe;IAQf,kBAAkB;IAIlB;;;;;OAKG;IACG,IAAI,CAAC,CAAC,EACV,SAAS,EAAE,MAAM,EACjB,WAAW,EAAE,MAAM,GAAG,IAAI,EAC1B,SAAS,EAAE,MAAM,GAAG,CAAC,CAAC,MAAM,EAAE,CAAC,KAAK,MAAM,CAAC,EAC3C,EAAE,EAAE,CAAC,OAAO,EAAE,YAAY,GAAG,IAAI,KAAK,OAAO,CAAC,CAAC,CAAC,EAChD,KAAK,GAAE,CAAC,MAAM,EAAE,CAAC,KAAK,MAAM,EAAE,GAAG,IAAiB,GACjD,OAAO,CAAC,CAAC,CAAC;IA4Cb;;;OAGG;IACG,OAAO,CAAC,CAAC,SAAS,UAAU,EAAG,WAAW,EAAE,MAAM,EAAE,EAAE,EAAE,MAAM,OAAO,CAAC,CAAC,EAAE,CAAC,EAAE,SAAS,EAAE,CAAC,GAAG,EAAE,CAAC,KAAK,YAAY,GAAG,IAAI,GAAG,SAAS,GAAG,OAAO,CAAC,CAAC,EAAE,CAAC;IAkCvJ,QAAQ,CAAE,WAAW,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM;IAK5C;;;;;;OAMG;IACG,OAAO,CAAC,CAAC,SAAS,UAAU,EAAG,WAAW,EAAE,MAAM,EAAE,IAAI,EAAE,CAAC,EAAE,EAAE,SAAS,EAAE,CAAC,GAAG,EAAE,CAAC,KAAK,YAAY,GAAG,IAAI,GAAG,SAAS,EAAE,EAAE,EAAE,MAAM,OAAO,CAAC,OAAO,CAAC,GAAG,OAAO,CAAC,IAAI,CAAC;IA2CvK;;OAEG;IACG,MAAM,CAAC,CAAC,EAAG,SAAS,EAAE,MAAM,EAAE,WAAW,EAAE,MAAM,EAAE,GAAG,EAAE,MAAM,EAAE,EAAE,EAAE,EAAE,MAAM,OAAO,CAAC,CAAC,CAAC,GAAG,OAAO,CAAC,CAAC,CAAC;CA8C1G;AAED,eAAe,SAAS,CAAA"}
@@ -0,0 +1,349 @@
1
+ import { context, defaultTextMapGetter, defaultTextMapSetter, metrics, propagation, ROOT_CONTEXT, SpanKind, SpanStatusCode, trace } from '@opentelemetry/api';
2
+ import packageJson from '../package.json' with { type: 'json' };
3
+ // The OpenTelemetry messaging semantic conventions, copied rather than imported: the messaging
4
+ // attributes still live in the unstable "incubating" entry point of @opentelemetry/semantic-conventions,
5
+ // which is not meant to be depended on by libraries.
6
+ // https://opentelemetry.io/docs/specs/semconv/messaging/
7
+ export const ATTR = {
8
+ messagingSystem: 'messaging.system',
9
+ operationName: 'messaging.operation.name',
10
+ operationType: 'messaging.operation.type',
11
+ destinationName: 'messaging.destination.name',
12
+ messageId: 'messaging.message.id',
13
+ batchMessageCount: 'messaging.batch.message_count',
14
+ errorType: 'error.type',
15
+ dbNamespace: 'db.namespace',
16
+ schema: 'pgboss.schema',
17
+ retryCount: 'pgboss.job.retry_count',
18
+ jobState: 'pgboss.job.state'
19
+ };
20
+ export const METRIC = {
21
+ operationDuration: 'messaging.client.operation.duration',
22
+ sentMessages: 'messaging.client.sent.messages',
23
+ consumedMessages: 'messaging.client.consumed.messages',
24
+ processDuration: 'messaging.process.duration',
25
+ queueJobs: 'pgboss.queue.jobs'
26
+ };
27
+ export const MESSAGING_SYSTEM = 'pg-boss';
28
+ export const INSTRUMENTATION_SCOPE = 'pg-boss';
29
+ // The bucket boundaries the messaging conventions recommend for their duration histograms.
30
+ const DURATION_BUCKETS = [0.005, 0.01, 0.025, 0.05, 0.075, 0.1, 0.25, 0.5, 0.75, 1, 2.5, 5, 7.5, 10];
31
+ function errorType(err) {
32
+ if (err instanceof Error)
33
+ return err.constructor.name;
34
+ return '_OTHER';
35
+ }
36
+ function spanName(operation, destination) {
37
+ return destination ? `${operation} ${destination}` : operation;
38
+ }
39
+ function metricAttributes(attributes, err) {
40
+ const { [ATTR.messageId]: _id, [ATTR.batchMessageCount]: _count, [ATTR.retryCount]: _retry, ...rest } = attributes;
41
+ return err === undefined ? rest : { ...rest, [ATTR.errorType]: errorType(err) };
42
+ }
43
+ function recordError(span, err) {
44
+ span.setAttribute(ATTR.errorType, errorType(err));
45
+ if (err instanceof Error) {
46
+ span.recordException(err);
47
+ span.setStatus({ code: SpanStatusCode.ERROR, message: err.message });
48
+ }
49
+ else {
50
+ span.setStatus({ code: SpanStatusCode.ERROR, message: String(err) });
51
+ }
52
+ }
53
+ function seconds(startedAt) {
54
+ return (performance.now() - startedAt) / 1000;
55
+ }
56
+ class Telemetry {
57
+ enabled;
58
+ #propagate;
59
+ #propagator;
60
+ #schema;
61
+ #namespace;
62
+ #tracer;
63
+ #meterProvider;
64
+ #queues;
65
+ #instrumentsFor = null;
66
+ #instruments = null;
67
+ #observing = false;
68
+ constructor(options = {}, schema, queues) {
69
+ this.enabled = options.enabled !== false;
70
+ this.#propagate = this.enabled && options.propagateContext === true;
71
+ this.#propagator = options.propagator ?? propagation;
72
+ this.#schema = schema;
73
+ this.#namespace = { [ATTR.schema]: schema };
74
+ this.#tracer = (options.tracerProvider ?? trace.getTracerProvider()).getTracer(INSTRUMENTATION_SCOPE, packageJson.version);
75
+ this.#meterProvider = options.meterProvider;
76
+ this.#queues = queues;
77
+ }
78
+ // db.namespace follows the PostgreSQL convention, {database}|{schema}, so the same schema in two
79
+ // databases stays apart. The database is only known once start() has asked for it.
80
+ setDatabase(database) {
81
+ this.#namespace = { [ATTR.dbNamespace]: `${database}|${this.#schema}`, [ATTR.schema]: this.#schema };
82
+ }
83
+ #baseAttributes(operation, type, destination) {
84
+ const attributes = {
85
+ [ATTR.messagingSystem]: MESSAGING_SYSTEM,
86
+ [ATTR.operationName]: operation,
87
+ [ATTR.operationType]: type,
88
+ ...this.#namespace
89
+ };
90
+ if (destination)
91
+ attributes[ATTR.destinationName] = destination;
92
+ return attributes;
93
+ }
94
+ // The global meter provider has no proxy the way the tracer provider does: a meter taken before
95
+ // an SDK registers stays a no-op forever. Resolving the provider on every use and rebuilding the
96
+ // instruments when it changes is what lets an SDK started after pg-boss still receive metrics.
97
+ #getInstruments() {
98
+ if (!this.enabled)
99
+ return null;
100
+ const provider = this.#meterProvider ?? metrics.getMeterProvider();
101
+ if (provider === this.#instrumentsFor && this.#instruments)
102
+ return this.#instruments;
103
+ this.#instruments?.queueJobs.removeCallback(this.#observeQueues);
104
+ const meter = provider.getMeter(INSTRUMENTATION_SCOPE, packageJson.version);
105
+ this.#instrumentsFor = provider;
106
+ this.#instruments = {
107
+ operationDuration: meter.createHistogram(METRIC.operationDuration, {
108
+ description: 'Duration of messaging operation initiated by a producer or consumer client.',
109
+ unit: 's',
110
+ advice: { explicitBucketBoundaries: DURATION_BUCKETS }
111
+ }),
112
+ sentMessages: meter.createCounter(METRIC.sentMessages, {
113
+ description: 'Number of messages producer attempted to send to the broker.',
114
+ unit: '{message}'
115
+ }),
116
+ consumedMessages: meter.createCounter(METRIC.consumedMessages, {
117
+ description: 'Number of messages that were delivered to the application.',
118
+ unit: '{message}'
119
+ }),
120
+ processDuration: meter.createHistogram(METRIC.processDuration, {
121
+ description: 'Duration of processing operation.',
122
+ unit: 's',
123
+ advice: { explicitBucketBoundaries: DURATION_BUCKETS }
124
+ }),
125
+ queueJobs: meter.createObservableGauge(METRIC.queueJobs, {
126
+ description: 'Jobs in a queue by state, as of the last queue cache refresh.',
127
+ unit: '{job}'
128
+ })
129
+ };
130
+ if (this.#observing) {
131
+ this.#instruments.queueJobs.addCallback(this.#observeQueues);
132
+ }
133
+ return this.#instruments;
134
+ }
135
+ #observeQueues = (result) => {
136
+ const queues = this.#queues();
137
+ if (!queues)
138
+ return;
139
+ for (const queue of Object.values(queues)) {
140
+ const attributes = { [ATTR.messagingSystem]: MESSAGING_SYSTEM, [ATTR.destinationName]: queue.name, ...this.#namespace };
141
+ result.observe(queue.deferredCount ?? 0, { ...attributes, [ATTR.jobState]: 'deferred' });
142
+ result.observe(queue.readyCount ?? 0, { ...attributes, [ATTR.jobState]: 'ready' });
143
+ result.observe(queue.blockedCount ?? 0, { ...attributes, [ATTR.jobState]: 'blocked' });
144
+ result.observe(queue.activeCount ?? 0, { ...attributes, [ATTR.jobState]: 'active' });
145
+ result.observe(queue.failedCount ?? 0, { ...attributes, [ATTR.jobState]: 'failed' });
146
+ }
147
+ };
148
+ // Starts reporting pgboss.queue.jobs from the queue cache. Called by start(), undone by stop().
149
+ observeQueues() {
150
+ if (!this.enabled || this.#observing)
151
+ return;
152
+ this.#observing = true;
153
+ this.#getInstruments()?.queueJobs.addCallback(this.#observeQueues);
154
+ }
155
+ unobserveQueues() {
156
+ if (!this.#observing)
157
+ return;
158
+ this.#observing = false;
159
+ this.#instruments?.queueJobs.removeCallback(this.#observeQueues);
160
+ }
161
+ // Moves the queue gauge to a meter provider registered after start(), which otherwise waits for
162
+ // the next send, fetch or settle. Called on each queue cache refresh.
163
+ refreshInstruments() {
164
+ if (this.#observing)
165
+ this.#getInstruments();
166
+ }
167
+ /**
168
+ * Runs a job-creating operation in a PRODUCER span and hands `fn` the trace context to store on
169
+ * each job it inserts, or null when there is nothing to propagate. `attempted` is the number of
170
+ * jobs it creates itself, counted whether or not they land. A function instead counts from the
171
+ * result, and counts nothing when `fn` throws.
172
+ */
173
+ async send(operation, destination, attempted, fn, idsOf = () => null) {
174
+ if (!this.enabled)
175
+ return fn(null);
176
+ const attributes = this.#baseAttributes(operation, 'send', destination);
177
+ if (typeof attempted === 'number' && attempted > 1)
178
+ attributes[ATTR.batchMessageCount] = attempted;
179
+ const span = this.#tracer.startSpan(spanName(operation, destination), { kind: SpanKind.PRODUCER, attributes });
180
+ const spanContext = trace.setSpan(context.active(), span);
181
+ const startedAt = performance.now();
182
+ let carrier = null;
183
+ if (this.#propagate) {
184
+ const injected = {};
185
+ this.#propagator.inject(spanContext, injected, defaultTextMapSetter);
186
+ carrier = Object.keys(injected).length > 0 ? injected : null;
187
+ }
188
+ try {
189
+ const result = await context.with(spanContext, () => fn(carrier));
190
+ const ids = idsOf(result);
191
+ const sent = typeof attempted === 'number' ? attempted : attempted(result);
192
+ if (sent === 1 && ids?.length === 1) {
193
+ span.setAttribute(ATTR.messageId, ids[0]);
194
+ }
195
+ this.#recordSend(startedAt, sent, metricAttributes(attributes));
196
+ return result;
197
+ }
198
+ catch (err) {
199
+ recordError(span, err);
200
+ this.#recordSend(startedAt, typeof attempted === 'number' ? attempted : 0, metricAttributes(attributes, err));
201
+ throw err;
202
+ }
203
+ finally {
204
+ span.end();
205
+ }
206
+ }
207
+ #recordSend(startedAt, attempted, attributes) {
208
+ const instruments = this.#getInstruments();
209
+ instruments?.operationDuration.record(seconds(startedAt), attributes);
210
+ if (attempted > 0)
211
+ instruments?.sentMessages.add(attempted, attributes);
212
+ }
213
+ /**
214
+ * Wraps a fetch() the application made itself in a CLIENT `receive` span, linked to the send of
215
+ * every job it returned.
216
+ */
217
+ async receive(destination, fn, carrierOf) {
218
+ if (!this.enabled)
219
+ return fn();
220
+ const attributes = this.#baseAttributes('receive', 'receive', destination);
221
+ const span = this.#tracer.startSpan(spanName('receive', destination), { kind: SpanKind.CLIENT, attributes });
222
+ const startedAt = performance.now();
223
+ try {
224
+ const jobs = await context.with(trace.setSpan(context.active(), span), fn);
225
+ if (jobs.length === 1) {
226
+ span.setAttribute(ATTR.messageId, jobs[0].id);
227
+ }
228
+ else {
229
+ span.setAttribute(ATTR.batchMessageCount, jobs.length);
230
+ }
231
+ span.addLinks(this.#links(jobs, carrierOf));
232
+ const instruments = this.#getInstruments();
233
+ instruments?.operationDuration.record(seconds(startedAt), metricAttributes(attributes));
234
+ if (jobs.length > 0)
235
+ instruments?.consumedMessages.add(jobs.length, metricAttributes(attributes));
236
+ return jobs;
237
+ }
238
+ catch (err) {
239
+ recordError(span, err);
240
+ this.#getInstruments()?.operationDuration.record(seconds(startedAt), metricAttributes(attributes, err));
241
+ throw err;
242
+ }
243
+ finally {
244
+ span.end();
245
+ }
246
+ }
247
+ // Jobs a worker claimed. The worker's own fetch has no span: it polls on a timer, and a span per
248
+ // empty poll would bury the traces that matter. The process span covers what it delivers.
249
+ consumed(destination, count) {
250
+ if (count === 0)
251
+ return;
252
+ this.#getInstruments()?.consumedMessages.add(count, metricAttributes(this.#baseAttributes('process', 'process', destination)));
253
+ }
254
+ /**
255
+ * Runs a worker's handler over a batch in a CONSUMER `process` span. A batch of one continues the
256
+ * trace its send() started; a larger batch starts a trace of its own and links to every send.
257
+ *
258
+ * `fn` resolves with the error the batch failed with, or undefined when it completed, because the
259
+ * worker settles a failed batch itself rather than letting the error escape.
260
+ */
261
+ async process(destination, jobs, carrierOf, fn) {
262
+ if (!this.enabled) {
263
+ await fn();
264
+ return;
265
+ }
266
+ const attributes = this.#baseAttributes('process', 'process', destination);
267
+ let parent = ROOT_CONTEXT;
268
+ let links = [];
269
+ if (jobs.length === 1) {
270
+ attributes[ATTR.messageId] = jobs[0].id;
271
+ if (jobs[0].retryCount !== undefined)
272
+ attributes[ATTR.retryCount] = jobs[0].retryCount;
273
+ // The whole extracted context, so baggage sent with the job reaches the handler too.
274
+ const carrier = carrierOf(jobs[0]);
275
+ if (carrier)
276
+ parent = this.#extract(carrier);
277
+ }
278
+ else {
279
+ attributes[ATTR.batchMessageCount] = jobs.length;
280
+ links = this.#links(jobs, carrierOf);
281
+ }
282
+ // The parent is chosen explicitly, never taken from context.active(): the worker loop inherits
283
+ // whatever context work() was called in, and a job has nothing to do with that caller's trace.
284
+ const span = this.#tracer.startSpan(spanName('process', destination), { kind: SpanKind.CONSUMER, attributes, links }, parent);
285
+ const startedAt = performance.now();
286
+ let failure;
287
+ try {
288
+ failure = await context.with(trace.setSpan(parent, span), fn);
289
+ }
290
+ catch (err) {
291
+ failure = err ?? new Error('undefined error');
292
+ throw err;
293
+ }
294
+ finally {
295
+ if (failure !== undefined)
296
+ recordError(span, failure);
297
+ this.#getInstruments()?.processDuration.record(seconds(startedAt), metricAttributes(attributes, failure));
298
+ span.end();
299
+ }
300
+ }
301
+ /**
302
+ * Wraps a call that settles or removes jobs (complete, fail, cancel, deleteJob) in a CLIENT span.
303
+ */
304
+ async settle(operation, destination, ids, fn) {
305
+ if (!this.enabled)
306
+ return fn();
307
+ const attributes = this.#baseAttributes(operation, 'settle', destination);
308
+ if (ids.length === 1) {
309
+ attributes[ATTR.messageId] = ids[0];
310
+ }
311
+ else {
312
+ attributes[ATTR.batchMessageCount] = ids.length;
313
+ }
314
+ const span = this.#tracer.startSpan(spanName(operation, destination), { kind: SpanKind.CLIENT, attributes });
315
+ const startedAt = performance.now();
316
+ try {
317
+ const result = await context.with(trace.setSpan(context.active(), span), fn);
318
+ this.#getInstruments()?.operationDuration.record(seconds(startedAt), metricAttributes(attributes));
319
+ return result;
320
+ }
321
+ catch (err) {
322
+ recordError(span, err);
323
+ this.#getInstruments()?.operationDuration.record(seconds(startedAt), metricAttributes(attributes, err));
324
+ throw err;
325
+ }
326
+ finally {
327
+ span.end();
328
+ }
329
+ }
330
+ #links(jobs, carrierOf) {
331
+ const links = [];
332
+ for (const job of jobs) {
333
+ const spanContext = this.#extractSpanContext(carrierOf(job));
334
+ if (spanContext)
335
+ links.push({ context: spanContext });
336
+ }
337
+ return links;
338
+ }
339
+ #extract(carrier) {
340
+ return this.#propagator.extract(ROOT_CONTEXT, carrier, defaultTextMapGetter);
341
+ }
342
+ #extractSpanContext(carrier) {
343
+ if (!carrier)
344
+ return null;
345
+ const spanContext = trace.getSpanContext(this.#extract(carrier));
346
+ return spanContext && trace.isSpanContextValid(spanContext) ? spanContext : null;
347
+ }
348
+ }
349
+ export default Telemetry;
@@ -76,6 +76,9 @@ declare class Timekeeper extends EventEmitter implements types.EventsMixin {
76
76
  * labels every row from its default. Either way the row reads fine and never fires again. So when
77
77
  * an expression cannot be read the way the column says, and is written the other way, it is read
78
78
  * the way it is written: one regex, on a path that was already about to give up.
79
+ *
80
+ * An empty expression is refused first, as schedule() refuses it. cron-parser would read one as
81
+ * every minute, and a row stored before schedule() checked for it would keep firing that often.
79
82
  */
80
83
  private dueOccurrences;
81
84
  /**
@@ -170,7 +173,7 @@ declare class Timekeeper extends EventEmitter implements types.EventsMixin {
170
173
  */
171
174
  previewSchedule(cron: string, options?: types.PreviewScheduleOptions): Date[];
172
175
  schedule(name: string, cron: string, data?: unknown, options?: types.ScheduleOptions): Promise<void>;
173
- unschedule(name: string, key?: string): Promise<void>;
176
+ unschedule(name: string, key?: string, options?: types.ConnectionOptions): Promise<void>;
174
177
  }
175
178
  export default Timekeeper;
176
179
  //# sourceMappingURL=timekeeper.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"timekeeper.d.ts","sourceRoot":"","sources":["../src/timekeeper.ts"],"names":[],"mappings":"AAEA,OAAO,YAAY,MAAM,aAAa,CAAA;AAItC,OAAO,KAAK,OAAO,MAAM,cAAc,CAAA;AAKvC,OAAO,KAAK,KAAK,MAAM,YAAY,CAAA;AAGnC,eAAO,MAAM,MAAM;;CAElB,CAAA;AAwBD,kGAAkG;AAClG,eAAO,MAAM,iBAAiB,OAAO,CAAA;AA8MrC,cAAM,UAAW,SAAQ,YAAa,YAAW,KAAK,CAAC,WAAW;IAChE,EAAE,EAAE,KAAK,CAAC,SAAS,CAAA;IACnB,MAAM,EAAE,KAAK,CAAC,0BAA0B,CAAA;IACxC,OAAO,EAAE,OAAO,CAAA;IAEhB,OAAO,CAAC,OAAO,CAAO;IACtB,OAAO,CAAC,gBAAgB,CAA+B;IACvD,OAAO,CAAC,gBAAgB,CAAqC;IAC7D,OAAO,CAAC,YAAY,CAAqB;IACzC,OAAO,CAAC,aAAa,CAAQ;IAS7B,OAAO,CAAC,eAAe,CAAoB;IAE3C,SAAS,SAAI;IACb,MAAM;;;;MAAS;gBAEF,EAAE,EAAE,KAAK,CAAC,SAAS,EAAE,OAAO,EAAE,OAAO,EAAE,MAAM,EAAE,KAAK,CAAC,0BAA0B;IAQ5F,IAAI,YAAY,IAAK,OAAO,CAE3B;IAED,IAAI,WAAW,IAAK,OAAO,CAE1B;IAMD,OAAO,KAAK,YAAY,GAEvB;IAED,OAAO,KAAK,cAAc,GASzB;IAEK,KAAK;IA4BL,IAAI;IAmCV,OAAO,CAAC,mBAAmB;IAUrB,cAAc;IA8Cd,MAAM;IAuDZ;;;;;;;;;;;;;;;;;;;;OAoBG;IACH,OAAO,CAAC,eAAe;IAQvB;;;;;OAKG;IACG,IAAI,CAAE,WAAW,GAAE,OAAc;IAoIvC,YAAY,CAAE,UAAU,EAAE,MAAM,EAAE,EAAE,EAAE,MAAM,EAAE,IAAI,GAAE,KAAK,CAAC,YAAwC;IAIlG;;;;;;;;;;;;;;OAcG;IACH,OAAO,CAAC,cAAc;IAgBtB;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;OA8BG;IACH,OAAO,CAAC,gBAAgB;IAgBxB;;;;;;;OAOG;IACH,OAAO,CAAC,sBAAsB;IAgB9B;;;;;;;;;;;;;;;;OAgBG;IACH,OAAO,CAAC,eAAe;IAwBvB,OAAO,CAAC,iBAAiB;YAQX,QAAQ;YAsDR,aAAa;IAerB,YAAY,CAAE,IAAI,CAAC,EAAE,MAAM,EAAE,GAAG,CAAC,EAAE,MAAM,GAAG,OAAO,CAAC,KAAK,CAAC,QAAQ,EAAE,CAAC;IAiBrE,WAAW,CAAE,IAAI,EAAE,MAAM,EAAE,GAAG,SAAK,GAAG,OAAO,CAAC,KAAK,CAAC,QAAQ,GAAG,IAAI,CAAC;IAe1E;;;;;;;OAOG;IACH,OAAO,CAAC,gBAAgB;IAYxB;;;;;;;;;;;;;;;;OAgBG;IACH,eAAe,CAAE,IAAI,EAAE,MAAM,EAAE,OAAO,GAAE,KAAK,CAAC,sBAA2B,GAAG,IAAI,EAAE;IA0C5E,QAAQ,CAAE,IAAI,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,EAAE,IAAI,CAAC,EAAE,OAAO,EAAE,OAAO,GAAE,KAAK,CAAC,eAAoB,GAAG,OAAO,CAAC,IAAI,CAAC;IA6CzG,UAAU,CAAE,IAAI,EAAE,MAAM,EAAE,GAAG,SAAK,GAAG,OAAO,CAAC,IAAI,CAAC;CAIzD;AAED,eAAe,UAAU,CAAA"}
1
+ {"version":3,"file":"timekeeper.d.ts","sourceRoot":"","sources":["../src/timekeeper.ts"],"names":[],"mappings":"AAEA,OAAO,YAAY,MAAM,aAAa,CAAA;AAItC,OAAO,KAAK,OAAO,MAAM,cAAc,CAAA;AAKvC,OAAO,KAAK,KAAK,MAAM,YAAY,CAAA;AAGnC,eAAO,MAAM,MAAM;;CAElB,CAAA;AAwBD,kGAAkG;AAClG,eAAO,MAAM,iBAAiB,OAAO,CAAA;AAuNrC,cAAM,UAAW,SAAQ,YAAa,YAAW,KAAK,CAAC,WAAW;IAChE,EAAE,EAAE,KAAK,CAAC,SAAS,CAAA;IACnB,MAAM,EAAE,KAAK,CAAC,0BAA0B,CAAA;IACxC,OAAO,EAAE,OAAO,CAAA;IAEhB,OAAO,CAAC,OAAO,CAAO;IACtB,OAAO,CAAC,gBAAgB,CAA+B;IACvD,OAAO,CAAC,gBAAgB,CAAqC;IAC7D,OAAO,CAAC,YAAY,CAAqB;IACzC,OAAO,CAAC,aAAa,CAAQ;IAS7B,OAAO,CAAC,eAAe,CAAoB;IAE3C,SAAS,SAAI;IACb,MAAM;;;;MAAS;gBAEF,EAAE,EAAE,KAAK,CAAC,SAAS,EAAE,OAAO,EAAE,OAAO,EAAE,MAAM,EAAE,KAAK,CAAC,0BAA0B;IAQ5F,IAAI,YAAY,IAAK,OAAO,CAE3B;IAED,IAAI,WAAW,IAAK,OAAO,CAE1B;IAMD,OAAO,KAAK,YAAY,GAEvB;IAED,OAAO,KAAK,cAAc,GASzB;IAEK,KAAK;IA4BL,IAAI;IAmCV,OAAO,CAAC,mBAAmB;IAUrB,cAAc;IA8Cd,MAAM;IAuDZ;;;;;;;;;;;;;;;;;;;;OAoBG;IACH,OAAO,CAAC,eAAe;IAQvB;;;;;OAKG;IACG,IAAI,CAAE,WAAW,GAAE,OAAc;IAoIvC,YAAY,CAAE,UAAU,EAAE,MAAM,EAAE,EAAE,EAAE,MAAM,EAAE,IAAI,GAAE,KAAK,CAAC,YAAwC;IAIlG;;;;;;;;;;;;;;;;;OAiBG;IACH,OAAO,CAAC,cAAc;IAkBtB;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;OA8BG;IACH,OAAO,CAAC,gBAAgB;IAgBxB;;;;;;;OAOG;IACH,OAAO,CAAC,sBAAsB;IAgB9B;;;;;;;;;;;;;;;;OAgBG;IACH,OAAO,CAAC,eAAe;IAwBvB,OAAO,CAAC,iBAAiB;YAQX,QAAQ;YAwDR,aAAa;IAerB,YAAY,CAAE,IAAI,CAAC,EAAE,MAAM,EAAE,GAAG,CAAC,EAAE,MAAM,GAAG,OAAO,CAAC,KAAK,CAAC,QAAQ,EAAE,CAAC;IAiBrE,WAAW,CAAE,IAAI,EAAE,MAAM,EAAE,GAAG,SAAK,GAAG,OAAO,CAAC,KAAK,CAAC,QAAQ,GAAG,IAAI,CAAC;IAe1E;;;;;;;OAOG;IACH,OAAO,CAAC,gBAAgB;IAcxB;;;;;;;;;;;;;;;;OAgBG;IACH,eAAe,CAAE,IAAI,EAAE,MAAM,EAAE,OAAO,GAAE,KAAK,CAAC,sBAA2B,GAAG,IAAI,EAAE;IA0C5E,QAAQ,CAAE,IAAI,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,EAAE,IAAI,CAAC,EAAE,OAAO,EAAE,OAAO,GAAE,KAAK,CAAC,eAAoB,GAAG,OAAO,CAAC,IAAI,CAAC;IAiDzG,UAAU,CAAE,IAAI,EAAE,MAAM,EAAE,GAAG,SAAK,EAAE,OAAO,GAAE,KAAK,CAAC,iBAAsB,GAAG,OAAO,CAAC,IAAI,CAAC;CAIhG;AAED,eAAe,UAAU,CAAA"}
@@ -178,11 +178,18 @@ function parseRecurrence(cron, tz, currentDate) {
178
178
  assertTimezone(tz);
179
179
  return CronExpressionParser.parse(cron, { tz, strict: false, currentDate });
180
180
  }
181
+ // cron-parser reads an empty expression, and undefined or null, as `* * * * *`, and those are nearly
182
+ // always a missing value, not a request to run every minute. Checked before isRrule(), which reads a
183
+ // non-string as its string form and would send something like ['FREQ=DAILY'] down the rule path.
184
+ function assertExpression(expression) {
185
+ assert(typeof expression === 'string' && expression.trim() !== '', 'cron expression must be a non-empty string');
186
+ }
181
187
  /**
182
188
  * Validates a recurrence in whichever of the two formats it is written, so previewSchedule() and
183
189
  * schedule() reject exactly the same expressions.
184
190
  */
185
191
  function assertRecurrence(expression, tz, now) {
192
+ assertExpression(expression);
186
193
  if (isRrule(expression)) {
187
194
  assertRrule(expression, tz);
188
195
  }
@@ -542,8 +549,12 @@ class Timekeeper extends EventEmitter {
542
549
  * labels every row from its default. Either way the row reads fine and never fires again. So when
543
550
  * an expression cannot be read the way the column says, and is written the other way, it is read
544
551
  * the way it is written: one regex, on a path that was already about to give up.
552
+ *
553
+ * An empty expression is refused first, as schedule() refuses it. cron-parser would read one as
554
+ * every minute, and a row stored before schedule() checked for it would keep firing that often.
545
555
  */
546
556
  dueOccurrences(expression, kind, tz, databaseTime = this.databaseTime) {
557
+ assertExpression(expression);
547
558
  try {
548
559
  return { kind, occurrences: this.readOccurrences(expression, kind, tz, databaseTime) };
549
560
  }
@@ -670,7 +681,9 @@ class Timekeeper extends EventEmitter {
670
681
  // key and slot are the pass's own bookkeeping, read below rather than sent: send() takes the
671
682
  // request the schedule row described and nothing else.
672
683
  const { key, slot, ...request } = data;
673
- return await this.manager.send(request);
684
+ // A row written before 12.37.0 may still carry `db` from schedule(); it is never a send option here.
685
+ const { db, ...options } = request.options ?? {};
686
+ return await this.manager.send({ ...request, options });
674
687
  }));
675
688
  // Keyed on (name, key) so a batch holding more than one occurrence of the same schedule
676
689
  // resolves to its latest. Feeding several to the UPDATE would let postgres pick any of the
@@ -756,6 +769,7 @@ class Timekeeper extends EventEmitter {
756
769
  * occurrence after the last one handed back.
757
770
  */
758
771
  occurrenceWalker(expression, tz, from) {
772
+ assertExpression(expression);
759
773
  if (isRrule(expression)) {
760
774
  assertRrule(expression, tz);
761
775
  return rruleWalker(expression, tz, from);
@@ -810,9 +824,12 @@ class Timekeeper extends EventEmitter {
810
824
  return occurrences;
811
825
  }
812
826
  async schedule(name, cron, data, options = {}) {
827
+ // `db` is how this call runs, not part of the schedule: it comes out first so the options blob
828
+ // stored on the row, and later handed to send() by the cron pass, never carries a connection.
829
+ const { db, ...persisted } = options;
813
830
  // `missed` comes out with tz and key: it tells the pass what to do about a gap and is no more a
814
831
  // send option than they are, so the send-option check below is not handed it.
815
- const { tz: requestedTz, key = '', missed, ...rest } = options;
832
+ const { tz: requestedTz, key = '', missed, ...rest } = persisted;
816
833
  // Any falsy zone is "none specified", not a zone to be judged: a destructuring default only
817
834
  // covers `undefined`, and a value threaded out of a config object or read back off the nullable
818
835
  // timezone column arrives as null. Coalescing it here is what keeps the two engines from
@@ -838,7 +855,7 @@ class Timekeeper extends EventEmitter {
838
855
  assertMissedPolicy(missed);
839
856
  try {
840
857
  const sql = plans.schedule(this.config.schema);
841
- await this.db.executeSql(sql, [name, key, kind, cron, tz, data, options]);
858
+ await (db || this.db).executeSql(sql, [name, key, kind, cron, tz, data, persisted]);
842
859
  }
843
860
  catch (err) {
844
861
  if (err.message.includes('foreign key')) {
@@ -847,9 +864,9 @@ class Timekeeper extends EventEmitter {
847
864
  throw err;
848
865
  }
849
866
  }
850
- async unschedule(name, key = '') {
867
+ async unschedule(name, key = '', options = {}) {
851
868
  const sql = plans.unschedule(this.config.schema);
852
- await this.db.executeSql(sql, [name, key]);
869
+ await (options.db || this.db).executeSql(sql, [name, key]);
853
870
  }
854
871
  }
855
872
  export default Timekeeper;