@webtypen/webframez-core 0.3.80 → 0.3.82

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.
@@ -11,6 +11,14 @@ export declare class QueueWorkerCommand extends ConsoleCommand {
11
11
  workerOperationId: string | null;
12
12
  workerHadError: boolean;
13
13
  lastAutomationCheckAt: any;
14
+ private stopping;
15
+ private automationRun;
16
+ private runtimeState;
17
+ private wakeWaiters;
18
+ private readonly stopSignal;
19
+ get workerInstanceKey(): string;
20
+ requestStop(): void;
21
+ private publishRuntimeState;
14
22
  jobsExecuted: number;
15
23
  jobsSucceeded: number;
16
24
  jobsFailed: number;
@@ -23,7 +31,7 @@ export declare class QueueWorkerCommand extends ConsoleCommand {
23
31
  init(): boolean;
24
32
  canStart(): boolean;
25
33
  random(min: number, max: number): number;
26
- wait(ms: number): Promise<unknown>;
34
+ wait(ms: number): Promise<void>;
27
35
  waitRun(): Promise<unknown>;
28
36
  cancelRun(jobId: any): Promise<unknown>;
29
37
  updateWorkerStatus(values: {
@@ -14,6 +14,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
14
14
  Object.defineProperty(exports, "__esModule", { value: true });
15
15
  exports.QueueWorkerCommand = void 0;
16
16
  const fs_1 = __importDefault(require("fs"));
17
+ const crypto_1 = __importDefault(require("crypto"));
17
18
  const path_1 = __importDefault(require("path"));
18
19
  const moment_timezone_1 = __importDefault(require("moment-timezone"));
19
20
  const ConsoleCommand_1 = require("./ConsoleCommand");
@@ -40,12 +41,44 @@ class QueueWorkerCommand extends ConsoleCommand_1.ConsoleCommand {
40
41
  this.workerOperationId = null;
41
42
  this.workerHadError = false;
42
43
  this.lastAutomationCheckAt = null;
44
+ this.stopping = false;
45
+ this.automationRun = null;
46
+ this.runtimeState = {};
47
+ this.wakeWaiters = new Set();
48
+ this.stopSignal = () => this.requestStop();
43
49
  // Statistics
44
50
  this.jobsExecuted = 0;
45
51
  this.jobsSucceeded = 0;
46
52
  this.jobsFailed = 0;
47
53
  this.lastJob = null;
48
54
  }
55
+ get workerInstanceKey() {
56
+ const instance = process.env.WEBFRAMEZ_QUEUE_INSTANCE;
57
+ if (instance && !/^[A-Za-z0-9_.-]+$/.test(instance))
58
+ throw new Error("Invalid queue instance ID");
59
+ return this.workerKey + (instance ? `--${instance}` : "");
60
+ }
61
+ requestStop() {
62
+ this.stopping = true;
63
+ try {
64
+ this.updateWorkerStatus({ status: "draining", status_at: new Date().toISOString() });
65
+ }
66
+ catch (error) {
67
+ // A telemetry/storage failure must not turn a graceful signal into a job interruption.
68
+ console.error("Could not publish queue drain status", error);
69
+ }
70
+ for (const wake of this.wakeWaiters)
71
+ wake();
72
+ }
73
+ publishRuntimeState(values) {
74
+ this.runtimeState = Object.assign(Object.assign(Object.assign({}, this.runtimeState), values), { pid: process.pid, worker: this.workerKey });
75
+ const target = process.env.WEBFRAMEZ_QUEUE_STATE_FILE;
76
+ if (!target)
77
+ return;
78
+ const temporary = `${target}.${process.pid}.tmp`;
79
+ fs_1.default.writeFileSync(temporary, JSON.stringify(this.runtimeState), { mode: 0o600 });
80
+ fs_1.default.renameSync(temporary, target);
81
+ }
49
82
  log(message) {
50
83
  this.writeln(`[${this.workerKey} | ${(0, moment_timezone_1.default)()
51
84
  .tz(this.workerConfig.timezone ? this.workerConfig.timezone : "Europe/Berlin")
@@ -101,31 +134,37 @@ class QueueWorkerCommand extends ConsoleCommand_1.ConsoleCommand {
101
134
  const workerKey = this.getOption("worker");
102
135
  if (!workerKey || typeof workerKey !== "string" || workerKey.trim() === "") {
103
136
  this.error("You must specify a worker key using --worker=<worker_key>");
137
+ this.workerHadError = true;
104
138
  return;
105
139
  }
106
140
  this.workerConfig = Config_1.Config.get("queue.workers." + workerKey);
107
141
  if (!this.workerConfig) {
108
142
  this.error(`Worker '${workerKey}' is not defined in the queue configuration.`);
143
+ this.workerHadError = true;
109
144
  return;
110
145
  }
111
146
  if (!this.workerConfig.is_active) {
112
147
  this.error(`Worker '${workerKey}' is disabled in the queue configuration.`);
148
+ this.workerHadError = true;
113
149
  return;
114
150
  }
115
151
  this.workerKey = workerKey;
116
152
  this.startTime = (0, moment_timezone_1.default)().tz(this.workerConfig.timezone ? this.workerConfig.timezone : "Europe/Berlin");
117
153
  if (!this.init()) {
154
+ this.workerHadError = true;
118
155
  return;
119
156
  }
157
+ process.on("SIGTERM", this.stopSignal);
158
+ process.on("SIGINT", this.stopSignal);
120
159
  this.workerOperationId = WebframezHooks_1.WebframezHooks.createOperationId("worker");
121
- yield WebframezHooks_1.WebframezHooks.emit("queue.worker.start", {
122
- operationId: this.workerOperationId,
123
- name: workerKey,
124
- attributes: {
125
- "webframez.queue.worker": workerKey,
126
- },
127
- });
128
160
  try {
161
+ yield WebframezHooks_1.WebframezHooks.emit("queue.worker.start", {
162
+ operationId: this.workerOperationId,
163
+ name: workerKey,
164
+ attributes: {
165
+ "webframez.queue.worker": workerKey,
166
+ },
167
+ });
129
168
  this.log("Queue started");
130
169
  this.logRegisteredAutomation();
131
170
  yield this.run();
@@ -158,21 +197,33 @@ class QueueWorkerCommand extends ConsoleCommand_1.ConsoleCommand {
158
197
  this.error(`Queue run failed: ${e instanceof Error ? e.message : String(e)}`);
159
198
  }
160
199
  finally {
161
- if (!this.workerHadError && this.workerOperationId) {
162
- yield WebframezHooks_1.WebframezHooks.emit("queue.worker.end", {
163
- operationId: this.workerOperationId,
164
- name: workerKey,
165
- status: "ok",
166
- attributes: {
167
- "webframez.queue.worker": workerKey,
168
- },
200
+ this.stopping = true;
201
+ for (const wake of this.wakeWaiters)
202
+ wake();
203
+ yield this.automationRun;
204
+ try {
205
+ if (!this.workerHadError && this.workerOperationId) {
206
+ yield WebframezHooks_1.WebframezHooks.emit("queue.worker.end", {
207
+ operationId: this.workerOperationId,
208
+ name: workerKey,
209
+ status: "ok",
210
+ attributes: {
211
+ "webframez.queue.worker": workerKey,
212
+ },
213
+ });
214
+ }
215
+ this.log("Queue stopped");
216
+ this.updateWorkerStatus({
217
+ status: "stopped",
218
+ ended_at: new Date().toISOString(),
219
+ current_job: null,
220
+ current_job_number: null,
169
221
  });
170
222
  }
171
- this.log("Queue stopped");
172
- this.updateWorkerStatus({
173
- status: "stopped",
174
- ended_at: new Date().toISOString(),
175
- });
223
+ finally {
224
+ process.off("SIGTERM", this.stopSignal);
225
+ process.off("SIGINT", this.stopSignal);
226
+ }
176
227
  }
177
228
  });
178
229
  }
@@ -187,7 +238,7 @@ class QueueWorkerCommand extends ConsoleCommand_1.ConsoleCommand {
187
238
  if (!fs_1.default.existsSync((0, FileFunctions_1.storageDir)("queue"))) {
188
239
  fs_1.default.mkdirSync((0, FileFunctions_1.storageDir)("queue"), { recursive: true });
189
240
  }
190
- const filePath = (0, FileFunctions_1.storageDir)("queue", `worker_${this.workerKey}.json`);
241
+ const filePath = (0, FileFunctions_1.storageDir)("queue", `worker_${this.workerInstanceKey}.json`);
191
242
  let lastLogFile = null;
192
243
  if (fs_1.default.existsSync(filePath)) {
193
244
  try {
@@ -207,7 +258,7 @@ class QueueWorkerCommand extends ConsoleCommand_1.ConsoleCommand {
207
258
  return true;
208
259
  }
209
260
  canStart() {
210
- const filePath = (0, FileFunctions_1.storageDir)("queue", `worker_${this.workerKey}.json`);
261
+ const filePath = (0, FileFunctions_1.storageDir)("queue", `worker_${this.workerInstanceKey}.json`);
211
262
  if (!fs_1.default.existsSync(filePath)) {
212
263
  return true;
213
264
  }
@@ -242,8 +293,16 @@ class QueueWorkerCommand extends ConsoleCommand_1.ConsoleCommand {
242
293
  }
243
294
  wait(ms) {
244
295
  return __awaiter(this, void 0, void 0, function* () {
245
- return new Promise((resolve) => {
246
- setTimeout(() => resolve(true), ms);
296
+ if (this.stopping)
297
+ return;
298
+ yield new Promise((resolve) => {
299
+ const done = () => {
300
+ clearTimeout(timer);
301
+ this.wakeWaiters.delete(done);
302
+ resolve();
303
+ };
304
+ const timer = setTimeout(done, ms);
305
+ this.wakeWaiters.add(done);
247
306
  });
248
307
  });
249
308
  }
@@ -254,6 +313,7 @@ class QueueWorkerCommand extends ConsoleCommand_1.ConsoleCommand {
254
313
  status: "waiting",
255
314
  status_at: new Date().toISOString(),
256
315
  current_job: null,
316
+ current_job_number: null,
257
317
  });
258
318
  yield this.wait(1000);
259
319
  resolve(true);
@@ -277,7 +337,10 @@ class QueueWorkerCommand extends ConsoleCommand_1.ConsoleCommand {
277
337
  });
278
338
  }
279
339
  updateWorkerStatus(values) {
280
- const filePath = (0, FileFunctions_1.storageDir)("queue", `worker_${this.workerKey}.json`);
340
+ if (this.stopping && values.status !== "stopped")
341
+ values = Object.assign(Object.assign({}, values), { status: "draining" });
342
+ this.publishRuntimeState(values);
343
+ const filePath = (0, FileFunctions_1.storageDir)("queue", `worker_${this.workerInstanceKey}.json`);
281
344
  if (!fs_1.default.existsSync(filePath))
282
345
  return;
283
346
  const json = JSON.parse(fs_1.default.readFileSync(filePath, "utf-8"));
@@ -378,6 +441,8 @@ class QueueWorkerCommand extends ConsoleCommand_1.ConsoleCommand {
378
441
  continue;
379
442
  }
380
443
  for (let automation of checkAutomation) {
444
+ if (this.checkStop())
445
+ break;
381
446
  if (!automation || !automation.jobclass || !automation._execution || !automation._execution.key) {
382
447
  continue;
383
448
  }
@@ -391,6 +456,7 @@ class QueueWorkerCommand extends ConsoleCommand_1.ConsoleCommand {
391
456
  // Create the automation job
392
457
  const now = (0, moment_timezone_1.default)().tz(this.workerConfig.timezone ? this.workerConfig.timezone : "Europe/Berlin");
393
458
  const newJob = {
459
+ _id: yield DBConnection_1.DBConnection.objectId(crypto_1.default.createHash("sha256").update("queue-automation:" + automation._execution.key).digest("hex").slice(0, 24)),
394
460
  jobclass: automation.jobclass,
395
461
  number: yield this.getNextJobNumber(connection),
396
462
  is_automated: true,
@@ -406,7 +472,17 @@ class QueueWorkerCommand extends ConsoleCommand_1.ConsoleCommand {
406
472
  executions: [],
407
473
  worker: this.workerKey,
408
474
  };
409
- yield DBConnection_1.DBConnection.documentStore(connection).collection("queue_jobs").insertOne(newJob);
475
+ if (this.checkStop())
476
+ break;
477
+ try {
478
+ yield DBConnection_1.DBConnection.documentStore(connection).collection("queue_jobs").insertOne(newJob);
479
+ }
480
+ catch (error) {
481
+ // Concurrent replicas may schedule the same occurrence; the primary key arbitrates.
482
+ const existing = yield DBConnection_1.DBConnection.documentStore(connection).collection("queue_jobs").findOne({ _id: newJob._id });
483
+ if (!existing)
484
+ throw error;
485
+ }
410
486
  }
411
487
  yield this.wait(5000);
412
488
  }
@@ -436,9 +512,11 @@ class QueueWorkerCommand extends ConsoleCommand_1.ConsoleCommand {
436
512
  });
437
513
  }
438
514
  checkStop() {
515
+ if (this.stopping)
516
+ return true;
439
517
  if (!this.workerKey)
440
518
  return false;
441
- const stopFilePath = path_1.default.join((0, FileFunctions_1.storageDir)(), "queue", this.workerKey + ".stop");
519
+ const stopFilePath = path_1.default.join((0, FileFunctions_1.storageDir)(), "queue", this.workerInstanceKey + ".stop");
442
520
  if (fs_1.default.existsSync(stopFilePath)) {
443
521
  if (this.autorestart) {
444
522
  fs_1.default.writeFileSync(path_1.default.join((0, FileFunctions_1.storageDir)(), "queue", this.workerKey + ".stop-autorestart"), JSON.stringify({ date: new Date() }));
@@ -450,7 +528,8 @@ class QueueWorkerCommand extends ConsoleCommand_1.ConsoleCommand {
450
528
  run() {
451
529
  return __awaiter(this, void 0, void 0, function* () {
452
530
  const connection = yield DBConnection_1.DBConnection.getConnection();
453
- this.runAutomation();
531
+ this.updateWorkerStatus({ status: "waiting", current_job: null });
532
+ this.automationRun = this.runAutomation();
454
533
  while (true) {
455
534
  const now = (0, moment_timezone_1.default)().tz(this.workerConfig.timezone ? this.workerConfig.timezone : "Europe/Berlin");
456
535
  if (this.checkStop()) {
@@ -486,6 +565,12 @@ class QueueWorkerCommand extends ConsoleCommand_1.ConsoleCommand {
486
565
  returnDocument: "after",
487
566
  });
488
567
  const job = jobUpdate;
568
+ // A signal can arrive while the atomic claim is in flight. Return that claim before executing it.
569
+ if (this.checkStop()) {
570
+ if (job === null || job === void 0 ? void 0 : job._id)
571
+ yield DBConnection_1.DBConnection.documentStore(connection).collection("queue_jobs").updateOne({ _id: job._id, status: "running", worker: this.workerKey }, { $set: { status: "pending", started_at: null } });
572
+ break;
573
+ }
489
574
  if (!job || !job.jobclass || !job._id) {
490
575
  this.currentJob = null;
491
576
  if (job && job._id) {
@@ -105,6 +105,10 @@ class ConsoleApplication {
105
105
  if (options && options.onEnd) {
106
106
  yield options.onEnd(command.signature);
107
107
  }
108
+ // Managed workers must terminate even when a database driver keeps sockets open.
109
+ if (command.signature === "queue:worker" && process.env.WEBFRAMEZ_QUEUE_STATE_FILE) {
110
+ process.exit(commandInstance.workerHadError ? 1 : 0);
111
+ }
108
112
  }
109
113
  catch (e) {
110
114
  yield ErrorHandler_1.ErrorHandler.report(e, {
@@ -36,9 +36,10 @@ export type DataBuilderOptionMapping = {
36
36
  labelPrefix?: string;
37
37
  };
38
38
  export type DataBuilderOptionsMapping = DataBuilderOptionMapping | DataBuilderOptionMapping[];
39
+ export type DataBuilderFormWidth = string | number | Partial<Record<"width" | "default" | "xs" | "sm" | "md" | "lg" | "xl", string | number>>;
39
40
  export type DataBuilderFormFieldDefinition = {
40
41
  field: string;
41
- width?: string | number;
42
+ width?: DataBuilderFormWidth;
42
43
  placement?: "content" | "actions";
43
44
  hidden?: string | boolean;
44
45
  disabled?: string | boolean;
@@ -55,13 +56,13 @@ export type DataBuilderFormTabDefinition = {
55
56
  export type DataBuilderFormTabsDefinition = {
56
57
  type: "tabs";
57
58
  label?: string;
58
- width?: string | number;
59
+ width?: DataBuilderFormWidth;
59
60
  appearance?: "card" | "plain";
60
61
  tabs: DataBuilderFormTabDefinition[];
61
62
  };
62
63
  export type DataBuilderFormLayoutDefinition = {
63
64
  type: string;
64
- width?: string | number;
65
+ width?: DataBuilderFormWidth;
65
66
  children?: DataBuilderFormDefinition[];
66
67
  [key: string]: any;
67
68
  };
@@ -0,0 +1,9 @@
1
+ # Managed queue workers
2
+
3
+ `queue:worker --worker=<key>` handles SIGTERM and SIGINT by stopping new claims and automated scheduling, then finishing the current job and persisting its result. A claim that completes after a stop request is returned to pending without executing its handler. Repeated stop signals remain graceful; SIGKILL is the explicit force operation.
4
+
5
+ The server runtime assigns `WEBFRAMEZ_QUEUE_INSTANCE` for independent status files and `WEBFRAMEZ_QUEUE_STATE_FILE` for readiness/drain telemetry. The worker configuration key still controls routing and job selection. When the managed console finishes its command/application completion hooks, it exits even if a database connection keeps Node sockets open. Run queue:worker directly under the runtime; do not nest queue:start or queue:worker:autorestart.
6
+
7
+ Automatic schedule occurrences have deterministic primary keys, preventing concurrent managed workers from creating the same occurrence twice. Existing pending jobs still use an atomic claim.
8
+
9
+ Graceful draining does not make job side effects transactional. A forcibly killed job may remain running in the database and have partially completed external work. Check its effects before manually recovering/retrying it.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@webtypen/webframez-core",
3
- "version": "0.3.80",
3
+ "version": "0.3.82",
4
4
  "description": "The core package of the typescript based webframez (web-framework) by webtypen.de",
5
5
  "homepage": "https://webtypen.de/",
6
6
  "license": "MIT",
@@ -32,7 +32,8 @@
32
32
  "auth-client.js",
33
33
  "auth-client.d.ts",
34
34
  "bearer-auth-client.js",
35
- "bearer-auth-client.d.ts"
35
+ "bearer-auth-client.d.ts",
36
+ "docs/queue-workers.md"
36
37
  ],
37
38
  "scripts": {
38
39
  "build": "tsc",