@byline/core 4.14.0 → 4.15.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.
- package/dist/@types/db-types.d.ts +222 -0
- package/dist/@types/site-config.d.ts +24 -0
- package/dist/codegen/index.test.node.js +1 -1
- package/dist/core.d.ts +8 -0
- package/dist/core.js +39 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +8 -11
- package/dist/scheduler/define-recurring-task.d.ts +19 -0
- package/dist/scheduler/define-recurring-task.js +20 -0
- package/dist/scheduler/index.d.ts +19 -0
- package/dist/scheduler/index.js +18 -0
- package/dist/scheduler/run-due-tasks.d.ts +39 -0
- package/dist/scheduler/run-due-tasks.js +324 -0
- package/dist/scheduler/run-due-tasks.test.node.d.ts +8 -0
- package/dist/scheduler/run-due-tasks.test.node.js +396 -0
- package/dist/scheduler/scheduled-publication-constants.d.ts +11 -0
- package/dist/scheduler/scheduled-publication-constants.js +11 -0
- package/dist/scheduler/scheduled-publication.d.ts +31 -0
- package/dist/scheduler/scheduled-publication.js +198 -0
- package/dist/scheduler/scheduled-publication.test.node.d.ts +8 -0
- package/dist/scheduler/scheduled-publication.test.node.js +109 -0
- package/dist/scheduler/scheduler-boot.test.node.d.ts +8 -0
- package/dist/scheduler/scheduler-boot.test.node.js +103 -0
- package/dist/scheduler/ticker.d.ts +34 -0
- package/dist/scheduler/ticker.js +144 -0
- package/dist/scheduler/ticker.test.node.d.ts +8 -0
- package/dist/scheduler/ticker.test.node.js +249 -0
- package/dist/scheduler/types.d.ts +241 -0
- package/dist/scheduler/types.js +8 -0
- package/dist/scheduler/validate-scheduler-config.d.ts +19 -0
- package/dist/scheduler/validate-scheduler-config.js +25 -0
- package/dist/scheduler/validate-scheduler-config.test.node.d.ts +8 -0
- package/dist/scheduler/validate-scheduler-config.test.node.js +38 -0
- package/dist/scheduler/validate-tasks.d.ts +14 -0
- package/dist/scheduler/validate-tasks.js +40 -0
- package/dist/scheduler/validate-tasks.test.node.d.ts +8 -0
- package/dist/scheduler/validate-tasks.test.node.js +49 -0
- package/dist/services/collection-bootstrap.test.node.js +2 -0
- package/dist/services/discover-counter-groups.test.node.js +2 -0
- package/dist/services/document-lifecycle/audit.d.ts +6 -0
- package/dist/services/document-lifecycle/audit.js +6 -0
- package/dist/services/document-lifecycle/copy-to-locale.js +15 -10
- package/dist/services/document-lifecycle/delete-locale.js +18 -10
- package/dist/services/document-lifecycle/delete.js +8 -0
- package/dist/services/document-lifecycle/index.d.ts +1 -0
- package/dist/services/document-lifecycle/index.js +1 -0
- package/dist/services/document-lifecycle/publish-schedule-consistency.d.ts +36 -0
- package/dist/services/document-lifecycle/publish-schedule-consistency.js +80 -0
- package/dist/services/document-lifecycle/restore.js +15 -10
- package/dist/services/document-lifecycle/scheduled-publish.d.ts +51 -0
- package/dist/services/document-lifecycle/scheduled-publish.js +351 -0
- package/dist/services/document-lifecycle/status-transition.d.ts +42 -0
- package/dist/services/document-lifecycle/status-transition.js +41 -0
- package/dist/services/document-lifecycle/status-transition.test.node.d.ts +8 -0
- package/dist/services/document-lifecycle/status-transition.test.node.js +145 -0
- package/dist/services/document-lifecycle/status.js +30 -23
- package/dist/services/document-lifecycle/tree.test.node.js +3 -0
- package/dist/services/document-lifecycle/update.js +35 -30
- package/dist/services/document-lifecycle.test.node.js +262 -2
- package/dist/services/field-upload.test.node.js +2 -0
- package/dist/services/populate.test.node.js +2 -0
- package/package.json +7 -2
|
@@ -0,0 +1,324 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* This Source Code is subject to the terms of the Mozilla Public
|
|
3
|
+
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
|
4
|
+
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
|
5
|
+
*
|
|
6
|
+
* Copyright (c) Infonomic Company Limited
|
|
7
|
+
*/
|
|
8
|
+
import { hostname } from 'node:os';
|
|
9
|
+
const DEFAULT_CONCURRENCY = 2;
|
|
10
|
+
const MAX_OWNER_LENGTH = 255;
|
|
11
|
+
const MAX_STORED_ERROR_LENGTH = 2_048;
|
|
12
|
+
class LeaseLostError extends Error {
|
|
13
|
+
constructor(taskName) {
|
|
14
|
+
super(`recurring task '${taskName}' lost its lease`);
|
|
15
|
+
this.name = 'LeaseLostError';
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
class HeartbeatError extends Error {
|
|
19
|
+
constructor(taskName, cause) {
|
|
20
|
+
super(`recurring task '${taskName}' could not renew its lease`, { cause });
|
|
21
|
+
this.name = 'HeartbeatError';
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
function normalizeConcurrency(value) {
|
|
25
|
+
const concurrency = value ?? DEFAULT_CONCURRENCY;
|
|
26
|
+
if (!Number.isSafeInteger(concurrency) || concurrency < 1) {
|
|
27
|
+
throw new Error(`scheduler concurrency must be a positive whole number (received ${concurrency})`);
|
|
28
|
+
}
|
|
29
|
+
return concurrency;
|
|
30
|
+
}
|
|
31
|
+
function durationSince(startedAt) {
|
|
32
|
+
return Math.max(0, Date.now() - startedAt);
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Store one bounded, single-line message. The complete error object (including
|
|
36
|
+
* its stack, when it is an Error) goes only to the configured logger.
|
|
37
|
+
*/
|
|
38
|
+
function sanitizeError(error) {
|
|
39
|
+
let message;
|
|
40
|
+
if (error instanceof Error) {
|
|
41
|
+
message = error.message || error.name;
|
|
42
|
+
}
|
|
43
|
+
else {
|
|
44
|
+
try {
|
|
45
|
+
message = typeof error === 'string' ? error : String(error);
|
|
46
|
+
}
|
|
47
|
+
catch {
|
|
48
|
+
message = 'Unknown recurring task error';
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
const newlineIndex = message.indexOf('\n');
|
|
52
|
+
const firstLine = newlineIndex === -1 ? message : message.slice(0, newlineIndex);
|
|
53
|
+
let sanitized = '';
|
|
54
|
+
for (const character of firstLine) {
|
|
55
|
+
const codePoint = character.codePointAt(0) ?? 0;
|
|
56
|
+
sanitized += codePoint <= 31 || codePoint === 127 ? ' ' : character;
|
|
57
|
+
if (sanitized.length >= MAX_STORED_ERROR_LENGTH)
|
|
58
|
+
break;
|
|
59
|
+
}
|
|
60
|
+
sanitized = sanitized.trim();
|
|
61
|
+
return (sanitized || 'Unknown recurring task error').slice(0, MAX_STORED_ERROR_LENGTH);
|
|
62
|
+
}
|
|
63
|
+
function logLostLease(params) {
|
|
64
|
+
params.logger.warn({
|
|
65
|
+
event: 'scheduler.lost-lease',
|
|
66
|
+
name: params.name,
|
|
67
|
+
owner: params.owner,
|
|
68
|
+
durationMs: params.durationMs,
|
|
69
|
+
phase: params.phase,
|
|
70
|
+
}, 'Recurring task lost its lease');
|
|
71
|
+
}
|
|
72
|
+
async function runClaimedTask(params) {
|
|
73
|
+
const { store, task, claim, owner, logger, signal, summary } = params;
|
|
74
|
+
const controller = new AbortController();
|
|
75
|
+
let state = 'active';
|
|
76
|
+
const startedAt = Date.now();
|
|
77
|
+
const abortFromParent = () => {
|
|
78
|
+
if (state !== 'active')
|
|
79
|
+
return;
|
|
80
|
+
state = 'aborted';
|
|
81
|
+
controller.abort(signal?.reason);
|
|
82
|
+
};
|
|
83
|
+
if (signal?.aborted)
|
|
84
|
+
abortFromParent();
|
|
85
|
+
else
|
|
86
|
+
signal?.addEventListener('abort', abortFromParent, { once: true });
|
|
87
|
+
if (claim.recoveredExpiredLease) {
|
|
88
|
+
logger.warn({
|
|
89
|
+
event: 'scheduler.recovered-expired-lease',
|
|
90
|
+
name: task.name,
|
|
91
|
+
owner,
|
|
92
|
+
durationMs: 0,
|
|
93
|
+
}, 'Recurring task recovered an expired lease');
|
|
94
|
+
}
|
|
95
|
+
logger.info({ event: 'scheduler.start', name: task.name, owner, durationMs: 0 }, 'Recurring task started');
|
|
96
|
+
let result = null;
|
|
97
|
+
let handlerRejected = false;
|
|
98
|
+
let handlerError;
|
|
99
|
+
try {
|
|
100
|
+
if (state === 'active') {
|
|
101
|
+
const taskResult = await task.run({
|
|
102
|
+
taskName: task.name,
|
|
103
|
+
scheduledFor: claim.scheduledFor,
|
|
104
|
+
signal: controller.signal,
|
|
105
|
+
logger,
|
|
106
|
+
heartbeat: async () => {
|
|
107
|
+
if (state === 'lease-lost')
|
|
108
|
+
throw new LeaseLostError(task.name);
|
|
109
|
+
if (state === 'heartbeat-error') {
|
|
110
|
+
throw new HeartbeatError(task.name, controller.signal.reason);
|
|
111
|
+
}
|
|
112
|
+
if (state === 'aborted') {
|
|
113
|
+
throw new Error(`recurring task '${task.name}' was aborted`);
|
|
114
|
+
}
|
|
115
|
+
let renewed;
|
|
116
|
+
try {
|
|
117
|
+
renewed = await store.renew({
|
|
118
|
+
name: task.name,
|
|
119
|
+
leaseToken: claim.leaseToken,
|
|
120
|
+
leaseMs: task.leaseMs,
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
catch (error) {
|
|
124
|
+
const heartbeatError = new HeartbeatError(task.name, error);
|
|
125
|
+
state = 'heartbeat-error';
|
|
126
|
+
controller.abort(heartbeatError);
|
|
127
|
+
logger.error({
|
|
128
|
+
event: 'scheduler.store-error',
|
|
129
|
+
operation: 'renew',
|
|
130
|
+
name: task.name,
|
|
131
|
+
owner,
|
|
132
|
+
durationMs: durationSince(startedAt),
|
|
133
|
+
err: error,
|
|
134
|
+
}, 'Recurring task heartbeat failed');
|
|
135
|
+
throw heartbeatError;
|
|
136
|
+
}
|
|
137
|
+
if (!renewed) {
|
|
138
|
+
const leaseLostError = new LeaseLostError(task.name);
|
|
139
|
+
state = 'lease-lost';
|
|
140
|
+
controller.abort(leaseLostError);
|
|
141
|
+
logLostLease({
|
|
142
|
+
logger,
|
|
143
|
+
name: task.name,
|
|
144
|
+
owner,
|
|
145
|
+
durationMs: durationSince(startedAt),
|
|
146
|
+
phase: 'heartbeat',
|
|
147
|
+
});
|
|
148
|
+
throw leaseLostError;
|
|
149
|
+
}
|
|
150
|
+
},
|
|
151
|
+
});
|
|
152
|
+
result = taskResult ?? null;
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
catch (error) {
|
|
156
|
+
handlerRejected = true;
|
|
157
|
+
handlerError = error;
|
|
158
|
+
}
|
|
159
|
+
finally {
|
|
160
|
+
signal?.removeEventListener('abort', abortFromParent);
|
|
161
|
+
}
|
|
162
|
+
const durationMs = durationSince(startedAt);
|
|
163
|
+
// Once shutdown, an uncertain heartbeat, or a token mismatch aborts a run,
|
|
164
|
+
// leave its row alone. In particular, never issue a known-stale fail write:
|
|
165
|
+
// the current owner is now another runner and the fence must remain intact.
|
|
166
|
+
if (state !== 'active') {
|
|
167
|
+
if (state === 'aborted') {
|
|
168
|
+
summary.aborted += 1;
|
|
169
|
+
logger.warn({ event: 'scheduler.aborted', name: task.name, owner, durationMs }, 'Recurring task aborted before finalization');
|
|
170
|
+
}
|
|
171
|
+
else {
|
|
172
|
+
summary.failed += 1;
|
|
173
|
+
}
|
|
174
|
+
return;
|
|
175
|
+
}
|
|
176
|
+
if (handlerRejected) {
|
|
177
|
+
summary.failed += 1;
|
|
178
|
+
logger.error({
|
|
179
|
+
event: 'scheduler.failure',
|
|
180
|
+
name: task.name,
|
|
181
|
+
owner,
|
|
182
|
+
durationMs,
|
|
183
|
+
err: handlerError,
|
|
184
|
+
}, 'Recurring task failed');
|
|
185
|
+
try {
|
|
186
|
+
const failed = await store.fail({
|
|
187
|
+
name: task.name,
|
|
188
|
+
leaseToken: claim.leaseToken,
|
|
189
|
+
durationMs,
|
|
190
|
+
error: sanitizeError(handlerError),
|
|
191
|
+
});
|
|
192
|
+
if (!failed) {
|
|
193
|
+
logLostLease({ logger, name: task.name, owner, durationMs, phase: 'fail' });
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
catch (error) {
|
|
197
|
+
logger.error({
|
|
198
|
+
event: 'scheduler.store-error',
|
|
199
|
+
operation: 'fail',
|
|
200
|
+
name: task.name,
|
|
201
|
+
owner,
|
|
202
|
+
durationMs,
|
|
203
|
+
err: error,
|
|
204
|
+
}, 'Recurring task failure could not be recorded');
|
|
205
|
+
}
|
|
206
|
+
return;
|
|
207
|
+
}
|
|
208
|
+
try {
|
|
209
|
+
const completed = await store.complete({
|
|
210
|
+
name: task.name,
|
|
211
|
+
leaseToken: claim.leaseToken,
|
|
212
|
+
durationMs,
|
|
213
|
+
workRemaining: result?.workRemaining === true,
|
|
214
|
+
});
|
|
215
|
+
if (!completed) {
|
|
216
|
+
summary.failed += 1;
|
|
217
|
+
logLostLease({ logger, name: task.name, owner, durationMs, phase: 'complete' });
|
|
218
|
+
return;
|
|
219
|
+
}
|
|
220
|
+
summary.succeeded += 1;
|
|
221
|
+
logger.info({
|
|
222
|
+
event: 'scheduler.success',
|
|
223
|
+
name: task.name,
|
|
224
|
+
owner,
|
|
225
|
+
durationMs,
|
|
226
|
+
workRemaining: result?.workRemaining === true,
|
|
227
|
+
}, 'Recurring task succeeded');
|
|
228
|
+
}
|
|
229
|
+
catch (error) {
|
|
230
|
+
summary.failed += 1;
|
|
231
|
+
logger.error({
|
|
232
|
+
event: 'scheduler.store-error',
|
|
233
|
+
operation: 'complete',
|
|
234
|
+
name: task.name,
|
|
235
|
+
owner,
|
|
236
|
+
durationMs,
|
|
237
|
+
err: error,
|
|
238
|
+
}, 'Recurring task completion could not be recorded');
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
/** A bounded, non-secret diagnostic label. Correctness never depends on it. */
|
|
242
|
+
export function defaultOwner() {
|
|
243
|
+
return `${hostname()}:${process.pid}`.slice(0, MAX_OWNER_LENGTH);
|
|
244
|
+
}
|
|
245
|
+
/**
|
|
246
|
+
* Dependency-injected implementation used by the ticker and unit tests. It is
|
|
247
|
+
* intentionally absent from the `@byline/core/scheduler` barrel.
|
|
248
|
+
*/
|
|
249
|
+
export async function runDueTasksWithDeps(params) {
|
|
250
|
+
const concurrency = normalizeConcurrency(params.concurrency);
|
|
251
|
+
const owner = params.owner.slice(0, MAX_OWNER_LENGTH);
|
|
252
|
+
const summary = { claimed: 0, succeeded: 0, failed: 0, aborted: 0 };
|
|
253
|
+
let nextTaskIndex = 0;
|
|
254
|
+
try {
|
|
255
|
+
await params.store.reconcile(params.tasks.map(({ name, intervalMs }) => ({ name, intervalMs })));
|
|
256
|
+
}
|
|
257
|
+
catch (error) {
|
|
258
|
+
params.logger.error({
|
|
259
|
+
event: 'scheduler.reconcile-error',
|
|
260
|
+
owner,
|
|
261
|
+
durationMs: 0,
|
|
262
|
+
err: error,
|
|
263
|
+
}, 'Recurring task definitions could not be reconciled');
|
|
264
|
+
throw error;
|
|
265
|
+
}
|
|
266
|
+
const takeNextTask = () => {
|
|
267
|
+
if (params.signal?.aborted)
|
|
268
|
+
return undefined;
|
|
269
|
+
const task = params.tasks[nextTaskIndex];
|
|
270
|
+
nextTaskIndex += 1;
|
|
271
|
+
return task;
|
|
272
|
+
};
|
|
273
|
+
const worker = async () => {
|
|
274
|
+
for (let task = takeNextTask(); task !== undefined; task = takeNextTask()) {
|
|
275
|
+
let claim;
|
|
276
|
+
try {
|
|
277
|
+
claim = await params.store.claim({ name: task.name, leaseMs: task.leaseMs, owner });
|
|
278
|
+
}
|
|
279
|
+
catch (error) {
|
|
280
|
+
summary.failed += 1;
|
|
281
|
+
params.logger.error({
|
|
282
|
+
event: 'scheduler.store-error',
|
|
283
|
+
operation: 'claim',
|
|
284
|
+
name: task.name,
|
|
285
|
+
owner,
|
|
286
|
+
durationMs: 0,
|
|
287
|
+
err: error,
|
|
288
|
+
}, 'Recurring task claim failed');
|
|
289
|
+
continue;
|
|
290
|
+
}
|
|
291
|
+
if (claim === null)
|
|
292
|
+
continue;
|
|
293
|
+
summary.claimed += 1;
|
|
294
|
+
await runClaimedTask({
|
|
295
|
+
store: params.store,
|
|
296
|
+
task,
|
|
297
|
+
claim,
|
|
298
|
+
owner,
|
|
299
|
+
logger: params.logger,
|
|
300
|
+
signal: params.signal,
|
|
301
|
+
summary,
|
|
302
|
+
});
|
|
303
|
+
}
|
|
304
|
+
};
|
|
305
|
+
const workerCount = Math.min(concurrency, params.tasks.length);
|
|
306
|
+
await Promise.all(Array.from({ length: workerCount }, worker));
|
|
307
|
+
return summary;
|
|
308
|
+
}
|
|
309
|
+
/** Run one claim-and-run pass over the task definitions vetted at core boot. */
|
|
310
|
+
export async function runDueTasks(core, options = {}) {
|
|
311
|
+
const store = core.db.scheduler;
|
|
312
|
+
if (store === undefined) {
|
|
313
|
+
throw new Error('runDueTasks() requires a database adapter with the scheduler capability. ' +
|
|
314
|
+
'Use a canonical adapter (@byline/db-postgres or @byline/db-mysql).');
|
|
315
|
+
}
|
|
316
|
+
return runDueTasksWithDeps({
|
|
317
|
+
store,
|
|
318
|
+
tasks: core.recurringTasks,
|
|
319
|
+
owner: options.owner ?? defaultOwner(),
|
|
320
|
+
logger: core.logger,
|
|
321
|
+
signal: options.signal,
|
|
322
|
+
concurrency: options.concurrency,
|
|
323
|
+
});
|
|
324
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* This Source Code is subject to the terms of the Mozilla Public
|
|
3
|
+
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
|
4
|
+
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
|
5
|
+
*
|
|
6
|
+
* Copyright (c) Infonomic Company Limited
|
|
7
|
+
*/
|
|
8
|
+
export {};
|