@pikku/queue-nats 0.12.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 +9 -0
- package/LICENSE +21 -0
- package/README.md +47 -0
- package/dist/consume.d.ts +54 -0
- package/dist/consume.js +213 -0
- package/dist/index.d.ts +37 -0
- package/dist/index.js +42 -0
- package/dist/nats-delayed-publisher.d.ts +35 -0
- package/dist/nats-delayed-publisher.js +85 -0
- package/dist/nats-queue-service.d.ts +44 -0
- package/dist/nats-queue-service.js +84 -0
- package/dist/nats-queue-worker.d.ts +46 -0
- package/dist/nats-queue-worker.js +199 -0
- package/dist/nats-scheduler-service.d.ts +156 -0
- package/dist/nats-scheduler-service.js +369 -0
- package/dist/nats-service-factory.d.ts +104 -0
- package/dist/nats-service-factory.js +231 -0
- package/dist/utils.d.ts +122 -0
- package/dist/utils.js +154 -0
- package/package.json +33 -0
- package/run-tests.sh +56 -0
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { headers } from '@nats-io/nats-core';
|
|
2
|
+
import { ATTEMPTS_HEADER, BACKOFF_DELAY_HEADER, BACKOFF_TYPE_HEADER, NATS_MSG_ID_HEADER, PIKKU_USER_ID_HEADER, attemptsHeaderValue, subjectForQueue, } from './utils.js';
|
|
3
|
+
/**
|
|
4
|
+
* JetStream queue service for publishing jobs.
|
|
5
|
+
*
|
|
6
|
+
* Deliberately narrower than the pg-boss and bullmq services in two ways, both
|
|
7
|
+
* forced by the broker rather than by choice — see `supportsResults` and
|
|
8
|
+
* `getJob` below.
|
|
9
|
+
*/
|
|
10
|
+
export class NatsQueueService {
|
|
11
|
+
js;
|
|
12
|
+
subjectPrefix;
|
|
13
|
+
/**
|
|
14
|
+
* A work-queue stream deletes a message the moment it is acked, so there is
|
|
15
|
+
* nowhere for a result to live and nothing to read it back from. Producers
|
|
16
|
+
* that need a return value should use an RPC, not a queue.
|
|
17
|
+
*/
|
|
18
|
+
supportsResults = false;
|
|
19
|
+
delayedPublisher;
|
|
20
|
+
constructor(js, subjectPrefix, delayedPublisher) {
|
|
21
|
+
this.js = js;
|
|
22
|
+
this.subjectPrefix = subjectPrefix;
|
|
23
|
+
this.delayedPublisher = delayedPublisher;
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* Attach the delayed publisher after construction. Needed because the pg-boss
|
|
27
|
+
* publisher republishes *through* this service, so the two reference each
|
|
28
|
+
* other and one of them has to be wired second.
|
|
29
|
+
*/
|
|
30
|
+
setDelayedPublisher(publisher) {
|
|
31
|
+
this.delayedPublisher = publisher;
|
|
32
|
+
}
|
|
33
|
+
async add(queueName, data, options) {
|
|
34
|
+
const hdrs = headers();
|
|
35
|
+
if (options?.pikkuUserId) {
|
|
36
|
+
hdrs.set(PIKKU_USER_ID_HEADER, options.pikkuUserId);
|
|
37
|
+
}
|
|
38
|
+
// JetStream dedupes on this header within the stream's duplicate window,
|
|
39
|
+
// which is the closest equivalent to pg-boss's singleton `jobId`.
|
|
40
|
+
if (options?.jobId) {
|
|
41
|
+
hdrs.set(NATS_MSG_ID_HEADER, options.jobId);
|
|
42
|
+
}
|
|
43
|
+
// Retry policy travels with the job — see ATTEMPTS_HEADER. The worker
|
|
44
|
+
// enforces it; JetStream itself has no per-message equivalent.
|
|
45
|
+
if (options?.attempts !== undefined) {
|
|
46
|
+
hdrs.set(ATTEMPTS_HEADER, attemptsHeaderValue(options.attempts, queueName));
|
|
47
|
+
}
|
|
48
|
+
if (options?.backoff !== undefined) {
|
|
49
|
+
const backoff = typeof options.backoff === 'string'
|
|
50
|
+
? { type: options.backoff }
|
|
51
|
+
: options.backoff;
|
|
52
|
+
hdrs.set(BACKOFF_TYPE_HEADER, backoff.type);
|
|
53
|
+
if (backoff.delay !== undefined) {
|
|
54
|
+
hdrs.set(BACKOFF_DELAY_HEADER, String(backoff.delay));
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
if (options?.delay) {
|
|
58
|
+
// Silently dropping this is not an option: `delay` is what backs
|
|
59
|
+
// workflow.sleep() and orchestrator backoff, so ignoring it turns a
|
|
60
|
+
// one-hour sleep into a zero-second one and an exponential backoff into a
|
|
61
|
+
// hot retry loop. `NatsServiceFactory` always wires a publisher, so this
|
|
62
|
+
// only fires for a hand-built service.
|
|
63
|
+
if (!this.delayedPublisher) {
|
|
64
|
+
throw new Error(`Queue "${queueName}" was given delay=${options.delay}ms but no delayedPublisher is configured. Construct NatsQueueService with a NatsDelayedPublisher (NatsServiceFactory does this for you) or enqueue without a delay.`);
|
|
65
|
+
}
|
|
66
|
+
return await this.delayedPublisher.publishAfter(queueName, data, options.delay, options);
|
|
67
|
+
}
|
|
68
|
+
const ack = await this.js.publish(subjectForQueue(this.subjectPrefix, queueName), JSON.stringify(data), { headers: hdrs });
|
|
69
|
+
// The stream sequence is the only broker-assigned identity a message has.
|
|
70
|
+
// It is unique per stream and monotonic, which is enough to correlate logs
|
|
71
|
+
// and telemetry — but note it is NOT the `jobId` the caller may have passed.
|
|
72
|
+
return String(ack.seq);
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* Not supported. A message in a work-queue stream is only reachable through a
|
|
76
|
+
* consumer, and only while it is being delivered — there is no lookup by id,
|
|
77
|
+
* and an acked message no longer exists. Returning null (rather than throwing)
|
|
78
|
+
* keeps callers that poll for optional status working; callers that genuinely
|
|
79
|
+
* need per-job state should read it from telemetry.
|
|
80
|
+
*/
|
|
81
|
+
async getJob(_queueName, _jobId) {
|
|
82
|
+
return null;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import type { ConsumerConfig, JetStreamClient, JetStreamManager } from '@nats-io/jetstream';
|
|
2
|
+
import type { ConfigValidationResult, PikkuWorkerConfig, QueueConfigMapping, QueueWorkers } from '@pikku/core/queue';
|
|
3
|
+
import type { Logger } from '@pikku/core/services';
|
|
4
|
+
/**
|
|
5
|
+
* Map a pikku worker config onto a JetStream consumer config.
|
|
6
|
+
*
|
|
7
|
+
* The important line is `max_ack_pending`. It is the number of messages the
|
|
8
|
+
* server will let this consumer hold unacked at once — i.e. real per-message
|
|
9
|
+
* concurrency, where a slow message occupies exactly one of the N slots. It is
|
|
10
|
+
* NOT a batch size: nothing here ever hands several messages to one handler and
|
|
11
|
+
* waits for all of them, which is the shape that produces head-of-line blocking
|
|
12
|
+
* on a shared queue. Keep it that way.
|
|
13
|
+
*/
|
|
14
|
+
export declare const mapPikkuWorkerToNats: (workerConfig?: PikkuWorkerConfig) => Partial<ConsumerConfig>;
|
|
15
|
+
/**
|
|
16
|
+
* JetStream queue worker implementation.
|
|
17
|
+
*
|
|
18
|
+
* Cron and delayed work are handled by `NatsSchedulerService`, which uses the
|
|
19
|
+
* server's own message scheduler (2.14+) rather than a database.
|
|
20
|
+
*/
|
|
21
|
+
export declare class NatsQueueWorkers implements QueueWorkers {
|
|
22
|
+
private readonly js;
|
|
23
|
+
private readonly jsm;
|
|
24
|
+
private readonly streamName;
|
|
25
|
+
private readonly subjectPrefix;
|
|
26
|
+
private readonly defaultConsumerConfig;
|
|
27
|
+
readonly name = "nats";
|
|
28
|
+
readonly supportsResults = false;
|
|
29
|
+
readonly configMappings: QueueConfigMapping;
|
|
30
|
+
private readonly activeConsumers;
|
|
31
|
+
constructor(js: JetStreamClient, jsm: JetStreamManager, streamName: string, subjectPrefix: string, defaultConsumerConfig?: Partial<ConsumerConfig>);
|
|
32
|
+
registerQueues(logger?: Logger): Promise<Record<string, ConfigValidationResult[]>>;
|
|
33
|
+
private handleMessage;
|
|
34
|
+
/** Stop consuming. In-flight handlers are left to settle their own acks. */
|
|
35
|
+
stop(): Promise<void>;
|
|
36
|
+
/**
|
|
37
|
+
* Stop consuming and wait for in-flight jobs to settle, up to `timeoutMs`
|
|
38
|
+
* across all queues. Returns the queues that did NOT finish in time.
|
|
39
|
+
*
|
|
40
|
+
* Use this on shutdown in preference to `stop()`. Without it a redeploy kills
|
|
41
|
+
* handlers mid-job, and every unacked message waits out `ack_wait` before
|
|
42
|
+
* being redelivered — safe, but slow, and it re-runs whatever side effects the
|
|
43
|
+
* handler had already performed.
|
|
44
|
+
*/
|
|
45
|
+
drain(timeoutMs: number): Promise<string[]>;
|
|
46
|
+
}
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
import { AckPolicy, DeliverPolicy } from '@nats-io/jetstream';
|
|
2
|
+
import { QueueJobDiscardedError, QueueJobFailedError, registerQueueWorkers, runQueueJob, } from '@pikku/core/queue';
|
|
3
|
+
import { pikkuState } from '@pikku/core/state';
|
|
4
|
+
import { attemptsFor, backoffFor, mapJsMsgToQueueJob } from './utils.js';
|
|
5
|
+
import { consumeQueue } from './consume.js';
|
|
6
|
+
const MS_TO_NS = 1_000_000;
|
|
7
|
+
/**
|
|
8
|
+
* Default abandonment timeout, used when a worker declares no `lockDuration`.
|
|
9
|
+
*
|
|
10
|
+
* Matches pg-boss's `expire_seconds` default of 900s. This MUST be set
|
|
11
|
+
* explicitly: JetStream's own default `ack_wait` is 30s, so leaving it unset
|
|
12
|
+
* silently shortens the abandonment timeout 30x versus pg-boss, and any job
|
|
13
|
+
* running longer than 30s — a workflow orchestrator that polls, say — is
|
|
14
|
+
* redelivered while it is still running and executed a second time.
|
|
15
|
+
*/
|
|
16
|
+
const DEFAULT_ACK_WAIT_MS = 900_000;
|
|
17
|
+
/**
|
|
18
|
+
* Map a pikku worker config onto a JetStream consumer config.
|
|
19
|
+
*
|
|
20
|
+
* The important line is `max_ack_pending`. It is the number of messages the
|
|
21
|
+
* server will let this consumer hold unacked at once — i.e. real per-message
|
|
22
|
+
* concurrency, where a slow message occupies exactly one of the N slots. It is
|
|
23
|
+
* NOT a batch size: nothing here ever hands several messages to one handler and
|
|
24
|
+
* waits for all of them, which is the shape that produces head-of-line blocking
|
|
25
|
+
* on a shared queue. Keep it that way.
|
|
26
|
+
*/
|
|
27
|
+
export const mapPikkuWorkerToNats = (workerConfig) => {
|
|
28
|
+
const config = {
|
|
29
|
+
ack_policy: AckPolicy.Explicit,
|
|
30
|
+
deliver_policy: DeliverPolicy.All,
|
|
31
|
+
max_ack_pending: workerConfig?.batchSize ?? 10,
|
|
32
|
+
};
|
|
33
|
+
// Unlike pg-boss (where expiry is a fixed ceiling from job start), JetStream
|
|
34
|
+
// redelivers purely on ack_wait — so this doubles as the abandonment timeout.
|
|
35
|
+
// It must exceed the longest a handler can legitimately run, or a still-running
|
|
36
|
+
// job gets redelivered and executed twice. Always set, never left to the
|
|
37
|
+
// server default — see DEFAULT_ACK_WAIT_MS.
|
|
38
|
+
config.ack_wait =
|
|
39
|
+
(workerConfig?.lockDuration ?? DEFAULT_ACK_WAIT_MS) * MS_TO_NS;
|
|
40
|
+
return config;
|
|
41
|
+
};
|
|
42
|
+
/**
|
|
43
|
+
* JetStream queue worker implementation.
|
|
44
|
+
*
|
|
45
|
+
* Cron and delayed work are handled by `NatsSchedulerService`, which uses the
|
|
46
|
+
* server's own message scheduler (2.14+) rather than a database.
|
|
47
|
+
*/
|
|
48
|
+
export class NatsQueueWorkers {
|
|
49
|
+
js;
|
|
50
|
+
jsm;
|
|
51
|
+
streamName;
|
|
52
|
+
subjectPrefix;
|
|
53
|
+
defaultConsumerConfig;
|
|
54
|
+
name = 'nats';
|
|
55
|
+
supportsResults = false;
|
|
56
|
+
configMappings = {
|
|
57
|
+
supported: {
|
|
58
|
+
batchSize: {
|
|
59
|
+
queueProperty: 'max_ack_pending',
|
|
60
|
+
description: 'Number of messages this consumer may hold unacked at once — per-message concurrency',
|
|
61
|
+
},
|
|
62
|
+
lockDuration: {
|
|
63
|
+
queueProperty: 'ack_wait',
|
|
64
|
+
transform: (value) => value * MS_TO_NS,
|
|
65
|
+
description: 'How long the server waits for an ack before redelivering (converted from ms to ns)',
|
|
66
|
+
},
|
|
67
|
+
},
|
|
68
|
+
unsupported: {
|
|
69
|
+
name: {
|
|
70
|
+
reason: 'Worker names are not supported in JetStream',
|
|
71
|
+
explanation: 'Consumers are identified by their durable name, which is derived from the queue name',
|
|
72
|
+
},
|
|
73
|
+
autorun: {
|
|
74
|
+
reason: 'Autorun is not configurable',
|
|
75
|
+
explanation: 'Consumers begin pulling as soon as they are registered',
|
|
76
|
+
},
|
|
77
|
+
drainDelay: {
|
|
78
|
+
reason: 'Drain delay is not configurable in JetStream',
|
|
79
|
+
explanation: 'Shutdown is handled by stopping consumption and letting in-flight acks settle',
|
|
80
|
+
},
|
|
81
|
+
pollInterval: {
|
|
82
|
+
reason: 'Pull consumers are not timer-driven',
|
|
83
|
+
explanation: 'The server pushes messages to a waiting pull request as they arrive, so there is no poll interval to tune',
|
|
84
|
+
},
|
|
85
|
+
groupConcurrency: {
|
|
86
|
+
reason: 'JetStream has no per-group fairness primitive',
|
|
87
|
+
explanation: 'Fairness across groups would need either a stream per group or application-side accounting',
|
|
88
|
+
},
|
|
89
|
+
},
|
|
90
|
+
fallbacks: {
|
|
91
|
+
removeOnComplete: {
|
|
92
|
+
reason: 'Work-queue streams delete messages on ack',
|
|
93
|
+
explanation: 'A completed message is removed from the stream immediately, so there is no retention to configure',
|
|
94
|
+
fallbackValue: 'Always removed on ack',
|
|
95
|
+
},
|
|
96
|
+
removeOnFail: {
|
|
97
|
+
reason: 'Exhausted messages leave via max_deliver, not retention',
|
|
98
|
+
explanation: 'A message that exceeds max_deliver raises a MAX_DELIVERIES advisory and is dropped from the stream',
|
|
99
|
+
fallbackValue: 'Managed by max_deliver',
|
|
100
|
+
},
|
|
101
|
+
},
|
|
102
|
+
};
|
|
103
|
+
activeConsumers = new Map();
|
|
104
|
+
constructor(js, jsm, streamName, subjectPrefix, defaultConsumerConfig = {}) {
|
|
105
|
+
this.js = js;
|
|
106
|
+
this.jsm = jsm;
|
|
107
|
+
this.streamName = streamName;
|
|
108
|
+
this.subjectPrefix = subjectPrefix;
|
|
109
|
+
this.defaultConsumerConfig = defaultConsumerConfig;
|
|
110
|
+
}
|
|
111
|
+
async registerQueues(logger) {
|
|
112
|
+
if (!logger) {
|
|
113
|
+
logger = pikkuState(null, 'package', 'singletonServices')?.logger;
|
|
114
|
+
}
|
|
115
|
+
if (!logger) {
|
|
116
|
+
throw new Error('Logger is required for registerQueues — pass it explicitly or ensure singleton services are initialized first');
|
|
117
|
+
}
|
|
118
|
+
const log = logger;
|
|
119
|
+
return await registerQueueWorkers(this.configMappings, log, async (queueName, processor) => {
|
|
120
|
+
const handle = await consumeQueue({
|
|
121
|
+
js: this.js,
|
|
122
|
+
jsm: this.jsm,
|
|
123
|
+
streamName: this.streamName,
|
|
124
|
+
subjectPrefix: this.subjectPrefix,
|
|
125
|
+
queueName,
|
|
126
|
+
config: {
|
|
127
|
+
...this.defaultConsumerConfig,
|
|
128
|
+
...mapPikkuWorkerToNats(processor.config),
|
|
129
|
+
},
|
|
130
|
+
handler: (msg) => this.handleMessage(queueName, msg, log),
|
|
131
|
+
logger: log,
|
|
132
|
+
});
|
|
133
|
+
this.activeConsumers.set(queueName, handle);
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
async handleMessage(queueName, msg, logger) {
|
|
137
|
+
try {
|
|
138
|
+
await runQueueJob({ job: mapJsMsgToQueueJob(queueName, msg) });
|
|
139
|
+
msg.ack();
|
|
140
|
+
}
|
|
141
|
+
catch (error) {
|
|
142
|
+
if (error instanceof QueueJobDiscardedError) {
|
|
143
|
+
// Permanently done, do not redeliver — the JetStream equivalent of
|
|
144
|
+
// pg-boss completing a job in order to discard it.
|
|
145
|
+
logger.info(`NATS message ${msg.seq} on ${queueName} discarded: ${error.message}`);
|
|
146
|
+
msg.term();
|
|
147
|
+
return;
|
|
148
|
+
}
|
|
149
|
+
const message = error instanceof QueueJobFailedError || error instanceof Error
|
|
150
|
+
? error.message
|
|
151
|
+
: 'Unknown error';
|
|
152
|
+
// Enforce the job's own retry policy. This CANNOT be left to the consumer:
|
|
153
|
+
// `attempts` is per-job (the workflow engine sets it per step, and uses 1
|
|
154
|
+
// to mean "never retry"), while max_deliver is per-consumer — and an unset
|
|
155
|
+
// max_deliver means unlimited, so without this a step marked no-retry
|
|
156
|
+
// would be redelivered forever.
|
|
157
|
+
const attempts = attemptsFor(msg);
|
|
158
|
+
if (attempts !== undefined && msg.info.deliveryCount >= attempts) {
|
|
159
|
+
logger.error(`NATS message ${msg.seq} on ${queueName} failed after ${msg.info.deliveryCount}/${attempts} attempts, giving up: ${message}`);
|
|
160
|
+
msg.term();
|
|
161
|
+
return;
|
|
162
|
+
}
|
|
163
|
+
const delayMs = backoffFor(msg);
|
|
164
|
+
logger.error(`NATS message ${msg.seq} on ${queueName} failed (attempt ${msg.info.deliveryCount}${attempts ? `/${attempts}` : ''}), retrying in ${delayMs}ms: ${message}`);
|
|
165
|
+
// nak(delay) is what makes backoff real. A bare nak() redelivers as fast
|
|
166
|
+
// as the server can, which turns an exponential-backoff policy into a hot
|
|
167
|
+
// loop against whatever is already failing.
|
|
168
|
+
msg.nak(delayMs);
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
/** Stop consuming. In-flight handlers are left to settle their own acks. */
|
|
172
|
+
async stop() {
|
|
173
|
+
for (const handle of this.activeConsumers.values()) {
|
|
174
|
+
handle.stop();
|
|
175
|
+
}
|
|
176
|
+
this.activeConsumers.clear();
|
|
177
|
+
}
|
|
178
|
+
/**
|
|
179
|
+
* Stop consuming and wait for in-flight jobs to settle, up to `timeoutMs`
|
|
180
|
+
* across all queues. Returns the queues that did NOT finish in time.
|
|
181
|
+
*
|
|
182
|
+
* Use this on shutdown in preference to `stop()`. Without it a redeploy kills
|
|
183
|
+
* handlers mid-job, and every unacked message waits out `ack_wait` before
|
|
184
|
+
* being redelivered — safe, but slow, and it re-runs whatever side effects the
|
|
185
|
+
* handler had already performed.
|
|
186
|
+
*/
|
|
187
|
+
async drain(timeoutMs) {
|
|
188
|
+
const deadline = Date.now() + timeoutMs;
|
|
189
|
+
const entries = [...this.activeConsumers.entries()];
|
|
190
|
+
this.activeConsumers.clear();
|
|
191
|
+
// Drained concurrently, not in sequence: one slow queue must not eat the
|
|
192
|
+
// whole budget and leave the rest no time at all.
|
|
193
|
+
const results = await Promise.all(entries.map(async ([queueName, handle]) => {
|
|
194
|
+
const drained = await handle.drain(Math.max(0, deadline - Date.now()));
|
|
195
|
+
return drained ? null : queueName;
|
|
196
|
+
}));
|
|
197
|
+
return results.filter((name) => name !== null);
|
|
198
|
+
}
|
|
199
|
+
}
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
import type { JetStreamClient, JetStreamManager } from '@nats-io/jetstream';
|
|
2
|
+
import type { CoreUserSession } from '@pikku/core/types';
|
|
3
|
+
import type { ScheduledTaskInfo, ScheduledTaskSummary } from '@pikku/core/services';
|
|
4
|
+
import { SchedulerService } from '@pikku/core/services';
|
|
5
|
+
/**
|
|
6
|
+
* Split a crontab expression into the schedule NATS wants and the timezone it
|
|
7
|
+
* was written in.
|
|
8
|
+
*
|
|
9
|
+
* The timezone has to come out of the expression because pikku's
|
|
10
|
+
* `CoreScheduledTask` has no timezone field — the cron string is the only place
|
|
11
|
+
* an author can say what zone they meant, and `TZ=`/`CRON_TZ=` is how crontab
|
|
12
|
+
* has always said it. It cannot be left in place either: NATS carries the zone
|
|
13
|
+
* in a header, and a prefix left in the expression lands in the seconds field,
|
|
14
|
+
* where it is rejected outright (`TZ=... 0 3 * * *`) or, from a 4-field
|
|
15
|
+
* expression, silently shifts every field along.
|
|
16
|
+
*/
|
|
17
|
+
export declare const parseNatsSchedule: (cronExpr: string) => {
|
|
18
|
+
schedule: string;
|
|
19
|
+
timezone?: string;
|
|
20
|
+
};
|
|
21
|
+
/**
|
|
22
|
+
* Translate a 5-field crontab expression into the 6-field form NATS requires.
|
|
23
|
+
*
|
|
24
|
+
* NATS cron is seconds-first and rejects anything that is not exactly 6 fields.
|
|
25
|
+
* Pikku's `wireScheduler` schedules are ordinary 5-field crontab, so the
|
|
26
|
+
* leading `0` is added here rather than pushed onto every task definition.
|
|
27
|
+
*
|
|
28
|
+
* A `TZ=`/`CRON_TZ=` prefix is stripped, since it belongs in a header rather
|
|
29
|
+
* than in the expression — use `parseNatsSchedule` to get the zone as well.
|
|
30
|
+
*/
|
|
31
|
+
export declare const toNatsCron: (cronExpr: string) => string;
|
|
32
|
+
/**
|
|
33
|
+
* Scheduler backed by JetStream's own message scheduler (server 2.14+).
|
|
34
|
+
*
|
|
35
|
+
* Mirrors `PgBossSchedulerService`, with the schedules living in the broker
|
|
36
|
+
* instead of a `pgboss.schedule` table. A schedule *is* a retained message on a
|
|
37
|
+
* reserved subject: publishing registers it, republishing to the same subject
|
|
38
|
+
* replaces it, and purging the subject cancels it. The server then produces the
|
|
39
|
+
* message body onto `Nats-Schedule-Target` on each occurrence, where an
|
|
40
|
+
* ordinary consumer picks it up.
|
|
41
|
+
*
|
|
42
|
+
* Two behaviours differ from a database-backed scheduler, both deliberate:
|
|
43
|
+
*
|
|
44
|
+
* - A missed occurrence is skipped, not caught up. This matches pg-boss, whose
|
|
45
|
+
* cron monitor only fires a schedule whose previous occurrence is under a
|
|
46
|
+
* minute old — so an instance that was down over an occurrence has always
|
|
47
|
+
* dropped it rather than replaying it.
|
|
48
|
+
* - There is no job history. A WorkQueue stream deletes a message on ack, so
|
|
49
|
+
* "did this task run" comes from telemetry, not from the broker.
|
|
50
|
+
*/
|
|
51
|
+
export declare class NatsSchedulerService extends SchedulerService {
|
|
52
|
+
private readonly js;
|
|
53
|
+
private readonly jsm;
|
|
54
|
+
private readonly streamName;
|
|
55
|
+
private readonly subjectPrefix;
|
|
56
|
+
/**
|
|
57
|
+
* Zone for tasks that do not name one themselves. Applies to every task
|
|
58
|
+
* without a `TZ=` prefix; absent means the server's own zone, which is UTC
|
|
59
|
+
* unless it was configured otherwise.
|
|
60
|
+
*/
|
|
61
|
+
private readonly defaultTimezone?;
|
|
62
|
+
private consumer?;
|
|
63
|
+
/**
|
|
64
|
+
* Tasks whose schedule could not be registered on the last `start()`, by name.
|
|
65
|
+
*
|
|
66
|
+
* A schedule that fails to register is the worst failure this service has:
|
|
67
|
+
* every other task keeps firing, so the service and the process both look
|
|
68
|
+
* healthy while one task silently never runs. Kept so a caller can assert on
|
|
69
|
+
* it — a health check, or a boot that would rather fail than run degraded.
|
|
70
|
+
*/
|
|
71
|
+
readonly failedSchedules: Map<string, string>;
|
|
72
|
+
constructor(js: JetStreamClient, jsm: JetStreamManager, streamName: string, subjectPrefix: string,
|
|
73
|
+
/**
|
|
74
|
+
* Zone for tasks that do not name one themselves. Applies to every task
|
|
75
|
+
* without a `TZ=` prefix; absent means the server's own zone, which is UTC
|
|
76
|
+
* unless it was configured otherwise.
|
|
77
|
+
*/
|
|
78
|
+
defaultTimezone?: string | undefined);
|
|
79
|
+
/** No-op: the connection and stream are owned by NatsServiceFactory. */
|
|
80
|
+
init(): Promise<void>;
|
|
81
|
+
/**
|
|
82
|
+
* Schedule a one-off delayed RPC.
|
|
83
|
+
*
|
|
84
|
+
* The returned task id is the schedule's own subject, which is what makes
|
|
85
|
+
* `unschedule` and `getTask` exact — there is no id to look up, the subject
|
|
86
|
+
* *is* the handle. `@at` schedules are purged by the server once they fire,
|
|
87
|
+
* so a fired task simply stops being findable.
|
|
88
|
+
*/
|
|
89
|
+
scheduleRPC(delay: number | string, rpcName: string, data?: any, session?: CoreUserSession): Promise<string>;
|
|
90
|
+
/**
|
|
91
|
+
* Cancel a scheduled task. Idempotent — purging a subject that holds no
|
|
92
|
+
* message is not an error, so cancelling a task that already fired returns
|
|
93
|
+
* false rather than throwing.
|
|
94
|
+
*/
|
|
95
|
+
unschedule(taskId: string): Promise<boolean>;
|
|
96
|
+
getTask(taskId: string): Promise<ScheduledTaskInfo | null>;
|
|
97
|
+
/**
|
|
98
|
+
* List every schedule this service owns.
|
|
99
|
+
*
|
|
100
|
+
* Unlike the pg-boss implementation — which returned an empty array because
|
|
101
|
+
* pg-boss has no cheap way to enumerate pending jobs — this is exact: the
|
|
102
|
+
* stream's subject list *is* the set of live schedules, so one
|
|
103
|
+
* `subjects_filter` call answers it. That listing is also what prunes
|
|
104
|
+
* orphans in `start()`.
|
|
105
|
+
*/
|
|
106
|
+
getAllTasks(): Promise<ScheduledTaskSummary[]>;
|
|
107
|
+
/** No-op: the connection is owned by NatsServiceFactory. */
|
|
108
|
+
close(): Promise<void>;
|
|
109
|
+
/**
|
|
110
|
+
* Register every code-declared recurring task with the broker and start
|
|
111
|
+
* consuming their fires.
|
|
112
|
+
*
|
|
113
|
+
* Rewriting all schedules on every boot is the point, not waste: a schedule
|
|
114
|
+
* lives in the broker and the code is the only source of truth for what
|
|
115
|
+
* should exist, so republishing reconciles a changed cron expression, and the
|
|
116
|
+
* prune below reconciles a deleted task. Without the prune a task removed
|
|
117
|
+
* from code would keep firing forever onto a queue whose handler no longer
|
|
118
|
+
* recognises it — the NATS-side version of the pg-boss bug where a removed
|
|
119
|
+
* task flooded a queue with stuck jobs.
|
|
120
|
+
*/
|
|
121
|
+
start(): Promise<void>;
|
|
122
|
+
/**
|
|
123
|
+
* Deregister every recurring schedule and drop the fires already queued.
|
|
124
|
+
*
|
|
125
|
+
* A runner that drives crons some other way has to call this, because a
|
|
126
|
+
* schedule lives in the broker rather than in the process: one left behind by
|
|
127
|
+
* an earlier deployment keeps firing on time into a queue nothing consumes,
|
|
128
|
+
* and on a work-queue stream those fires are never reclaimed. `start()`
|
|
129
|
+
* re-registers everything it owns, so a runner that does drive crons here can
|
|
130
|
+
* call this first at no cost.
|
|
131
|
+
*/
|
|
132
|
+
pruneAll(): Promise<number>;
|
|
133
|
+
/**
|
|
134
|
+
* Stop consuming fires, leaving the schedules registered.
|
|
135
|
+
*
|
|
136
|
+
* Deliberately not symmetric with `start()`: the pg-boss implementation
|
|
137
|
+
* unscheduled everything on stop, which meant a rolling deploy briefly had no
|
|
138
|
+
* schedules at all and any occurrence in that window was lost. Schedules are
|
|
139
|
+
* shared state that outlive one instance; only the consumer is per-instance.
|
|
140
|
+
*/
|
|
141
|
+
stop(): Promise<void>;
|
|
142
|
+
/** Run one recurring fire. Owns the message's terminal call — `consumeQueue`
|
|
143
|
+
* launches handlers without awaiting them, so it cannot ack on their behalf. */
|
|
144
|
+
private runFire;
|
|
145
|
+
/** Stable schedule subject for a code-declared recurring task. */
|
|
146
|
+
private recurringSubjectFor;
|
|
147
|
+
/** Guards against a caller passing an id from some other scheduler, which
|
|
148
|
+
* would otherwise purge an arbitrary subject in this stream. */
|
|
149
|
+
private ownsSubject;
|
|
150
|
+
/**
|
|
151
|
+
* Subjects currently holding a schedule, optionally narrowed to one queue.
|
|
152
|
+
* `subjects_filter` makes the server return its per-subject message counts,
|
|
153
|
+
* which for schedule subjects is exactly the live schedule set.
|
|
154
|
+
*/
|
|
155
|
+
private scheduleSubjects;
|
|
156
|
+
}
|