@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,369 @@
|
|
|
1
|
+
import { AckPolicy, DeliverPolicy } from '@nats-io/jetstream';
|
|
2
|
+
import { headers } from '@nats-io/nats-core';
|
|
3
|
+
import { SchedulerService } from '@pikku/core/services';
|
|
4
|
+
import { parseDurationString } from '@pikku/core/utils';
|
|
5
|
+
import { pikkuState } from '@pikku/core/state';
|
|
6
|
+
import { getScheduledTasks, runScheduledTask } from '@pikku/core/scheduler';
|
|
7
|
+
import { consumeQueue } from './consume.js';
|
|
8
|
+
import { SCHEDULE_HEADER, SCHEDULE_TARGET_HEADER, SCHEDULE_TIMEZONE_HEADER, SCHEDULE_SUBJECT_SEGMENT, queueNameToToken, scheduleSubjectFor, subjectForQueue, } from './utils.js';
|
|
9
|
+
/**
|
|
10
|
+
* Queue recurring cron fires land on.
|
|
11
|
+
*
|
|
12
|
+
* Deliberately separate from `pikku-remote-internal-rpc` (where one-off
|
|
13
|
+
* `scheduleRPC` calls land): that queue is a registered pikku worker whose
|
|
14
|
+
* consumer is owned by the queue registry, and a recurring task is not an RPC
|
|
15
|
+
* invocation — it goes through `runScheduledTask`, which resolves the task's
|
|
16
|
+
* own wiring, middleware and session. Sharing one queue would mean one handler
|
|
17
|
+
* having to guess which of the two a message was.
|
|
18
|
+
*/
|
|
19
|
+
const RECURRING_TASK_QUEUE = 'pikku-recurring-scheduled-task';
|
|
20
|
+
/** Queue a one-off `scheduleRPC` fires onto — a registered pikku worker. */
|
|
21
|
+
const REMOTE_RPC_QUEUE = 'pikku-remote-internal-rpc';
|
|
22
|
+
/** Concurrent in-flight scheduled tasks. */
|
|
23
|
+
const RECURRING_MAX_ACK_PENDING = 5;
|
|
24
|
+
/** Ack window for a recurring fire. Generous: recurring tasks are batch
|
|
25
|
+
* jobs (key rotation, data enrichment), not request-shaped work. */
|
|
26
|
+
const RECURRING_ACK_WAIT_NS = 15 * 60_000 * 1_000_000;
|
|
27
|
+
/**
|
|
28
|
+
* Crontab's timezone prefix. Both spellings are in the wild — `CRON_TZ=` is
|
|
29
|
+
* what vixie-cron documents, `TZ=` is what most people write.
|
|
30
|
+
*/
|
|
31
|
+
const TIMEZONE_PREFIX = /^(?:TZ|CRON_TZ)=(\S+)\s+(.+)$/;
|
|
32
|
+
/** Add the seconds field NATS requires, if the expression is 5-field crontab. */
|
|
33
|
+
const toSixFields = (cronExpr) => {
|
|
34
|
+
const trimmed = cronExpr.trim();
|
|
35
|
+
const fields = trimmed.split(/\s+/);
|
|
36
|
+
return fields.length === 5 ? `0 ${fields.join(' ')}` : trimmed;
|
|
37
|
+
};
|
|
38
|
+
/**
|
|
39
|
+
* Split a crontab expression into the schedule NATS wants and the timezone it
|
|
40
|
+
* was written in.
|
|
41
|
+
*
|
|
42
|
+
* The timezone has to come out of the expression because pikku's
|
|
43
|
+
* `CoreScheduledTask` has no timezone field — the cron string is the only place
|
|
44
|
+
* an author can say what zone they meant, and `TZ=`/`CRON_TZ=` is how crontab
|
|
45
|
+
* has always said it. It cannot be left in place either: NATS carries the zone
|
|
46
|
+
* in a header, and a prefix left in the expression lands in the seconds field,
|
|
47
|
+
* where it is rejected outright (`TZ=... 0 3 * * *`) or, from a 4-field
|
|
48
|
+
* expression, silently shifts every field along.
|
|
49
|
+
*/
|
|
50
|
+
export const parseNatsSchedule = (cronExpr) => {
|
|
51
|
+
const match = TIMEZONE_PREFIX.exec(cronExpr.trim());
|
|
52
|
+
if (!match)
|
|
53
|
+
return { schedule: toSixFields(cronExpr) };
|
|
54
|
+
return { schedule: toSixFields(match[2]), timezone: match[1] };
|
|
55
|
+
};
|
|
56
|
+
/**
|
|
57
|
+
* Translate a 5-field crontab expression into the 6-field form NATS requires.
|
|
58
|
+
*
|
|
59
|
+
* NATS cron is seconds-first and rejects anything that is not exactly 6 fields.
|
|
60
|
+
* Pikku's `wireScheduler` schedules are ordinary 5-field crontab, so the
|
|
61
|
+
* leading `0` is added here rather than pushed onto every task definition.
|
|
62
|
+
*
|
|
63
|
+
* A `TZ=`/`CRON_TZ=` prefix is stripped, since it belongs in a header rather
|
|
64
|
+
* than in the expression — use `parseNatsSchedule` to get the zone as well.
|
|
65
|
+
*/
|
|
66
|
+
export const toNatsCron = (cronExpr) => parseNatsSchedule(cronExpr).schedule;
|
|
67
|
+
/**
|
|
68
|
+
* Scheduler backed by JetStream's own message scheduler (server 2.14+).
|
|
69
|
+
*
|
|
70
|
+
* Mirrors `PgBossSchedulerService`, with the schedules living in the broker
|
|
71
|
+
* instead of a `pgboss.schedule` table. A schedule *is* a retained message on a
|
|
72
|
+
* reserved subject: publishing registers it, republishing to the same subject
|
|
73
|
+
* replaces it, and purging the subject cancels it. The server then produces the
|
|
74
|
+
* message body onto `Nats-Schedule-Target` on each occurrence, where an
|
|
75
|
+
* ordinary consumer picks it up.
|
|
76
|
+
*
|
|
77
|
+
* Two behaviours differ from a database-backed scheduler, both deliberate:
|
|
78
|
+
*
|
|
79
|
+
* - A missed occurrence is skipped, not caught up. This matches pg-boss, whose
|
|
80
|
+
* cron monitor only fires a schedule whose previous occurrence is under a
|
|
81
|
+
* minute old — so an instance that was down over an occurrence has always
|
|
82
|
+
* dropped it rather than replaying it.
|
|
83
|
+
* - There is no job history. A WorkQueue stream deletes a message on ack, so
|
|
84
|
+
* "did this task run" comes from telemetry, not from the broker.
|
|
85
|
+
*/
|
|
86
|
+
export class NatsSchedulerService extends SchedulerService {
|
|
87
|
+
js;
|
|
88
|
+
jsm;
|
|
89
|
+
streamName;
|
|
90
|
+
subjectPrefix;
|
|
91
|
+
defaultTimezone;
|
|
92
|
+
consumer;
|
|
93
|
+
/**
|
|
94
|
+
* Tasks whose schedule could not be registered on the last `start()`, by name.
|
|
95
|
+
*
|
|
96
|
+
* A schedule that fails to register is the worst failure this service has:
|
|
97
|
+
* every other task keeps firing, so the service and the process both look
|
|
98
|
+
* healthy while one task silently never runs. Kept so a caller can assert on
|
|
99
|
+
* it — a health check, or a boot that would rather fail than run degraded.
|
|
100
|
+
*/
|
|
101
|
+
failedSchedules = new Map();
|
|
102
|
+
constructor(js, jsm, streamName, subjectPrefix,
|
|
103
|
+
/**
|
|
104
|
+
* Zone for tasks that do not name one themselves. Applies to every task
|
|
105
|
+
* without a `TZ=` prefix; absent means the server's own zone, which is UTC
|
|
106
|
+
* unless it was configured otherwise.
|
|
107
|
+
*/
|
|
108
|
+
defaultTimezone) {
|
|
109
|
+
super();
|
|
110
|
+
this.js = js;
|
|
111
|
+
this.jsm = jsm;
|
|
112
|
+
this.streamName = streamName;
|
|
113
|
+
this.subjectPrefix = subjectPrefix;
|
|
114
|
+
this.defaultTimezone = defaultTimezone;
|
|
115
|
+
}
|
|
116
|
+
/** No-op: the connection and stream are owned by NatsServiceFactory. */
|
|
117
|
+
async init() { }
|
|
118
|
+
/**
|
|
119
|
+
* Schedule a one-off delayed RPC.
|
|
120
|
+
*
|
|
121
|
+
* The returned task id is the schedule's own subject, which is what makes
|
|
122
|
+
* `unschedule` and `getTask` exact — there is no id to look up, the subject
|
|
123
|
+
* *is* the handle. `@at` schedules are purged by the server once they fire,
|
|
124
|
+
* so a fired task simply stops being findable.
|
|
125
|
+
*/
|
|
126
|
+
async scheduleRPC(delay, rpcName, data, session) {
|
|
127
|
+
const delayMs = typeof delay === 'string' ? parseDurationString(delay) : delay;
|
|
128
|
+
const subject = scheduleSubjectFor(this.subjectPrefix, REMOTE_RPC_QUEUE);
|
|
129
|
+
const hdrs = headers();
|
|
130
|
+
// A timestamp already in the past fires immediately rather than erroring,
|
|
131
|
+
// which matches pg-boss's `startAfter` with an elapsed date.
|
|
132
|
+
hdrs.set(SCHEDULE_HEADER, `@at ${new Date(Date.now() + delayMs).toISOString()}`);
|
|
133
|
+
hdrs.set(SCHEDULE_TARGET_HEADER, subjectForQueue(this.subjectPrefix, REMOTE_RPC_QUEUE));
|
|
134
|
+
await this.js.publish(subject, JSON.stringify({ rpcName, data, session }), { headers: hdrs });
|
|
135
|
+
return subject;
|
|
136
|
+
}
|
|
137
|
+
/**
|
|
138
|
+
* Cancel a scheduled task. Idempotent — purging a subject that holds no
|
|
139
|
+
* message is not an error, so cancelling a task that already fired returns
|
|
140
|
+
* false rather than throwing.
|
|
141
|
+
*/
|
|
142
|
+
async unschedule(taskId) {
|
|
143
|
+
if (!this.ownsSubject(taskId))
|
|
144
|
+
return false;
|
|
145
|
+
const res = await this.jsm.streams.purge(this.streamName, {
|
|
146
|
+
filter: taskId,
|
|
147
|
+
});
|
|
148
|
+
return (res.purged ?? 0) > 0;
|
|
149
|
+
}
|
|
150
|
+
async getTask(taskId) {
|
|
151
|
+
if (!this.ownsSubject(taskId))
|
|
152
|
+
return null;
|
|
153
|
+
let msg;
|
|
154
|
+
try {
|
|
155
|
+
msg = await this.jsm.streams.getMessage(this.streamName, {
|
|
156
|
+
last_by_subj: taskId,
|
|
157
|
+
});
|
|
158
|
+
}
|
|
159
|
+
catch {
|
|
160
|
+
// 10037 "no message found" — an unknown or already-fired task.
|
|
161
|
+
return null;
|
|
162
|
+
}
|
|
163
|
+
if (!msg)
|
|
164
|
+
return null;
|
|
165
|
+
const jobData = JSON.parse(new TextDecoder().decode(msg.data));
|
|
166
|
+
return {
|
|
167
|
+
taskId,
|
|
168
|
+
rpcName: jobData.rpcName,
|
|
169
|
+
scheduledFor: nextFireFrom(msg.header.get(SCHEDULE_HEADER)) ?? msg.time,
|
|
170
|
+
data: jobData.data,
|
|
171
|
+
session: jobData.session,
|
|
172
|
+
status: 'scheduled',
|
|
173
|
+
};
|
|
174
|
+
}
|
|
175
|
+
/**
|
|
176
|
+
* List every schedule this service owns.
|
|
177
|
+
*
|
|
178
|
+
* Unlike the pg-boss implementation — which returned an empty array because
|
|
179
|
+
* pg-boss has no cheap way to enumerate pending jobs — this is exact: the
|
|
180
|
+
* stream's subject list *is* the set of live schedules, so one
|
|
181
|
+
* `subjects_filter` call answers it. That listing is also what prunes
|
|
182
|
+
* orphans in `start()`.
|
|
183
|
+
*/
|
|
184
|
+
async getAllTasks() {
|
|
185
|
+
const subjects = await this.scheduleSubjects();
|
|
186
|
+
const tasks = [];
|
|
187
|
+
for (const subject of subjects) {
|
|
188
|
+
const task = await this.getTask(subject);
|
|
189
|
+
if (task)
|
|
190
|
+
tasks.push(task);
|
|
191
|
+
}
|
|
192
|
+
return tasks;
|
|
193
|
+
}
|
|
194
|
+
/** No-op: the connection is owned by NatsServiceFactory. */
|
|
195
|
+
async close() { }
|
|
196
|
+
/**
|
|
197
|
+
* Register every code-declared recurring task with the broker and start
|
|
198
|
+
* consuming their fires.
|
|
199
|
+
*
|
|
200
|
+
* Rewriting all schedules on every boot is the point, not waste: a schedule
|
|
201
|
+
* lives in the broker and the code is the only source of truth for what
|
|
202
|
+
* should exist, so republishing reconciles a changed cron expression, and the
|
|
203
|
+
* prune below reconciles a deleted task. Without the prune a task removed
|
|
204
|
+
* from code would keep firing forever onto a queue whose handler no longer
|
|
205
|
+
* recognises it — the NATS-side version of the pg-boss bug where a removed
|
|
206
|
+
* task flooded a queue with stuck jobs.
|
|
207
|
+
*/
|
|
208
|
+
async start() {
|
|
209
|
+
const logger = pikkuState(null, 'package', 'singletonServices').logger;
|
|
210
|
+
const scheduledTasks = getScheduledTasks();
|
|
211
|
+
this.consumer = await consumeQueue({
|
|
212
|
+
js: this.js,
|
|
213
|
+
jsm: this.jsm,
|
|
214
|
+
streamName: this.streamName,
|
|
215
|
+
subjectPrefix: this.subjectPrefix,
|
|
216
|
+
queueName: RECURRING_TASK_QUEUE,
|
|
217
|
+
config: {
|
|
218
|
+
ack_policy: AckPolicy.Explicit,
|
|
219
|
+
deliver_policy: DeliverPolicy.All,
|
|
220
|
+
max_ack_pending: RECURRING_MAX_ACK_PENDING,
|
|
221
|
+
ack_wait: RECURRING_ACK_WAIT_NS,
|
|
222
|
+
},
|
|
223
|
+
handler: (msg) => this.runFire(msg),
|
|
224
|
+
logger,
|
|
225
|
+
});
|
|
226
|
+
const expected = new Set();
|
|
227
|
+
this.failedSchedules.clear();
|
|
228
|
+
for (const [name, task] of scheduledTasks) {
|
|
229
|
+
const subject = this.recurringSubjectFor(name);
|
|
230
|
+
expected.add(subject);
|
|
231
|
+
const { schedule, timezone } = parseNatsSchedule(task.schedule);
|
|
232
|
+
const hdrs = headers();
|
|
233
|
+
hdrs.set(SCHEDULE_HEADER, schedule);
|
|
234
|
+
hdrs.set(SCHEDULE_TARGET_HEADER, subjectForQueue(this.subjectPrefix, RECURRING_TASK_QUEUE));
|
|
235
|
+
const zone = timezone ?? this.defaultTimezone;
|
|
236
|
+
// Omitted rather than defaulted to 'UTC': the server resolves IANA names
|
|
237
|
+
// against its own tzdata and rejects the whole schedule if the zone is
|
|
238
|
+
// unknown, so an absent value — which already means UTC — is safer than
|
|
239
|
+
// naming it.
|
|
240
|
+
if (zone && zone !== 'UTC') {
|
|
241
|
+
hdrs.set(SCHEDULE_TIMEZONE_HEADER, zone);
|
|
242
|
+
}
|
|
243
|
+
try {
|
|
244
|
+
await this.js.publish(subject, JSON.stringify({ rpcName: name }), { headers: hdrs });
|
|
245
|
+
}
|
|
246
|
+
catch (err) {
|
|
247
|
+
// One bad cron expression must not take every other task down with it.
|
|
248
|
+
const reason = err instanceof Error ? err.message : String(err);
|
|
249
|
+
this.failedSchedules.set(name, reason);
|
|
250
|
+
logger.error(`Failed to schedule ${name} (${task.schedule}): ${reason}`);
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
// The per-task catch above keeps one bad expression from taking the rest
|
|
254
|
+
// down, but on its own it leaves the outcome as a line in a log nobody
|
|
255
|
+
// reads while `start()` returns as if everything registered. Say plainly,
|
|
256
|
+
// once, that this process is running with tasks that will never fire.
|
|
257
|
+
if (this.failedSchedules.size > 0) {
|
|
258
|
+
logger.error(`${this.failedSchedules.size} scheduled task(s) were NOT registered and will never fire: ${[
|
|
259
|
+
...this.failedSchedules.keys(),
|
|
260
|
+
].join(', ')}`);
|
|
261
|
+
}
|
|
262
|
+
try {
|
|
263
|
+
for (const subject of await this.scheduleSubjects(RECURRING_TASK_QUEUE)) {
|
|
264
|
+
if (expected.has(subject))
|
|
265
|
+
continue;
|
|
266
|
+
await this.jsm.streams.purge(this.streamName, { filter: subject });
|
|
267
|
+
logger.warn(`Pruned orphaned scheduled task: ${subject}`);
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
catch (err) {
|
|
271
|
+
logger.warn(`Failed to prune orphaned scheduled tasks: ${err instanceof Error ? err.message : String(err)}`);
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
/**
|
|
275
|
+
* Deregister every recurring schedule and drop the fires already queued.
|
|
276
|
+
*
|
|
277
|
+
* A runner that drives crons some other way has to call this, because a
|
|
278
|
+
* schedule lives in the broker rather than in the process: one left behind by
|
|
279
|
+
* an earlier deployment keeps firing on time into a queue nothing consumes,
|
|
280
|
+
* and on a work-queue stream those fires are never reclaimed. `start()`
|
|
281
|
+
* re-registers everything it owns, so a runner that does drive crons here can
|
|
282
|
+
* call this first at no cost.
|
|
283
|
+
*/
|
|
284
|
+
async pruneAll() {
|
|
285
|
+
const subjects = await this.scheduleSubjects(RECURRING_TASK_QUEUE);
|
|
286
|
+
for (const subject of subjects) {
|
|
287
|
+
await this.jsm.streams.purge(this.streamName, { filter: subject });
|
|
288
|
+
}
|
|
289
|
+
await this.jsm.streams.purge(this.streamName, {
|
|
290
|
+
filter: subjectForQueue(this.subjectPrefix, RECURRING_TASK_QUEUE),
|
|
291
|
+
});
|
|
292
|
+
return subjects.length;
|
|
293
|
+
}
|
|
294
|
+
/**
|
|
295
|
+
* Stop consuming fires, leaving the schedules registered.
|
|
296
|
+
*
|
|
297
|
+
* Deliberately not symmetric with `start()`: the pg-boss implementation
|
|
298
|
+
* unscheduled everything on stop, which meant a rolling deploy briefly had no
|
|
299
|
+
* schedules at all and any occurrence in that window was lost. Schedules are
|
|
300
|
+
* shared state that outlive one instance; only the consumer is per-instance.
|
|
301
|
+
*/
|
|
302
|
+
async stop() {
|
|
303
|
+
this.consumer?.stop();
|
|
304
|
+
this.consumer = undefined;
|
|
305
|
+
}
|
|
306
|
+
/** Run one recurring fire. Owns the message's terminal call — `consumeQueue`
|
|
307
|
+
* launches handlers without awaiting them, so it cannot ack on their behalf. */
|
|
308
|
+
async runFire(msg) {
|
|
309
|
+
const logger = pikkuState(null, 'package', 'singletonServices').logger;
|
|
310
|
+
let rpcName;
|
|
311
|
+
try {
|
|
312
|
+
rpcName = msg.json()?.rpcName;
|
|
313
|
+
if (!rpcName) {
|
|
314
|
+
logger.error(`Malformed scheduled task fire ${msg.seq} (no rpcName)`);
|
|
315
|
+
msg.term();
|
|
316
|
+
return;
|
|
317
|
+
}
|
|
318
|
+
logger.info(`Running scheduled task: ${rpcName}`);
|
|
319
|
+
await runScheduledTask({ name: rpcName });
|
|
320
|
+
msg.ack();
|
|
321
|
+
}
|
|
322
|
+
catch (err) {
|
|
323
|
+
// Terminal rather than retried: the next occurrence is already scheduled,
|
|
324
|
+
// and a cron task that failed is far more often wrong-in-code than
|
|
325
|
+
// unlucky. Redelivering would also let a slow-failing task stack up
|
|
326
|
+
// against the next fire, running two copies of work that assumed it was
|
|
327
|
+
// the only one.
|
|
328
|
+
logger.error(`Scheduled task ${rpcName ?? msg.seq} failed: ${err}`);
|
|
329
|
+
msg.term();
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
/** Stable schedule subject for a code-declared recurring task. */
|
|
333
|
+
recurringSubjectFor(taskName) {
|
|
334
|
+
return scheduleSubjectFor(this.subjectPrefix, RECURRING_TASK_QUEUE, taskName);
|
|
335
|
+
}
|
|
336
|
+
/** Guards against a caller passing an id from some other scheduler, which
|
|
337
|
+
* would otherwise purge an arbitrary subject in this stream. */
|
|
338
|
+
ownsSubject(subject) {
|
|
339
|
+
return subject.startsWith(`${this.subjectPrefix}.${SCHEDULE_SUBJECT_SEGMENT}.`);
|
|
340
|
+
}
|
|
341
|
+
/**
|
|
342
|
+
* Subjects currently holding a schedule, optionally narrowed to one queue.
|
|
343
|
+
* `subjects_filter` makes the server return its per-subject message counts,
|
|
344
|
+
* which for schedule subjects is exactly the live schedule set.
|
|
345
|
+
*/
|
|
346
|
+
async scheduleSubjects(queueName) {
|
|
347
|
+
const filter = queueName
|
|
348
|
+
? `${this.subjectPrefix}.${SCHEDULE_SUBJECT_SEGMENT}.${queueNameToToken(queueName)}.>`
|
|
349
|
+
: `${this.subjectPrefix}.${SCHEDULE_SUBJECT_SEGMENT}.>`;
|
|
350
|
+
const info = await this.jsm.streams.info(this.streamName, {
|
|
351
|
+
subjects_filter: filter,
|
|
352
|
+
});
|
|
353
|
+
return Object.keys(info.state.subjects ?? {});
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
/**
|
|
357
|
+
* Best-effort "when does this fire next" for `getTask`.
|
|
358
|
+
*
|
|
359
|
+
* Only `@at` is answerable without a cron parser, and that is the case that
|
|
360
|
+
* matters: `scheduleRPC` returns those ids, and a recurring task's next
|
|
361
|
+
* occurrence is better read from the server's own `Nats-Schedule-Next` on the
|
|
362
|
+
* produced message than recomputed here.
|
|
363
|
+
*/
|
|
364
|
+
const nextFireFrom = (schedule) => {
|
|
365
|
+
if (!schedule?.startsWith('@at '))
|
|
366
|
+
return undefined;
|
|
367
|
+
const parsed = new Date(schedule.slice(4).trim());
|
|
368
|
+
return Number.isNaN(parsed.getTime()) ? undefined : parsed;
|
|
369
|
+
};
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { type ConsumerConfig, type JetStreamClient, type JetStreamManager } from '@nats-io/jetstream';
|
|
2
|
+
import { type NatsConnection } from '@nats-io/transport-node';
|
|
3
|
+
import { NatsQueueService } from './nats-queue-service.js';
|
|
4
|
+
import { NatsSchedulerService } from './nats-scheduler-service.js';
|
|
5
|
+
import { NatsQueueWorkers } from './nats-queue-worker.js';
|
|
6
|
+
export interface NatsServiceFactoryOptions {
|
|
7
|
+
/** NATS server(s), e.g. `nats://services.internal:4222`. */
|
|
8
|
+
servers: string | string[];
|
|
9
|
+
/** Stream holding this prefix's jobs. Created if absent. */
|
|
10
|
+
streamName: string;
|
|
11
|
+
/**
|
|
12
|
+
* Subject prefix for every queue in this stream. The stream is bound to
|
|
13
|
+
* `<prefix>.>`, and each queue gets one consumer filtered to `<prefix>.<queue>`.
|
|
14
|
+
*/
|
|
15
|
+
subjectPrefix: string;
|
|
16
|
+
/** Applied to every consumer; per-worker config from pikku overrides these. */
|
|
17
|
+
defaultConsumerConfig?: Partial<ConsumerConfig>;
|
|
18
|
+
/** Replicas for the stream. 1 for a single node; 3 for a quorum cluster. */
|
|
19
|
+
replicas?: number;
|
|
20
|
+
/**
|
|
21
|
+
* JetStream publish dedupe window in ms, applied to the stream. This is what
|
|
22
|
+
* backs `JobOptions.jobId`, and it is NOT equivalent to pg-boss's singleton
|
|
23
|
+
* `jobId`: pg-boss rejects a duplicate for as long as the job row survives
|
|
24
|
+
* (retention, days), whereas JetStream only remembers message ids for this
|
|
25
|
+
* window. Set it deliberately — the server default is 2 minutes, which is
|
|
26
|
+
* short enough that a retrying producer can double-enqueue.
|
|
27
|
+
*/
|
|
28
|
+
duplicateWindowMs?: number;
|
|
29
|
+
/**
|
|
30
|
+
* Zone for recurring tasks that do not name one with a `TZ=` prefix. Absent
|
|
31
|
+
* means the server's own zone.
|
|
32
|
+
*/
|
|
33
|
+
schedulerTimezone?: string;
|
|
34
|
+
/** Credentials/auth passthrough. */
|
|
35
|
+
user?: string;
|
|
36
|
+
pass?: string;
|
|
37
|
+
token?: string;
|
|
38
|
+
name?: string;
|
|
39
|
+
/**
|
|
40
|
+
* Called if the connection closes without `close()` having been asked for.
|
|
41
|
+
*
|
|
42
|
+
* With infinite reconnect below this only fires on a fault the client refuses
|
|
43
|
+
* to retry — an authorization violation after a rotated password is the one
|
|
44
|
+
* that matters in practice. The connection is then dead for the life of the
|
|
45
|
+
* process and NOTHING reopens it, because `init()` runs once at boot. Rebuilding
|
|
46
|
+
* it here would not help: the queue, worker and scheduler services each captured
|
|
47
|
+
* the old JetStream client, so a new connection would be wired to nothing.
|
|
48
|
+
*
|
|
49
|
+
* So this is an escalation hook, not a repair one. A process holding a dead
|
|
50
|
+
* queue is not healthy — it publishes nothing and runs no cron — and the only
|
|
51
|
+
* honest responses are to exit and let the supervisor restart, or to fail a
|
|
52
|
+
* readiness probe. The caller owns that choice; the default is to warn, so a
|
|
53
|
+
* library consumer is never killed by surprise.
|
|
54
|
+
*/
|
|
55
|
+
onConnectionLost?: (err?: Error) => void;
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* Factory for the JetStream queue services.
|
|
59
|
+
*
|
|
60
|
+
* Mirrors PgBossServiceFactory, including `getSchedulerService`. That was not
|
|
61
|
+
* always true: this package originally had no scheduler at all, because
|
|
62
|
+
* JetStream had no delayed or scheduled message primitive and cron therefore
|
|
63
|
+
* stayed on pg-boss. Server 2.12 added one-shot `@at` delays and 2.14 added
|
|
64
|
+
* repeating cron schedules, so the split no longer buys anything and the
|
|
65
|
+
* pg-boss dependency is gone.
|
|
66
|
+
*/
|
|
67
|
+
export declare class NatsServiceFactory {
|
|
68
|
+
private readonly options;
|
|
69
|
+
private connection?;
|
|
70
|
+
private js?;
|
|
71
|
+
private jsm?;
|
|
72
|
+
private queueService?;
|
|
73
|
+
private queueWorkers?;
|
|
74
|
+
private schedulerService?;
|
|
75
|
+
private initialized;
|
|
76
|
+
/** Set by `close()`, so the close watcher can tell a shutdown from a fault. */
|
|
77
|
+
private closing;
|
|
78
|
+
constructor(options: NatsServiceFactoryOptions);
|
|
79
|
+
init(): Promise<void>;
|
|
80
|
+
/**
|
|
81
|
+
* Surfaces a connection that closed without being asked to — see
|
|
82
|
+
* `onConnectionLost`. Detached on purpose: `closed()` resolves only at the end
|
|
83
|
+
* of the connection's life, so awaiting it would never let `init()` return.
|
|
84
|
+
*/
|
|
85
|
+
private watchForUnexpectedClose;
|
|
86
|
+
getQueueService(): NatsQueueService;
|
|
87
|
+
getSchedulerService(): NatsSchedulerService;
|
|
88
|
+
getQueueWorkers(): NatsQueueWorkers;
|
|
89
|
+
/**
|
|
90
|
+
* Raw JetStream handles, for callers that run their own consumers instead of
|
|
91
|
+
* going through pikku's queue registry — see `consumeQueue`.
|
|
92
|
+
*/
|
|
93
|
+
getJetStream(): {
|
|
94
|
+
js: JetStreamClient;
|
|
95
|
+
jsm: JetStreamManager;
|
|
96
|
+
};
|
|
97
|
+
getConnection(): NatsConnection;
|
|
98
|
+
/** Drain the queue workers, if any are running. Returns the queues that still
|
|
99
|
+
* had jobs in flight. Safe before `init()`. */
|
|
100
|
+
drainWorkers(timeoutMs: number): Promise<string[]>;
|
|
101
|
+
close(): Promise<void>;
|
|
102
|
+
/** Alias for close() — used by stopSingletonServices. */
|
|
103
|
+
stop(): Promise<void>;
|
|
104
|
+
}
|