@adminforth/background-jobs 1.4.1 → 1.6.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/build.log CHANGED
@@ -11,5 +11,5 @@ custom/StateToIcon.vue
11
11
  custom/tsconfig.json
12
12
  custom/utils.ts
13
13
 
14
- sent 12,535 bytes received 134 bytes 25,338.00 bytes/sec
15
- total size is 12,050 speedup is 0.95
14
+ sent 13,135 bytes received 134 bytes 26,538.00 bytes/sec
15
+ total size is 12,650 speedup is 0.95
@@ -111,7 +111,7 @@ async function getJobTasks(limit: number = 10, offset: number = 0): Promise<{sta
111
111
  },
112
112
  });
113
113
  if (res.ok) {
114
- return res.tasks;
114
+ return res.data;
115
115
  } else {
116
116
  console.error('Error fetching job tasks:', res.error);
117
117
  return [];
@@ -1,40 +1,53 @@
1
1
  <template>
2
- <Tooltip v-if="job.status === 'IN_PROGRESS'">
2
+ <Tooltip v-if="job?.status === 'IN_PROGRESS' || status === 'IN_PROGRESS'">
3
3
  <Spinner class="w-5 h-5 ml-2" />
4
4
  <template #tooltip>
5
5
  {{ t('In progress') }}
6
6
  </template>
7
7
  </Tooltip>
8
- <Tooltip v-else-if="job.status === 'DONE'">
8
+ <Tooltip v-else-if="job?.status === 'DONE' || status === 'DONE'">
9
9
  <IconCheckCircleOutline class="w-6 h-6 ml-2 text-green-500" />
10
10
  <template #tooltip>
11
11
  {{ t('Done') }}
12
12
  </template>
13
13
  </Tooltip>
14
- <Tooltip v-else-if="job.status === 'CANCELLED'">
14
+ <Tooltip v-else-if="job?.status === 'CANCELLED' || status === 'CANCELLED'">
15
15
  <IconCloseCircleOutline class="w-6 h-6 ml-2 text-red-500" />
16
16
  <template #tooltip>
17
17
  {{ t('Cancelled') }}
18
18
  </template>
19
19
  </Tooltip>
20
- <Tooltip v-else-if="job.status === 'DONE_WITH_ERRORS'">
20
+ <Tooltip v-else-if="job?.status === 'DONE_WITH_ERRORS' || status === 'DONE_WITH_ERRORS'">
21
21
  <IconExclamationCircleOutline class="w-6 h-6 ml-2 text-yellow-500" />
22
22
  <template #tooltip>
23
23
  {{ t('Done with errors') }}
24
24
  </template>
25
25
  </Tooltip>
26
+ <Tooltip v-else-if="status === 'FAILED'">
27
+ <IconCloseCircleOutline class="w-6 h-6 ml-2 text-red-500" />
28
+ <template #tooltip>
29
+ {{ t('Failed') }}
30
+ </template>
31
+ </Tooltip>
32
+ <Tooltip v-else-if="status === 'SCHEDULED'">
33
+ <IconClockOutline class="w-6 h-6 ml-2 text-blue-500" />
34
+ <template #tooltip>
35
+ {{ t('Scheduled') }}
36
+ </template>
37
+ </Tooltip>
26
38
  </template>
27
39
 
28
40
 
29
41
  <script setup lang="ts">
30
42
  import type { IJob } from './utils';
31
- import { IconCheckCircleOutline, IconCloseCircleOutline, IconExclamationCircleOutline } from '@iconify-prerendered/vue-flowbite';
43
+ import { IconCheckCircleOutline, IconCloseCircleOutline, IconExclamationCircleOutline, IconClockOutline } from '@iconify-prerendered/vue-flowbite';
32
44
  import { Spinner, Tooltip } from '@/afcl';
33
45
  import { useI18n } from 'vue-i18n';
34
46
 
35
47
  const { t } = useI18n();
36
48
 
37
49
  const props = defineProps<{
38
- job: IJob;
50
+ job?: IJob;
51
+ status?: 'SCHEDULED' | 'IN_PROGRESS' | 'DONE' | 'FAILED' | 'CANCELLED' | 'DONE_WITH_ERRORS';
39
52
  }>();
40
53
  </script>
@@ -111,7 +111,7 @@ async function getJobTasks(limit: number = 10, offset: number = 0): Promise<{sta
111
111
  },
112
112
  });
113
113
  if (res.ok) {
114
- return res.tasks;
114
+ return res.data;
115
115
  } else {
116
116
  console.error('Error fetching job tasks:', res.error);
117
117
  return [];
@@ -1,40 +1,53 @@
1
1
  <template>
2
- <Tooltip v-if="job.status === 'IN_PROGRESS'">
2
+ <Tooltip v-if="job?.status === 'IN_PROGRESS' || status === 'IN_PROGRESS'">
3
3
  <Spinner class="w-5 h-5 ml-2" />
4
4
  <template #tooltip>
5
5
  {{ t('In progress') }}
6
6
  </template>
7
7
  </Tooltip>
8
- <Tooltip v-else-if="job.status === 'DONE'">
8
+ <Tooltip v-else-if="job?.status === 'DONE' || status === 'DONE'">
9
9
  <IconCheckCircleOutline class="w-6 h-6 ml-2 text-green-500" />
10
10
  <template #tooltip>
11
11
  {{ t('Done') }}
12
12
  </template>
13
13
  </Tooltip>
14
- <Tooltip v-else-if="job.status === 'CANCELLED'">
14
+ <Tooltip v-else-if="job?.status === 'CANCELLED' || status === 'CANCELLED'">
15
15
  <IconCloseCircleOutline class="w-6 h-6 ml-2 text-red-500" />
16
16
  <template #tooltip>
17
17
  {{ t('Cancelled') }}
18
18
  </template>
19
19
  </Tooltip>
20
- <Tooltip v-else-if="job.status === 'DONE_WITH_ERRORS'">
20
+ <Tooltip v-else-if="job?.status === 'DONE_WITH_ERRORS' || status === 'DONE_WITH_ERRORS'">
21
21
  <IconExclamationCircleOutline class="w-6 h-6 ml-2 text-yellow-500" />
22
22
  <template #tooltip>
23
23
  {{ t('Done with errors') }}
24
24
  </template>
25
25
  </Tooltip>
26
+ <Tooltip v-else-if="status === 'FAILED'">
27
+ <IconCloseCircleOutline class="w-6 h-6 ml-2 text-red-500" />
28
+ <template #tooltip>
29
+ {{ t('Failed') }}
30
+ </template>
31
+ </Tooltip>
32
+ <Tooltip v-else-if="status === 'SCHEDULED'">
33
+ <IconClockOutline class="w-6 h-6 ml-2 text-blue-500" />
34
+ <template #tooltip>
35
+ {{ t('Scheduled') }}
36
+ </template>
37
+ </Tooltip>
26
38
  </template>
27
39
 
28
40
 
29
41
  <script setup lang="ts">
30
42
  import type { IJob } from './utils';
31
- import { IconCheckCircleOutline, IconCloseCircleOutline, IconExclamationCircleOutline } from '@iconify-prerendered/vue-flowbite';
43
+ import { IconCheckCircleOutline, IconCloseCircleOutline, IconExclamationCircleOutline, IconClockOutline } from '@iconify-prerendered/vue-flowbite';
32
44
  import { Spinner, Tooltip } from '@/afcl';
33
45
  import { useI18n } from 'vue-i18n';
34
46
 
35
47
  const { t } = useI18n();
36
48
 
37
49
  const props = defineProps<{
38
- job: IJob;
50
+ job?: IJob;
51
+ status?: 'SCHEDULED' | 'IN_PROGRESS' | 'DONE' | 'FAILED' | 'CANCELLED' | 'DONE_WITH_ERRORS';
39
52
  }>();
40
53
  </script>
package/dist/index.js CHANGED
@@ -13,7 +13,6 @@ import pLimit from 'p-limit';
13
13
  import { Level } from 'level';
14
14
  import fs from 'fs/promises';
15
15
  import { Mutex } from 'async-mutex';
16
- const mutex = new Mutex();
17
16
  export default class BackgroundJobsPlugin extends AdminForthPlugin {
18
17
  constructor(options) {
19
18
  super(options, import.meta.url);
@@ -21,6 +20,7 @@ export default class BackgroundJobsPlugin extends AdminForthPlugin {
21
20
  this.jobCustomComponents = {};
22
21
  this.jobParallelLimits = {};
23
22
  this.levelDbInstances = {};
23
+ this.jobStateMutexes = {};
24
24
  this.options = options;
25
25
  this.shouldHaveSingleInstancePerWholeApp = () => true;
26
26
  }
@@ -39,7 +39,6 @@ export default class BackgroundJobsPlugin extends AdminForthPlugin {
39
39
  return __awaiter(this, void 0, void 0, function* () {
40
40
  var _a, _b;
41
41
  _super.modifyResourceConfig.call(this, adminforth, resourceConfig);
42
- console.log('Modifying resource config for Background Jobs Plugin');
43
42
  if (!((_b = (_a = adminforth.config.customization) === null || _a === void 0 ? void 0 : _a.globalInjections) === null || _b === void 0 ? void 0 : _b.header)) {
44
43
  adminforth.config.customization.globalInjections.header = [];
45
44
  }
@@ -49,6 +48,12 @@ export default class BackgroundJobsPlugin extends AdminForthPlugin {
49
48
  pluginInstanceId: this.pluginInstanceId,
50
49
  }
51
50
  });
51
+ if (!this.adminforth.config.componentsToExplicitRegister) {
52
+ this.adminforth.config.componentsToExplicitRegister = [];
53
+ }
54
+ this.adminforth.config.componentsToExplicitRegister.push({
55
+ file: this.componentPath('StateToIcon.vue')
56
+ });
52
57
  if (!this.resourceConfig.hooks) {
53
58
  this.resourceConfig.hooks = {};
54
59
  }
@@ -66,6 +71,8 @@ export default class BackgroundJobsPlugin extends AdminForthPlugin {
66
71
  yield jobLevelDb.close();
67
72
  delete this.levelDbInstances[recordId];
68
73
  }
74
+ // cleanup per-job mutex as well
75
+ delete this.jobStateMutexes[recordId];
69
76
  //delete level db folder for the job
70
77
  yield fs.rm(levelDbPath, {
71
78
  recursive: true,
@@ -75,6 +82,12 @@ export default class BackgroundJobsPlugin extends AdminForthPlugin {
75
82
  }));
76
83
  });
77
84
  }
85
+ cleanupJobMutexIfTerminalStatus(jobId, status) {
86
+ // Keep mutex while job is active to preserve atomicity between concurrent tasks.
87
+ if (status === 'DONE' || status === 'DONE_WITH_ERRORS' || status === 'CANCELLED') {
88
+ delete this.jobStateMutexes[jobId];
89
+ }
90
+ }
78
91
  checkIfFieldInResource(resourceConfig, fieldName, fieldString) {
79
92
  if (!fieldName) {
80
93
  throw new Error(`Field name for ${fieldString} is not provided. Please check your plugin options.`);
@@ -126,6 +139,12 @@ export default class BackgroundJobsPlugin extends AdminForthPlugin {
126
139
  return Promise.resolve(null);
127
140
  });
128
141
  }
142
+ getTotalTasksInLevelDb(levelDb) {
143
+ return __awaiter(this, void 0, void 0, function* () {
144
+ const count = yield levelDb.get('_meta:count');
145
+ return count ? parseInt(count, 10) : 0;
146
+ });
147
+ }
129
148
  registerTaskHandler({ jobHandlerName, handler, parallelLimit = 3, }) {
130
149
  //register the handler in a map with jobHandlerName as key and handler as value
131
150
  this.taskHandlers[jobHandlerName] = handler;
@@ -171,6 +190,7 @@ export default class BackgroundJobsPlugin extends AdminForthPlugin {
171
190
  //create a level db instance for the job with name as jobId
172
191
  const jobLevelDb = new Level(`${this.options.levelDbPath || './background-jobs-dbs/'}job_${jobId}`, { valueEncoding: 'json' });
173
192
  this.levelDbInstances[jobId] = jobLevelDb;
193
+ yield jobLevelDb.put('_meta:count', `${tasks.length}`);
174
194
  const limit2 = pLimit(parrallelLimit);
175
195
  const createTaskRecordsPromises = tasks.map((task, index) => {
176
196
  return limit2(() => this.createLevelDbTaskRecord(jobLevelDb, index.toString(), task.state));
@@ -247,6 +267,7 @@ export default class BackgroundJobsPlugin extends AdminForthPlugin {
247
267
  [this.options.finishedAtField]: (new Date()).toISOString(),
248
268
  });
249
269
  this.adminforth.websocket.publish('/background-jobs', { jobId, status: 'DONE', finishedAt: (new Date()).toISOString() });
270
+ this.cleanupJobMutexIfTerminalStatus(jobId, 'DONE');
250
271
  }
251
272
  else if (failedTasks > 0) {
252
273
  yield this.adminforth.resource(this.getResourceId()).update(jobId, {
@@ -254,6 +275,7 @@ export default class BackgroundJobsPlugin extends AdminForthPlugin {
254
275
  [this.options.finishedAtField]: (new Date()).toISOString(),
255
276
  });
256
277
  this.adminforth.websocket.publish('/background-jobs', { jobId, status: 'DONE_WITH_ERRORS' });
278
+ this.cleanupJobMutexIfTerminalStatus(jobId, 'DONE_WITH_ERRORS');
257
279
  }
258
280
  });
259
281
  }
@@ -344,6 +366,26 @@ export default class BackgroundJobsPlugin extends AdminForthPlugin {
344
366
  return JSON.parse(state);
345
367
  });
346
368
  }
369
+ updateJobFieldsAtomicly(jobId, updateFunction) {
370
+ return __awaiter(this, void 0, void 0, function* () {
371
+ if (!jobId) {
372
+ throw new Error('updateJobFieldsAtomicly: jobId is required');
373
+ }
374
+ if (typeof updateFunction !== 'function') {
375
+ throw new Error('updateJobFieldsAtomicly: updateFunction must be a function');
376
+ }
377
+ // Ensure updates are atomic per jobId.
378
+ // Different jobs are not blocked by each other.
379
+ let mutex = this.jobStateMutexes[jobId];
380
+ if (!mutex) {
381
+ mutex = new Mutex();
382
+ this.jobStateMutexes[jobId] = mutex;
383
+ }
384
+ return mutex.runExclusive(() => __awaiter(this, void 0, void 0, function* () {
385
+ yield updateFunction();
386
+ }));
387
+ });
388
+ }
347
389
  processAllUnfinishedJobs() {
348
390
  return __awaiter(this, void 0, void 0, function* () {
349
391
  const resourceId = this.getResourceId();
@@ -465,7 +507,8 @@ export default class BackgroundJobsPlugin extends AdminForthPlugin {
465
507
  tasks.push(parsedTaskData);
466
508
  taskIndex++;
467
509
  }
468
- return { ok: true, tasks };
510
+ const total = yield this.getTotalTasksInLevelDb(jobLevelDb);
511
+ return { ok: true, data: { tasks, total } };
469
512
  })
470
513
  });
471
514
  }
package/index.ts CHANGED
@@ -5,9 +5,7 @@ import { afLogger } from "adminforth";
5
5
  import pLimit from 'p-limit';
6
6
  import { Level } from 'level';
7
7
  import fs from 'fs/promises';
8
- import {Mutex, MutexInterface, Semaphore, SemaphoreInterface, withTimeout} from 'async-mutex';
9
-
10
- const mutex = new Mutex();
8
+ import { Mutex } from 'async-mutex';
11
9
 
12
10
  type TaskStatus = 'SCHEDULED' | 'IN_PROGRESS' | 'DONE' | 'FAILED';
13
11
  type setStateFieldParams = (state: Record<string, any>) => void;
@@ -24,6 +22,7 @@ export default class BackgroundJobsPlugin extends AdminForthPlugin {
24
22
  private jobCustomComponents: Record<string, AdminForthComponentDeclarationFull> = {};
25
23
  private jobParallelLimits: Record<string, number> = {};
26
24
  private levelDbInstances: Record<string, Level> = {};
25
+ private jobStateMutexes: Record<string, Mutex> = {};
27
26
 
28
27
  constructor(options: PluginOptions) {
29
28
  super(options, import.meta.url);
@@ -42,7 +41,6 @@ export default class BackgroundJobsPlugin extends AdminForthPlugin {
42
41
 
43
42
  async modifyResourceConfig(adminforth: IAdminForth, resourceConfig: AdminForthResource) {
44
43
  super.modifyResourceConfig(adminforth, resourceConfig);
45
- console.log('Modifying resource config for Background Jobs Plugin');
46
44
  if (!adminforth.config.customization?.globalInjections?.header) {
47
45
  adminforth.config.customization.globalInjections.header = [];
48
46
  }
@@ -53,6 +51,15 @@ export default class BackgroundJobsPlugin extends AdminForthPlugin {
53
51
  }
54
52
  });
55
53
 
54
+ if (!this.adminforth.config.componentsToExplicitRegister) {
55
+ this.adminforth.config.componentsToExplicitRegister = [];
56
+ }
57
+ this.adminforth.config.componentsToExplicitRegister.push(
58
+ {
59
+ file: this.componentPath('StateToIcon.vue')
60
+ }
61
+ );
62
+
56
63
  if (!this.resourceConfig.hooks) {
57
64
  this.resourceConfig.hooks = {};
58
65
  }
@@ -73,6 +80,9 @@ export default class BackgroundJobsPlugin extends AdminForthPlugin {
73
80
  delete this.levelDbInstances[recordId];
74
81
  }
75
82
 
83
+ // cleanup per-job mutex as well
84
+ delete this.jobStateMutexes[recordId];
85
+
76
86
  //delete level db folder for the job
77
87
  await fs.rm(levelDbPath, {
78
88
  recursive: true,
@@ -83,6 +93,13 @@ export default class BackgroundJobsPlugin extends AdminForthPlugin {
83
93
  })
84
94
  }
85
95
 
96
+ private cleanupJobMutexIfTerminalStatus(jobId: string, status: string) {
97
+ // Keep mutex while job is active to preserve atomicity between concurrent tasks.
98
+ if (status === 'DONE' || status === 'DONE_WITH_ERRORS' || status === 'CANCELLED') {
99
+ delete this.jobStateMutexes[jobId];
100
+ }
101
+ }
102
+
86
103
  private checkIfFieldInResource(resourceConfig: AdminForthResource, fieldName: string, fieldString?: string) {
87
104
  if (!fieldName) {
88
105
  throw new Error(`Field name for ${fieldString} is not provided. Please check your plugin options.`);
@@ -129,6 +146,11 @@ export default class BackgroundJobsPlugin extends AdminForthPlugin {
129
146
  }
130
147
  return Promise.resolve(null);
131
148
  }
149
+
150
+ private async getTotalTasksInLevelDb(levelDb: Level): Promise<number> {
151
+ const count = await levelDb.get('_meta:count');
152
+ return count ? parseInt(count, 10) : 0;
153
+ }
132
154
 
133
155
  public registerTaskHandler({ jobHandlerName, handler, parallelLimit = 3,
134
156
  }:{jobHandlerName: string, handler: taskHandlerType, parallelLimit?: number}) {
@@ -188,7 +210,7 @@ export default class BackgroundJobsPlugin extends AdminForthPlugin {
188
210
  //create a level db instance for the job with name as jobId
189
211
  const jobLevelDb = new Level(`${this.options.levelDbPath || './background-jobs-dbs/'}job_${jobId}`, { valueEncoding: 'json' });
190
212
  this.levelDbInstances[jobId] = jobLevelDb;
191
-
213
+ await jobLevelDb.put('_meta:count', `${tasks.length}`);
192
214
  const limit2 = pLimit(parrallelLimit);
193
215
  const createTaskRecordsPromises = tasks.map((task, index) => {
194
216
  return limit2(() => this.createLevelDbTaskRecord(jobLevelDb, index.toString(), task.state));
@@ -279,12 +301,14 @@ export default class BackgroundJobsPlugin extends AdminForthPlugin {
279
301
  [this.options.finishedAtField]: (new Date()).toISOString(),
280
302
  })
281
303
  this.adminforth.websocket.publish('/background-jobs', { jobId, status: 'DONE', finishedAt: (new Date()).toISOString() });
304
+ this.cleanupJobMutexIfTerminalStatus(jobId, 'DONE');
282
305
  } else if (failedTasks > 0) {
283
306
  await this.adminforth.resource(this.getResourceId()).update(jobId, {
284
307
  [this.options.statusField]: 'DONE_WITH_ERRORS',
285
308
  [this.options.finishedAtField]: (new Date()).toISOString(),
286
309
  })
287
310
  this.adminforth.websocket.publish('/background-jobs', { jobId, status: 'DONE_WITH_ERRORS' });
311
+ this.cleanupJobMutexIfTerminalStatus(jobId, 'DONE_WITH_ERRORS');
288
312
  }
289
313
  }
290
314
 
@@ -372,6 +396,27 @@ export default class BackgroundJobsPlugin extends AdminForthPlugin {
372
396
  return JSON.parse(state);
373
397
  }
374
398
 
399
+ public async updateJobFieldsAtomicly(jobId: string, updateFunction: () => Promise<void>) {
400
+ if (!jobId) {
401
+ throw new Error('updateJobFieldsAtomicly: jobId is required');
402
+ }
403
+ if (typeof updateFunction !== 'function') {
404
+ throw new Error('updateJobFieldsAtomicly: updateFunction must be a function');
405
+ }
406
+
407
+ // Ensure updates are atomic per jobId.
408
+ // Different jobs are not blocked by each other.
409
+ let mutex = this.jobStateMutexes[jobId];
410
+ if (!mutex) {
411
+ mutex = new Mutex();
412
+ this.jobStateMutexes[jobId] = mutex;
413
+ }
414
+
415
+ return mutex.runExclusive(async () => {
416
+ await updateFunction();
417
+ });
418
+ }
419
+
375
420
  private async processAllUnfinishedJobs() {
376
421
  const resourceId = this.getResourceId();
377
422
  const unprocessedJobs = await this.adminforth.resource(resourceId).list(Filters.EQ(this.options.statusField, 'IN_PROGRESS'));
@@ -494,7 +539,9 @@ export default class BackgroundJobsPlugin extends AdminForthPlugin {
494
539
  tasks.push(parsedTaskData);
495
540
  taskIndex++;
496
541
  }
497
- return { ok: true, tasks };
542
+
543
+ const total = await this.getTotalTasksInLevelDb(jobLevelDb);
544
+ return { ok: true, data: { tasks, total } };
498
545
  }
499
546
  });
500
547
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adminforth/background-jobs",
3
- "version": "1.4.1",
3
+ "version": "1.6.0",
4
4
  "main": "dist/index.js",
5
5
  "types": "dist/index.d.ts",
6
6
  "type": "module",
@@ -20,7 +20,7 @@
20
20
  },
21
21
  "dependencies": {
22
22
  "@vueuse/core": "^14.2.1",
23
- "adminforth": "latest",
23
+ "adminforth": "^2.24.0",
24
24
  "async-mutex": "^0.5.0",
25
25
  "level": "^10.0.0",
26
26
  "p-limit": "^7.3.0"