@unchainedshop/core-worker 4.3.4 → 4.4.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.
@@ -1,710 +0,0 @@
1
- import os from 'node:os';
2
- import { createLogger } from '@unchainedshop/logger';
3
- import {
4
- generateDbFilterById,
5
- buildSortOptions,
6
- mongodb,
7
- generateDbObjectId,
8
- ModuleInput,
9
- assertDocumentDBCompatMode,
10
- } from '@unchainedshop/mongodb';
11
- import { emit, registerEvents } from '@unchainedshop/events';
12
- import {
13
- buildObfuscatedFieldsFilter,
14
- DateFilterInput,
15
- SortDirection,
16
- SortOption,
17
- } from '@unchainedshop/utils';
18
- import { Work, WorkQueueCollection, WorkStatus } from '../db/WorkQueueCollection.js';
19
-
20
- const { UNCHAINED_WORKER_ID = os.hostname() } = process.env;
21
-
22
- export const DIRECTOR_MARKED_FAILED_ERROR = 'DIRECTOR_MARKED_FAILED';
23
-
24
- const logger = createLogger('unchained:core-worker');
25
-
26
- export type WorkData = Pick<
27
- Partial<Work>,
28
- 'input' | 'originalWorkId' | 'priority' | 'retries' | 'timeout' | 'worker' | 'scheduleId'
29
- > &
30
- Pick<Work, 'type' | 'scheduled'>;
31
-
32
- export interface WorkResult<Result = unknown> {
33
- success: boolean;
34
- result?: Result;
35
- error?: any;
36
- }
37
-
38
- export enum WorkerEventTypes {
39
- ADDED = 'WORK_ADDED',
40
- ALLOCATED = 'WORK_ALLOCATED',
41
- FINISHED = 'WORK_FINISHED',
42
- DELETED = 'WORK_DELETED',
43
- RESCHEDULED = 'WORK_RESCHEDULED',
44
- }
45
-
46
- export interface WorkerSettingsOptions {
47
- blacklistedVariables?: string[];
48
- }
49
-
50
- export interface WorkerReport {
51
- type: string;
52
- newCount: number;
53
- errorCount: number;
54
- successCount: number;
55
- startCount: number;
56
- deleteCount: number;
57
- }
58
-
59
- export interface WorkQueueQuery {
60
- created?: { end?: Date; start?: Date };
61
- types?: string[];
62
- status?: WorkStatus[];
63
- queryString?: string;
64
- scheduled?: { end?: Date; start?: Date };
65
- }
66
-
67
- const WORK_STATUS_FILTER_MAP = {
68
- [WorkStatus.DELETED]: { deleted: { $exists: true } },
69
- [WorkStatus.NEW]: {
70
- started: { $exists: false },
71
- deleted: { $exists: false },
72
- },
73
- [WorkStatus.ALLOCATED]: {
74
- started: { $exists: true },
75
- finished: { $exists: false },
76
- deleted: { $exists: false },
77
- },
78
- [WorkStatus.SUCCESS]: {
79
- finished: { $exists: true },
80
- success: true,
81
- deleted: { $exists: false },
82
- },
83
- [WorkStatus.FAILED]: {
84
- finished: { $exists: true },
85
- success: false,
86
- deleted: { $exists: false },
87
- },
88
- };
89
-
90
- export const buildQuerySelector = ({
91
- created,
92
- scheduled,
93
- types,
94
- status,
95
- workId,
96
- queryString,
97
- ...rest
98
- }: mongodb.Filter<Work> & {
99
- created?: { end?: Date; start?: Date };
100
- scheduled?: { end?: Date; start?: Date };
101
- status?: WorkStatus[];
102
- workId?: string;
103
- queryString?: string;
104
- types?: string[];
105
- }) => {
106
- const statusQuery = {
107
- $or: Object.entries(WORK_STATUS_FILTER_MAP).reduce(
108
- (acc, [key, filter]) => (status?.includes(key as WorkStatus) ? [...acc, filter] : acc),
109
- [],
110
- ),
111
- };
112
-
113
- let query: mongodb.Filter<Work> =
114
- statusQuery.$or.length > 0 ? statusQuery : { deleted: { $exists: false } };
115
-
116
- if (created) {
117
- query.created = created?.end
118
- ? { $gte: created.start || new Date(0), $lte: created.end }
119
- : { $gte: created.start || new Date(0) };
120
- }
121
- if (scheduled) {
122
- query.scheduled = scheduled?.end
123
- ? { $gte: scheduled.start || new Date(0), $lte: scheduled.end }
124
- : { $gte: scheduled.start || new Date(0) };
125
- }
126
- if (types && Array.isArray(types)) {
127
- query.type = { $in: types };
128
- }
129
-
130
- if (workId) {
131
- query = generateDbFilterById(workId, query);
132
- }
133
- if (queryString) {
134
- assertDocumentDBCompatMode();
135
- (query as any).$text = { $search: queryString };
136
- }
137
-
138
- return { ...query, ...rest };
139
- };
140
-
141
- const convertFilterMapToPipelineBranches = () => {
142
- return Object.entries(WORK_STATUS_FILTER_MAP).map(([status, conditions]) => {
143
- const caseConditions = Object.entries(conditions).map(([field, condition]) => {
144
- if (typeof condition === 'object' && '$exists' in condition) {
145
- return condition.$exists
146
- ? { $ne: [{ $type: `$${field}` }, 'missing'] }
147
- : { $eq: [{ $type: `$${field}` }, 'missing'] };
148
- }
149
- return { $eq: [`$${field}`, condition] };
150
- });
151
-
152
- return {
153
- case: caseConditions.length > 1 ? { $and: caseConditions } : caseConditions[0],
154
- then: status,
155
- };
156
- });
157
- };
158
-
159
- const defaultSort: { key: string; value: SortDirection }[] = [
160
- { key: 'started', value: SortDirection.DESC },
161
- { key: 'priority', value: SortDirection.DESC },
162
- { key: 'originalWorkId', value: SortDirection.ASC },
163
- { key: 'created', value: SortDirection.ASC },
164
- ];
165
-
166
- const normalizeWorkQueueAggregateResult = (data: mongodb.Document[] = []): WorkerReport[] => {
167
- const statusToFieldMap = {
168
- NEW: 'newCount',
169
- ALLOCATED: 'startCount',
170
- FAILED: 'errorCount',
171
- SUCCESS: 'successCount',
172
- DELETED: 'deleteCount',
173
- };
174
-
175
- return data.map((item) => {
176
- const workStatistics: WorkerReport = {
177
- type: item.type,
178
- ...(Object.values(statusToFieldMap).reduce((acc, key) => ({ ...acc, [key]: 0 }), {}) as any),
179
- };
180
-
181
- item.statuses.forEach(({ status, count }) => {
182
- if (statusToFieldMap[status]) {
183
- workStatistics[statusToFieldMap[status]] = count;
184
- }
185
- });
186
- const { errorCount, successCount, deleteCount } = workStatistics;
187
-
188
- workStatistics.startCount += errorCount + successCount + deleteCount;
189
- workStatistics.newCount += workStatistics.startCount;
190
-
191
- return workStatistics;
192
- }) as unknown as WorkerReport[];
193
- };
194
-
195
- export const configureWorkerModule = async ({ db, options }: ModuleInput<WorkerSettingsOptions>) => {
196
- registerEvents(Object.values(WorkerEventTypes));
197
-
198
- const WorkQueue = await WorkQueueCollection(db);
199
-
200
- const removePrivateFields = buildObfuscatedFieldsFilter(options?.blacklistedVariables);
201
-
202
- const allocateWork = async ({
203
- types,
204
- worker = UNCHAINED_WORKER_ID,
205
- }: {
206
- types: string[];
207
- worker: string;
208
- }) => {
209
- // Find a work item that is scheduled for now and is not started.
210
- // Also:
211
- // - Restrict by types and worker if provided
212
- // - Sort by default queue order
213
- const query = buildQuerySelector({
214
- status: [WorkStatus.NEW],
215
- scheduled: { end: new Date() },
216
- worker: { $in: [null, '', worker] },
217
- ...(types ? { type: { $in: types } } : {}),
218
- });
219
- const result = await WorkQueue.findOneAndUpdate(
220
- query,
221
- {
222
- $set: { started: new Date(), worker },
223
- },
224
- { sort: buildSortOptions(defaultSort), returnDocument: 'after' },
225
- );
226
-
227
- if (!result) return null;
228
- emit(WorkerEventTypes.ALLOCATED, removePrivateFields(result));
229
- return result;
230
- };
231
-
232
- const finishWork = async (
233
- workId: string,
234
- {
235
- error,
236
- finished = new Date(),
237
- result,
238
- started = new Date(),
239
- success,
240
- worker = UNCHAINED_WORKER_ID,
241
- }: WorkResult<any> & {
242
- finished?: Date;
243
- started?: Date;
244
- worker?: string;
245
- },
246
- ) => {
247
- const workBeforeUpdate = await WorkQueue.findOne(
248
- buildQuerySelector({ workId, status: [WorkStatus.ALLOCATED] }),
249
- );
250
-
251
- if (!workBeforeUpdate) return null;
252
-
253
- const work = await WorkQueue.findOneAndUpdate(
254
- generateDbFilterById(workId),
255
- {
256
- $set: {
257
- updated: new Date(),
258
- finished,
259
- success,
260
- error,
261
- result,
262
- ...(!workBeforeUpdate.started ? { started } : {}),
263
- worker,
264
- },
265
- },
266
- { returnDocument: 'after' },
267
- );
268
-
269
- if (!work) return null;
270
-
271
- const duration = new Date(work.finished!).getTime() - new Date(work.started!).getTime();
272
- if (work.success) {
273
- logger.debug(`${work.type} finished with success (${duration}ms)`, {
274
- workId,
275
- worker,
276
- });
277
- } else {
278
- logger.warn(`${work.type} finished with errors (${duration}ms) / ${work.retries} remaining`, {
279
- workId,
280
- worker,
281
- });
282
- }
283
- logger.debug(`work details:`, { work });
284
- emit(WorkerEventTypes.FINISHED, removePrivateFields(work));
285
-
286
- return work;
287
- };
288
-
289
- return {
290
- // Queries
291
-
292
- workerId: UNCHAINED_WORKER_ID,
293
-
294
- activeWorkTypes: async (): Promise<string[]> => {
295
- const typeList = await WorkQueue.aggregate([{ $group: { _id: '$type' } }]).toArray();
296
- return typeList.map((t) => t._id as string);
297
- },
298
-
299
- findWork: async (
300
- params:
301
- | {
302
- workId: string;
303
- }
304
- | {
305
- originalWorkId: string;
306
- },
307
- ) => {
308
- if ('workId' in params) {
309
- return WorkQueue.findOne(generateDbFilterById(params.workId), {});
310
- }
311
- return WorkQueue.findOne({ originalWorkId: params.originalWorkId }, {});
312
- },
313
-
314
- findWorkQueue: async ({
315
- limit,
316
- skip,
317
- sort,
318
- ...selectorOptions
319
- }: WorkQueueQuery & {
320
- sort?: SortOption[];
321
- limit?: number;
322
- skip?: number;
323
- }): Promise<Work[]> => {
324
- const selector = buildQuerySelector(selectorOptions);
325
- return WorkQueue.find(selector, {
326
- skip,
327
- limit,
328
- sort: buildSortOptions(sort || defaultSort),
329
- }).toArray();
330
- },
331
-
332
- count: async (query: WorkQueueQuery) => {
333
- return WorkQueue.countDocuments(buildQuerySelector(query));
334
- },
335
-
336
- allocationMap: async (): Promise<Record<string, number>> => {
337
- const alreadyAllocatedWork = await WorkQueue.aggregate(
338
- [
339
- {
340
- $match: {
341
- started: {
342
- $exists: true,
343
- },
344
- finished: {
345
- $exists: false,
346
- },
347
- deleted: {
348
- $exists: false,
349
- },
350
- },
351
- },
352
- {
353
- $group: {
354
- _id: '$type',
355
- count: {
356
- $sum: 1,
357
- },
358
- },
359
- },
360
- ],
361
- {
362
- allowDiskUse: false,
363
- },
364
- ).toArray();
365
-
366
- const allocationMap = Object.fromEntries(alreadyAllocatedWork.map((w) => [w._id, w.count]));
367
- return allocationMap;
368
- },
369
-
370
- workExists: async ({
371
- workId,
372
- originalWorkId,
373
- }: {
374
- workId?: string;
375
- originalWorkId?: string;
376
- }): Promise<boolean> => {
377
- const queueCount = await WorkQueue.countDocuments(
378
- workId ? generateDbFilterById(workId) : { originalWorkId },
379
- { limit: 1 },
380
- );
381
- return !!queueCount;
382
- },
383
-
384
- status: (work: Work): WorkStatus => {
385
- if (work.deleted) {
386
- return WorkStatus.DELETED;
387
- }
388
- if (!work.started && !work.finished) {
389
- return WorkStatus.NEW;
390
- }
391
- if (work.started && !work.finished) {
392
- return WorkStatus.ALLOCATED;
393
- }
394
- if (work.started && work.finished && work.success) {
395
- return WorkStatus.SUCCESS;
396
- }
397
- if (work.started && work.finished && !work.success) {
398
- return WorkStatus.FAILED;
399
- }
400
-
401
- logger.error(`${work.type} is in unexpected state`, { workId: work._id });
402
-
403
- throw new Error('Unexpected work status');
404
- },
405
-
406
- // Mutations
407
- async addWork({
408
- type,
409
- input,
410
- priority = 0,
411
- scheduled,
412
- originalWorkId,
413
- worker = null,
414
- retries = 20,
415
- }: Pick<Work, 'type' | 'originalWorkId' | 'worker'> &
416
- Pick<Partial<Work>, 'scheduled' | 'priority' | 'input' | 'retries'>): Promise<Work> {
417
- const created = new Date();
418
- const { insertedId: workId } = await WorkQueue.insertOne({
419
- _id: generateDbObjectId(),
420
- created,
421
- type,
422
- input: input || {},
423
- priority,
424
- scheduled: scheduled || created,
425
- originalWorkId,
426
- retries,
427
- worker,
428
- });
429
-
430
- logger.debug(`${type} scheduled @ ${new Date(scheduled || created).toISOString()}`, {
431
- workId,
432
- });
433
-
434
- const work = (await WorkQueue.findOne(generateDbFilterById(workId), {})) as Work;
435
- emit(WorkerEventTypes.ADDED, removePrivateFields(work));
436
-
437
- return work;
438
- },
439
-
440
- rescheduleWork: async (currentWork: Work, scheduled: Date) => {
441
- const work = await WorkQueue.findOneAndUpdate(
442
- generateDbFilterById(currentWork._id),
443
- {
444
- $set: {
445
- updated: new Date(),
446
- scheduled,
447
- },
448
- },
449
- { returnDocument: 'after' },
450
- );
451
-
452
- if (!work) return null;
453
- emit(WorkerEventTypes.RESCHEDULED, {
454
- work: removePrivateFields(work),
455
- oldScheduled: currentWork.scheduled,
456
- });
457
-
458
- return work;
459
- },
460
-
461
- allocateWork,
462
-
463
- ensureNoWork: async ({
464
- type,
465
- priority = 0,
466
- scheduleId,
467
- }: {
468
- priority: number;
469
- type: string;
470
- scheduleId: string;
471
- }): Promise<void> => {
472
- const query = buildQuerySelector({
473
- type,
474
- status: [WorkStatus.NEW],
475
- priority,
476
- autoscheduled: true,
477
- scheduleId,
478
- });
479
-
480
- await WorkQueue.updateMany(query, {
481
- $set: {
482
- deleted: new Date(),
483
- },
484
- });
485
- },
486
-
487
- ensureOneWork: async ({
488
- type,
489
- input,
490
- priority = 0,
491
- scheduled,
492
- timeout,
493
- originalWorkId,
494
- retries = 20,
495
- scheduleId,
496
- }: WorkData) => {
497
- const workId = `${scheduleId}:${scheduled.getTime()}`;
498
-
499
- const created = new Date();
500
- const query = buildQuerySelector({
501
- type,
502
- status: [WorkStatus.NEW],
503
- priority,
504
- autoscheduled: true,
505
- scheduleId,
506
- });
507
- try {
508
- const result = await WorkQueue.findOneAndUpdate(
509
- query,
510
- {
511
- $set: {
512
- input,
513
- worker: null,
514
- updated: created,
515
- retries,
516
- timeout,
517
- originalWorkId,
518
- },
519
- $unset: {
520
- deleted: 1,
521
- },
522
- $setOnInsert: {
523
- _id: workId,
524
- scheduleId,
525
- scheduled,
526
- type,
527
- created,
528
- autoscheduled: true,
529
- priority,
530
- },
531
- },
532
- {
533
- sort: buildSortOptions(defaultSort),
534
- returnDocument: 'after',
535
- includeResultMetadata: true,
536
- upsert: true,
537
- },
538
- );
539
-
540
- if (!result.lastErrorObject) {
541
- logger.debug(`${type} auto-scheduled @ ${new Date(scheduled).toISOString()}`, {
542
- workId,
543
- });
544
- emit(WorkerEventTypes.ADDED, removePrivateFields(result.value));
545
- }
546
- return result.value;
547
- } catch {
548
- /* Conflicting deleted work problem:
549
- If the findOneAndUpdate call failed because of _id conflict with a DELETED work,
550
- we should NOT permanently remove the conflicting deleted work
551
- and retry the findOneAndUpdate call.
552
- If we did remove the deleted work, deleted jobs would "re-appear" as NEW with same schedule and _id.
553
- So we just return null here and let the scheduler try again in the next cycle.
554
- */
555
- return null;
556
- }
557
- },
558
-
559
- finishWork,
560
-
561
- deleteWork: async (workId: string) => {
562
- const workBeforeRemoval = await WorkQueue.findOne(
563
- buildQuerySelector({
564
- workId,
565
- status: [WorkStatus.NEW, WorkStatus.ALLOCATED],
566
- }),
567
- );
568
- if (!workBeforeRemoval) return null;
569
-
570
- const work = await WorkQueue.findOneAndUpdate(
571
- generateDbFilterById(workId),
572
- {
573
- $set: {
574
- deleted: new Date(),
575
- },
576
- },
577
- { returnDocument: 'after' },
578
- );
579
-
580
- if (!work) return null;
581
- emit(WorkerEventTypes.DELETED, removePrivateFields(work));
582
- return work;
583
- },
584
-
585
- markOldWorkAsFailed: async ({
586
- types,
587
- worker = UNCHAINED_WORKER_ID,
588
- referenceDate,
589
- }: {
590
- types: string[];
591
- worker: string;
592
- referenceDate: Date;
593
- }): Promise<Work[]> => {
594
- const workQueue = await WorkQueue.find(
595
- buildQuerySelector({
596
- status: [WorkStatus.ALLOCATED],
597
- started: { $lte: referenceDate },
598
- worker: { $in: [worker, '', null] }, // Don't mark work failed of other workers!
599
- type: { $in: types },
600
- }),
601
- { projection: { _id: true }, sort: { test: 1 } },
602
- ).toArray();
603
-
604
- return (
605
- await Promise.all(
606
- workQueue.map(({ _id }) =>
607
- finishWork(_id as string, {
608
- finished: new Date(),
609
- result: null,
610
- success: false,
611
- error: {
612
- name: DIRECTOR_MARKED_FAILED_ERROR,
613
- message:
614
- 'Director marked old work as failed after restart. This work was eventually running at the moment when node.js exited.',
615
- },
616
- worker,
617
- }),
618
- ),
619
- )
620
- ).filter(Boolean) as Work[];
621
- },
622
-
623
- getReport: async ({
624
- types,
625
- dateRange,
626
- }: {
627
- types?: string[];
628
- dateRange?: DateFilterInput;
629
- }): Promise<WorkerReport[]> => {
630
- const pipeline: mongodb.BSON.Document[] = [];
631
- const matchConditions: any[] = [];
632
- // build date filter based on provided values it can be a range if both to and from is supplied
633
- // a upper or lowe limit if either from or to is provided
634
- // or all if none is provided
635
- if (dateRange?.start || dateRange?.end) {
636
- const dateConditions: any[] = [];
637
- if (dateRange?.start) {
638
- const fromDate = new Date(dateRange?.start);
639
- dateConditions.push({
640
- $or: [{ created: { $gte: fromDate } }, { updated: { $gte: fromDate } }],
641
- });
642
- }
643
- if (dateRange?.end) {
644
- const toDate = new Date(dateRange?.end);
645
- dateConditions.push({
646
- $or: [{ created: { $lte: toDate } }, { updated: { $lte: toDate } }],
647
- });
648
- }
649
- if (dateConditions.length > 0) {
650
- matchConditions.push({ $and: dateConditions });
651
- }
652
- }
653
- // build types filter if type is provided or ignore types if it is not provided
654
- if (types && Array.isArray(types) && types.length) {
655
- matchConditions.push({ type: { $in: types } });
656
- }
657
- if (matchConditions.length > 0) {
658
- pipeline.push({
659
- $match: {
660
- $and: matchConditions,
661
- },
662
- });
663
- }
664
- pipeline.push(
665
- {
666
- $addFields: {
667
- status: {
668
- $switch: {
669
- branches: convertFilterMapToPipelineBranches(),
670
- default: 'UNKNOWN',
671
- },
672
- },
673
- },
674
- },
675
- {
676
- $group: {
677
- _id: {
678
- type: '$type',
679
- status: '$status',
680
- },
681
- count: { $sum: 1 },
682
- },
683
- },
684
- {
685
- $group: {
686
- _id: '$_id.type',
687
- statuses: {
688
- $push: {
689
- status: '$_id.status',
690
- count: '$count',
691
- },
692
- },
693
- },
694
- },
695
-
696
- {
697
- $project: {
698
- _id: 0,
699
- type: '$_id',
700
- statuses: 1,
701
- },
702
- },
703
- );
704
-
705
- return normalizeWorkQueueAggregateResult(await WorkQueue.aggregate(pipeline).toArray()) as any;
706
- },
707
- };
708
- };
709
-
710
- export type WorkerModule = Awaited<ReturnType<typeof configureWorkerModule>>;
@@ -1,3 +0,0 @@
1
- export * from './db/WorkQueueCollection.js';
2
-
3
- export * from './module/configureWorkerModule.js';
package/tsconfig.json DELETED
@@ -1,10 +0,0 @@
1
- {
2
- "extends": "../shared/base.tsconfig.json",
3
- "compilerOptions": {
4
- "declarationDir": "./lib",
5
- "rootDir": "./src",
6
- "outDir": "./lib",
7
-
8
- },
9
- "exclude": ["**/*.test.ts", "**/*.test.js", "tests", "lib"]
10
- }