bazis 0.133.0 → 0.134.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/core/agent/AgentToolExecutor.ts +5 -0
- package/core/agent/Tool.contract.ts +2 -2
- package/core/agent/metadata.ts +11 -0
- package/core/infra/connectors/codex/CodexAppServerClient.ts +1 -1
- package/core/scripts/di-generate.ts +8 -0
- package/core/tools/StandardTools.module.ts +14 -0
- package/core/tools/generated/deps.ts +10 -0
- package/core/tools/index.ts +8 -0
- package/core/tools/orm/OrmEntity.references.ts +71 -0
- package/core/tools/orm/ormEntityTools.ts +423 -0
- package/core/workflow/contracts/WorkflowError.ts +2 -1
- package/core/workflow/contracts/WorkflowPartitions.ts +29 -0
- package/core/workflow/contracts/WorkflowProcessUsage.ts +8 -0
- package/core/workflow/definitions_module/WorkflowDefinitions.module.ts +7 -2
- package/core/workflow/definitions_module/access/Definitions.permissions.ts +4 -2
- package/core/workflow/definitions_module/access/ProcessPartition.lookup.ts +27 -0
- package/core/workflow/definitions_module/events/Definitions.events.ts +1 -1
- package/core/workflow/definitions_module/http/Processes.controller.ts +19 -4
- package/core/workflow/definitions_module/services/IWorkflowDefinitions.service.ts +8 -0
- package/core/workflow/definitions_module/services/WorkflowDefinitions.service.ts +24 -0
- package/core/workflow/generated/deps.ts +8 -2
- package/core/workflow/index.ts +5 -0
- package/core/workflow/runtime_module/WorkflowRuntime.module.ts +5 -0
- package/core/workflow/runtime_module/internal/EventDelivery.ts +234 -0
- package/core/workflow/runtime_module/internal/RunLimits.ts +13 -3
- package/core/workflow/runtime_module/internal/RuntimeProcessUsage.ts +13 -0
- package/core/workflow/runtime_module/internal/Schedules.ts +97 -30
- package/core/workflow/runtime_module/internal/WorkflowEngine.ts +29 -3
- package/core/workflow/runtime_module/internal/WorkflowJobs.ts +7 -0
- package/core/workflow/runtime_module/model/WfJob.model.ts +7 -6
- package/core/workflow/runtime_module/services/IWorkflowRuntime.service.ts +21 -1
- package/core/workflow/runtime_module/services/WorkflowRuntime.service.ts +29 -40
- package/library/workflow/cron.ts +103 -24
- package/library/workflow/index.ts +2 -2
- package/library/workflow/nodeKinds.ts +64 -9
- package/library/workflow/paths.ts +2 -2
- package/library/workflow/validateProcess.ts +12 -0
- package/package.json +2 -1
|
@@ -3,12 +3,12 @@ import { WorkflowError } from "../../contracts/WorkflowError";
|
|
|
3
3
|
import type { IWorkflowDefinitions, VersionDetails } from "../../definitions_module/services/IWorkflowDefinitions.service";
|
|
4
4
|
import type { RuntimeDbContext } from "../model/Runtime.dbContext";
|
|
5
5
|
import type { WfJob } from "../model/WfJob.model";
|
|
6
|
-
import type
|
|
6
|
+
import { queuesSchedule, type WorkflowEngine } from "./WorkflowEngine";
|
|
7
7
|
|
|
8
8
|
/** How many waiting instances of a process one pass looks at. */
|
|
9
9
|
const RELEASE_BATCH = 1000;
|
|
10
10
|
|
|
11
|
-
/** What a whole run is limited by and what follows its failure: `runTimeout`, `maxConcurrent`, `sameKey: "queue"`, `errorHandler`. */
|
|
11
|
+
/** What a whole run is limited by and what follows its failure: `runTimeout`, `maxConcurrent`, `sameKey: "queue"`, `overlap: "queue"`, `errorHandler`. */
|
|
12
12
|
export class RunLimits {
|
|
13
13
|
constructor(
|
|
14
14
|
private readonly db: RuntimeDbContext,
|
|
@@ -47,9 +47,11 @@ export class RunLimits {
|
|
|
47
47
|
await this.engine.lockProcess(processKey);
|
|
48
48
|
let running = await this.engine.runningOf(processKey);
|
|
49
49
|
for (const row of waiting) {
|
|
50
|
-
const {
|
|
50
|
+
const { document } = graphs.get(row.id)!;
|
|
51
|
+
const { maxConcurrent, sameKey } = document.settings ?? {};
|
|
51
52
|
if (maxConcurrent !== undefined && running >= maxConcurrent) continue;
|
|
52
53
|
if (sameKey === "queue" && row.businessKey !== null && await this.keyTaken(processKey, row.businessKey, row.id)) continue;
|
|
54
|
+
if (row.startedVia === "schedule" && queuesSchedule(document) && await this.scheduleTaken(processKey, row.partition, row.id)) continue;
|
|
53
55
|
const released = await this.engine.inStep(row.id, async (step) => {
|
|
54
56
|
if (step.instance.status !== "pending") return false;
|
|
55
57
|
await this.engine.release(step);
|
|
@@ -68,6 +70,14 @@ export class RunLimits {
|
|
|
68
70
|
return same > 0;
|
|
69
71
|
}
|
|
70
72
|
|
|
73
|
+
/** Whether a scheduled run of the partition goes on, or waits since before this one. */
|
|
74
|
+
private async scheduleTaken(processKey: string, partition: string | null, id: string): Promise<boolean> {
|
|
75
|
+
const same = await this.db.instances.asNoTracking()
|
|
76
|
+
.where((i) => i.processKey.eq(processKey).and(i.startedVia.eq("schedule")).and(partition === null ? i.partition.isNull() : i.partition.eq(partition)).and(i.status.eq("running").or(i.status.eq("pending").and(i.id.lt(id)))))
|
|
77
|
+
.count();
|
|
78
|
+
return same > 0;
|
|
79
|
+
}
|
|
80
|
+
|
|
71
81
|
/** An `errorHandler` job: the handler starts with `$event` telling what happened. What keeps it from starting goes to the history of the instance that failed. */
|
|
72
82
|
async errorHandler(job: WfJob): Promise<void> {
|
|
73
83
|
const failed = job.instanceId === null ? undefined : await this.db.instances.asNoTracking().firstOrDefault((i) => i.id.eq(job.instanceId!));
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { WorkflowProcessUsage } from "../../contracts/WorkflowProcessUsage";
|
|
2
|
+
import type { RuntimeDbContext } from "../model/Runtime.dbContext";
|
|
3
|
+
|
|
4
|
+
/** Unfinished instances of a process: they stop it from being deleted. */
|
|
5
|
+
export class RuntimeProcessUsage extends WorkflowProcessUsage {
|
|
6
|
+
constructor(private readonly db: RuntimeDbContext) {
|
|
7
|
+
super();
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
async liveInstances(processKey: string): Promise<number> {
|
|
11
|
+
return await this.db.instances.asNoTracking().where((i) => i.processKey.eq(processKey).and(i.status.in(["pending", "running", "failed"]))).count();
|
|
12
|
+
}
|
|
13
|
+
}
|
|
@@ -1,43 +1,76 @@
|
|
|
1
|
-
import { inputSpecOf, parseCron, validateForm } from "../../../../library/workflow";
|
|
1
|
+
import { inputSpecOf, parseCron, validateForm, type ProcessNode } from "../../../../library/workflow";
|
|
2
|
+
import type { ServiceProvider } from "../../../di";
|
|
3
|
+
import { WorkflowPartitions, type ProcessPartition } from "../../contracts/WorkflowPartitions";
|
|
2
4
|
import type { IWorkflowDefinitions, VersionDetails } from "../../definitions_module/services/IWorkflowDefinitions.service";
|
|
5
|
+
import type { WorkflowLanguages } from "../../internal/WorkflowLanguages";
|
|
3
6
|
import type { RuntimeDbContext } from "../model/Runtime.dbContext";
|
|
4
7
|
import { WfJob } from "../model/WfJob.model";
|
|
5
8
|
import type { WorkflowEngine } from "./WorkflowEngine";
|
|
6
9
|
|
|
7
10
|
const SCHEDULE_ACTOR = { kind: "system", id: "schedule" } as const;
|
|
11
|
+
const MINUTE = 60_000;
|
|
12
|
+
|
|
13
|
+
/** The `config` of a `scheduleStart`, as validation lets it through. */
|
|
14
|
+
interface ScheduleConfig {
|
|
15
|
+
readonly cron: string;
|
|
16
|
+
readonly timezone?: string;
|
|
17
|
+
readonly from?: string;
|
|
18
|
+
readonly until?: string;
|
|
19
|
+
readonly overlap?: "skip" | "queue" | "parallel";
|
|
20
|
+
readonly catchUp?: boolean;
|
|
21
|
+
readonly partitions?: "none" | "all" | readonly string[];
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** One place a node fires in: a partition with its time zone. */
|
|
25
|
+
interface Target {
|
|
26
|
+
readonly partition: string | null;
|
|
27
|
+
readonly timezone: string | undefined;
|
|
28
|
+
}
|
|
8
29
|
|
|
9
30
|
/**
|
|
10
|
-
* Scheduled starts (`scheduleStart
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
* starts the
|
|
14
|
-
* transaction, so a crash never starts one firing twice.
|
|
15
|
-
*
|
|
31
|
+
* Scheduled starts (`scheduleStart`). Every node of a latest published version
|
|
32
|
+
* has a `schedule` job per partition it runs in, due at its next firing.
|
|
33
|
+
* `reconcile` keeps the jobs in line with the catalog and `WorkflowPartitions`;
|
|
34
|
+
* `fire` starts the instance of a due job and plans the next firing in the same
|
|
35
|
+
* transaction, so a crash never starts one firing twice. With `catchUp` the next
|
|
36
|
+
* firing is planned from the one just made, so every missed firing runs;
|
|
37
|
+
* otherwise from now, and those missed while no worker ran are gone.
|
|
16
38
|
*/
|
|
17
39
|
export class Schedules {
|
|
18
40
|
constructor(
|
|
19
41
|
private readonly db: RuntimeDbContext,
|
|
20
42
|
private readonly engine: WorkflowEngine,
|
|
21
43
|
private readonly definitions: IWorkflowDefinitions,
|
|
44
|
+
private readonly languages: WorkflowLanguages,
|
|
45
|
+
/** The scope: the application's own `WorkflowPartitions` replaces the default. */
|
|
46
|
+
private readonly services: ServiceProvider,
|
|
22
47
|
) {}
|
|
23
48
|
|
|
24
|
-
/** Adds jobs for new scheduled
|
|
49
|
+
/** Adds jobs for new scheduled nodes and partitions and drops those no longer wanted. */
|
|
25
50
|
async reconcile(now = this.engine.now()): Promise<void> {
|
|
26
|
-
const
|
|
27
|
-
const
|
|
51
|
+
const wanted = new Map<string, { readonly version: VersionDetails; readonly nodeId: string; readonly config: ScheduleConfig; readonly target: Target }>();
|
|
52
|
+
const invalid: string[] = [];
|
|
53
|
+
for (const version of await this.definitions.published()) {
|
|
54
|
+
for (const [nodeId, config] of scheduleNodes(version)) {
|
|
55
|
+
for (const target of await this.targetsOf(version.key, config)) {
|
|
56
|
+
if (parseCron(config.cron, target.timezone) === undefined) invalid.push(`${version.key}: time zone ${String(target.timezone)} of partition ${String(target.partition)}`);
|
|
57
|
+
else wanted.set(keyOf(version.id, nodeId, target.partition), { version, nodeId, config, target });
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
}
|
|
28
61
|
const added: WfJob[] = [];
|
|
29
62
|
await this.db.transactionScope(async () => {
|
|
30
63
|
// One reconciler at a time across application instances.
|
|
31
64
|
await this.db.database.querySqlRaw("SELECT pg_advisory_xact_lock(hashtext('bazis.workflow.schedules'))");
|
|
32
65
|
const jobs = await this.db.jobs.asNoTracking().where((j) => j.kind.eq("schedule")).toList();
|
|
33
|
-
const stale = jobs.filter((job) =>
|
|
66
|
+
const stale = jobs.filter((job) => !wanted.has(keyOf(job.versionId, job.nodeId, job.partition))).map((job) => job.id);
|
|
34
67
|
if (stale.length > 0) await this.db.jobs.asNoTracking().where((j) => j.id.in(stale)).executeDelete();
|
|
35
|
-
const planned = new Set(jobs.map((job) => job.versionId));
|
|
36
|
-
for (const version of
|
|
37
|
-
if (planned.has(
|
|
38
|
-
const dueAt = nextFiring(
|
|
68
|
+
const planned = new Set(jobs.map((job) => keyOf(job.versionId, job.nodeId, job.partition)));
|
|
69
|
+
for (const [key, { version, nodeId, config, target }] of wanted) {
|
|
70
|
+
if (planned.has(key)) continue;
|
|
71
|
+
const dueAt = nextFiring(config, target, now);
|
|
39
72
|
if (dueAt === undefined) continue;
|
|
40
|
-
const job = Object.assign(new WfJob(), { id: Bun.randomUUIDv7(), kind: "schedule" as const, versionId: version.id, dueAt });
|
|
73
|
+
const job = Object.assign(new WfJob(), { id: Bun.randomUUIDv7(), kind: "schedule" as const, versionId: version.id, nodeId, partition: target.partition, dueAt });
|
|
41
74
|
this.db.jobs.add(job);
|
|
42
75
|
added.push(job);
|
|
43
76
|
}
|
|
@@ -45,40 +78,74 @@ export class Schedules {
|
|
|
45
78
|
});
|
|
46
79
|
// The jobs belong to the worker from now on: this context must not hold them.
|
|
47
80
|
for (const job of added) this.db.detach(job);
|
|
81
|
+
if (invalid.length > 0) throw new Error(`Scheduled starts are not planned for ${invalid.join("; ")}`);
|
|
48
82
|
}
|
|
49
83
|
|
|
50
|
-
/** A `schedule` job is due: start an instance
|
|
84
|
+
/** A `schedule` job is due: start an instance unless `overlap` says otherwise, and plan the next firing. */
|
|
51
85
|
async fire(job: WfJob): Promise<void> {
|
|
52
86
|
const version = job.versionId === null ? undefined : await this.definitions.versionById(job.versionId);
|
|
53
87
|
const latest = version === undefined ? undefined : (await this.definitions.published()).find((item) => item.id === version.id);
|
|
54
|
-
|
|
88
|
+
const config = latest === undefined || job.nodeId === null ? undefined : scheduleConfig(latest.document.nodes[job.nodeId]);
|
|
89
|
+
const target = latest === undefined || config === undefined ? undefined : (await this.targetsOf(latest.key, config)).find((item) => item.partition === job.partition);
|
|
90
|
+
if (!latest || !config || !target) {
|
|
55
91
|
await this.db.jobs.asNoTracking().where((j) => j.id.eq(job.id)).executeDelete();
|
|
56
92
|
return;
|
|
57
93
|
}
|
|
58
|
-
const due = new Date(Math.floor(job.dueAt.getTime() /
|
|
94
|
+
const due = new Date(Math.floor(job.dueAt.getTime() / MINUTE) * MINUTE);
|
|
59
95
|
const graph = this.engine.cache(latest);
|
|
96
|
+
const { overlap = "parallel" } = config;
|
|
60
97
|
await this.db.transactionScope(async () => {
|
|
61
|
-
// Planned from now, not from the missed firing: a stopped worker does not catch up.
|
|
62
98
|
const now = this.engine.now();
|
|
63
|
-
const next = nextFiring(
|
|
99
|
+
const next = nextFiring(config, target, config.catchUp === true || now <= due ? due : now);
|
|
64
100
|
if (next === undefined) await this.db.jobs.asNoTracking().where((j) => j.id.eq(job.id)).executeDelete();
|
|
65
101
|
else await this.db.jobs.asNoTracking().where((j) => j.id.eq(job.id)).executeUpdate({ dueAt: next, lockedUntil: null, attempts: 0, lastError: null });
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
await this.engine.
|
|
102
|
+
let queued = false;
|
|
103
|
+
if (overlap !== "parallel") {
|
|
104
|
+
await this.engine.lockProcess(latest.key);
|
|
105
|
+
if (await this.engine.scheduledLive(latest.key, target.partition) > 0) {
|
|
106
|
+
if (overlap === "skip") return;
|
|
107
|
+
queued = true;
|
|
108
|
+
}
|
|
69
109
|
}
|
|
110
|
+
await this.engine.begin(graph, job.nodeId!, { input: validateForm(inputSpecOf(latest.document), {}).value, partition: target.partition, businessKey: null, startedVia: "schedule", actor: SCHEDULE_ACTOR, queued, scheduledAt: due });
|
|
70
111
|
});
|
|
71
112
|
}
|
|
113
|
+
|
|
114
|
+
/** Where a node runs: `none` without a partition, `all` in every enabled one, a list in those not switched off. */
|
|
115
|
+
private async targetsOf(processKey: string, config: ScheduleConfig): Promise<readonly Target[]> {
|
|
116
|
+
const { partitions = "none", timezone } = config;
|
|
117
|
+
if (partitions === "none") return [{ partition: null, timezone }];
|
|
118
|
+
const known = await this.partitionsOf(processKey);
|
|
119
|
+
if (partitions === "all") return known.filter((item) => item.enabled).map((item) => ({ partition: item.partition, timezone: timezone ?? item.timezone }));
|
|
120
|
+
return partitions.flatMap((partition) => {
|
|
121
|
+
const entry = known.find((item) => item.partition === partition);
|
|
122
|
+
return entry?.enabled === false ? [] : [{ partition, timezone: timezone ?? entry?.timezone }];
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
private async partitionsOf(processKey: string): Promise<readonly ProcessPartition[]> {
|
|
127
|
+
return await this.services.resolve(WorkflowPartitions).of(processKey, { partition: null, language: this.languages.default, actor: null });
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function keyOf(versionId: string | null, nodeId: string | null, partition: string | null): string {
|
|
132
|
+
return JSON.stringify([versionId, nodeId, partition]);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function scheduleConfig(node: ProcessNode | undefined): ScheduleConfig | undefined {
|
|
136
|
+
return node?.kind === "scheduleStart" && typeof node.config?.cron === "string" ? node.config as unknown as ScheduleConfig : undefined;
|
|
72
137
|
}
|
|
73
138
|
|
|
74
|
-
function scheduleNodes(version: VersionDetails) {
|
|
139
|
+
function scheduleNodes(version: VersionDetails): readonly (readonly [string, ScheduleConfig])[] {
|
|
75
140
|
return Object.entries(version.document.nodes).flatMap(([id, node]) => {
|
|
76
|
-
const
|
|
77
|
-
return
|
|
141
|
+
const config = scheduleConfig(node);
|
|
142
|
+
return config === undefined ? [] : [[id, config] as const];
|
|
78
143
|
});
|
|
79
144
|
}
|
|
80
145
|
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
146
|
+
/** The first firing after `after` inside the period of the node. */
|
|
147
|
+
function nextFiring(config: ScheduleConfig, target: Target, after: Date): Date | undefined {
|
|
148
|
+
const from = config.from === undefined ? undefined : new Date(Date.parse(config.from) - MINUTE);
|
|
149
|
+
const next = parseCron(config.cron, target.timezone)?.next(from !== undefined && from > after ? from : after);
|
|
150
|
+
return next === undefined || (config.until !== undefined && next.getTime() > Date.parse(config.until)) ? undefined : next;
|
|
84
151
|
}
|
|
@@ -49,6 +49,10 @@ export interface BeginOptions {
|
|
|
49
49
|
readonly parent?: { readonly instanceId: string; readonly tokenId: string; readonly depth: number };
|
|
50
50
|
/** What led to the start: `$trigger.cause`. */
|
|
51
51
|
readonly cause?: Readonly<Record<string, unknown>>;
|
|
52
|
+
/** It waits as `pending` whatever the limits say: `overlap: "queue"`. */
|
|
53
|
+
readonly queued?: boolean;
|
|
54
|
+
/** The firing a scheduled start belongs to: `$trigger.scheduledAt`. */
|
|
55
|
+
readonly scheduledAt?: Date;
|
|
52
56
|
}
|
|
53
57
|
|
|
54
58
|
/** Why a step failed. `reason` is the business reason an error boundary may name. */
|
|
@@ -160,7 +164,8 @@ export class WorkflowEngine {
|
|
|
160
164
|
return await this.db.transactionScope(async (transaction) => {
|
|
161
165
|
const admission = await this.admit(graph, options.businessKey);
|
|
162
166
|
if (typeof admission === "object") return { id: admission.skippedBy, skipped: true };
|
|
163
|
-
const
|
|
167
|
+
const waits = admission === "queue" || options.queued === true;
|
|
168
|
+
const instance = this.newInstance(graph, options, waits ? "pending" : "running");
|
|
164
169
|
this.db.instances.add(instance);
|
|
165
170
|
await this.db.saveChanges();
|
|
166
171
|
const step = this.stepOf(instance, [], graph);
|
|
@@ -170,7 +175,7 @@ export class WorkflowEngine {
|
|
|
170
175
|
payload: { instanceId: instance.id, processKey: graph.processKey, version: graph.version, partition: options.partition, businessKey: options.businessKey, startedVia: options.startedVia, actor: options.actor, request: options.request ?? null },
|
|
171
176
|
});
|
|
172
177
|
const first = this.spawn(step, startNodeId, null, TOP);
|
|
173
|
-
if (
|
|
178
|
+
if (waits) {
|
|
174
179
|
first.status = "waiting";
|
|
175
180
|
this.history(step, first, startNodeId, "queued", null);
|
|
176
181
|
} else {
|
|
@@ -222,6 +227,13 @@ export class WorkflowEngine {
|
|
|
222
227
|
return await this.db.instances.asNoTracking().where((i) => i.processKey.eq(processKey).and(i.status.eq("running"))).count();
|
|
223
228
|
}
|
|
224
229
|
|
|
230
|
+
/** Scheduled runs of a process that still go on in a partition, for `overlap`. A failed one waits for a person and takes no place. */
|
|
231
|
+
async scheduledLive(processKey: string, partition: string | null): Promise<number> {
|
|
232
|
+
return await this.db.instances.asNoTracking()
|
|
233
|
+
.where((i) => i.processKey.eq(processKey).and(i.startedVia.eq("schedule")).and(i.status.in(["pending", "running"])).and(partition === null ? i.partition.isNull() : i.partition.eq(partition)))
|
|
234
|
+
.count();
|
|
235
|
+
}
|
|
236
|
+
|
|
225
237
|
/** A `pending` instance got its place: its token moves from the start. */
|
|
226
238
|
async release(step: Step): Promise<void> {
|
|
227
239
|
if (step.instance.status !== "pending") return;
|
|
@@ -253,6 +265,7 @@ export class WorkflowEngine {
|
|
|
253
265
|
...(options.event === undefined ? {} : { eventKey: options.event.key }),
|
|
254
266
|
...(options.cause === undefined ? {} : { cause: options.cause }),
|
|
255
267
|
...(options.parent === undefined ? {} : { depth: options.parent.depth }),
|
|
268
|
+
...(options.scheduledAt === undefined ? {} : { scheduledAt: options.scheduledAt.toISOString() }),
|
|
256
269
|
},
|
|
257
270
|
businessKey: options.businessKey,
|
|
258
271
|
startedVia: options.startedVia,
|
|
@@ -418,6 +431,14 @@ export class WorkflowEngine {
|
|
|
418
431
|
return await this.fireBoundary(step, token, node, { firedAt }, "timer");
|
|
419
432
|
}
|
|
420
433
|
|
|
434
|
+
/** Whether the step takes this event: `eventWait.until` may leave it waiting for the next one. */
|
|
435
|
+
eventAccepted(step: Step, tokenId: string, payload: Record<string, unknown>): boolean {
|
|
436
|
+
const token = step.tokens.find((t) => t.id === tokenId);
|
|
437
|
+
const node = token === undefined ? undefined : step.graph.document.nodes[token.nodeId];
|
|
438
|
+
if (token === undefined || node?.kind !== "eventWait" || node.config?.until === undefined || token.status !== "waiting") return true;
|
|
439
|
+
return isTruthy(evaluateExpression(node.config.until as Expr, { ...dataOf(step.instance, this.now(), token), event: payload }));
|
|
440
|
+
}
|
|
441
|
+
|
|
421
442
|
/**
|
|
422
443
|
* An event the token waits for arrived. An `eventWait` step moves on with
|
|
423
444
|
* the payload (and `saveTo`); a `boundaryEvent` takes its flow like a
|
|
@@ -553,7 +574,7 @@ export class WorkflowEngine {
|
|
|
553
574
|
const { instance, graph } = step;
|
|
554
575
|
const settings = graph.document.settings;
|
|
555
576
|
if (instance.parentInstanceId !== null) this.addJob(step, "returned", { payload: { parentInstanceId: instance.parentInstanceId, parentTokenId: instance.parentTokenId } });
|
|
556
|
-
if (settings?.maxConcurrent !== undefined || settings?.sameKey === "queue") this.addJob(step, "release", { payload: { processKey: instance.processKey } });
|
|
577
|
+
if (settings?.maxConcurrent !== undefined || settings?.sameKey === "queue" || (instance.startedVia === "schedule" && queuesSchedule(graph.document))) this.addJob(step, "release", { payload: { processKey: instance.processKey } });
|
|
557
578
|
// A handler that fails does not start a handler: a chain of them would never end.
|
|
558
579
|
if ((instance.status === "failed" || instance.status === "timedOut") && settings?.errorHandler !== undefined && instance.startedVia !== "errorHandler") {
|
|
559
580
|
this.addJob(step, "errorHandler", { payload: { process: settings.errorHandler } });
|
|
@@ -960,6 +981,11 @@ export function runActorOf(document: ProcessDocument, instance: WfInstance): Wor
|
|
|
960
981
|
return document.settings?.runAs === "process" ? { kind: "system", id: `process:${instance.processKey}` } : { kind: instance.startedByKind, id: instance.startedById };
|
|
961
982
|
}
|
|
962
983
|
|
|
984
|
+
/** Whether a scheduled run of the process waits for the one before it: `overlap: "queue"`. */
|
|
985
|
+
export function queuesSchedule(document: ProcessDocument): boolean {
|
|
986
|
+
return Object.values(document.nodes).some((node) => node.kind === "scheduleStart" && node.config?.overlap === "queue");
|
|
987
|
+
}
|
|
988
|
+
|
|
963
989
|
function isLoopState(value: unknown): value is LoopState {
|
|
964
990
|
return typeof value === "object" && value !== null && Object.hasOwn(value, "next") && Object.hasOwn(value, "collected");
|
|
965
991
|
}
|
|
@@ -4,6 +4,7 @@ import { RuntimeDbContext } from "../model/Runtime.dbContext";
|
|
|
4
4
|
import type { WfJob } from "../model/WfJob.model";
|
|
5
5
|
import { ChildCalls } from "./ChildCalls";
|
|
6
6
|
import { RunLimits } from "./RunLimits";
|
|
7
|
+
import { EventDelivery } from "./EventDelivery";
|
|
7
8
|
import { Schedules } from "./Schedules";
|
|
8
9
|
import { TimerStep } from "./TimerStep";
|
|
9
10
|
import { LEASE_MS, ToolStep } from "./ToolStep";
|
|
@@ -81,6 +82,12 @@ export class WorkflowJobs {
|
|
|
81
82
|
case "errorHandler":
|
|
82
83
|
await scope.resolve(RunLimits).errorHandler(job);
|
|
83
84
|
break;
|
|
85
|
+
case "event":
|
|
86
|
+
await scope.resolve(EventDelivery).deliverLater(job);
|
|
87
|
+
break;
|
|
88
|
+
case "eventStart":
|
|
89
|
+
await scope.resolve(EventDelivery).startLater(job);
|
|
90
|
+
break;
|
|
84
91
|
default:
|
|
85
92
|
await scope.resolve(ToolStep).run(job, signal);
|
|
86
93
|
}
|
|
@@ -7,9 +7,10 @@ import { WfInstance } from "./WfInstance.model";
|
|
|
7
7
|
* `call` starts a called process, `returned` tells the caller it ended,
|
|
8
8
|
* `cancelChildren` closes what a closed step called. `runTimeout` ends a run that
|
|
9
9
|
* took too long, `release` starts instances that wait for a place, `errorHandler`
|
|
10
|
-
* starts the process that handles a failure.
|
|
10
|
+
* starts the process that handles a failure. `event` delivers what `emitLater`
|
|
11
|
+
* wrote, `eventStart` starts a process by an event after `delay` or `debounce`.
|
|
11
12
|
*/
|
|
12
|
-
export type JobKind = "tool" | "timer" | "schedule" | "loop" | "call" | "returned" | "cancelChildren" | "runTimeout" | "release" | "errorHandler";
|
|
13
|
+
export type JobKind = "tool" | "timer" | "schedule" | "loop" | "call" | "returned" | "cancelChildren" | "runTimeout" | "release" | "errorHandler" | "event" | "eventStart";
|
|
13
14
|
|
|
14
15
|
/**
|
|
15
16
|
* Deferred work of the worker, created in the same transaction as the waiting
|
|
@@ -26,7 +27,7 @@ export class WfJob {
|
|
|
26
27
|
@Column({ type: "text" })
|
|
27
28
|
kind: JobKind = "tool";
|
|
28
29
|
|
|
29
|
-
/** `null` for
|
|
30
|
+
/** `null` for `schedule`, `event` and `eventStart` jobs: the instance does not exist yet. */
|
|
30
31
|
@Index()
|
|
31
32
|
@Column({ type: "text", nullable: true })
|
|
32
33
|
instanceId: string | null = null;
|
|
@@ -35,7 +36,7 @@ export class WfJob {
|
|
|
35
36
|
@Column({ type: "text", nullable: true })
|
|
36
37
|
tokenId: string | null = null;
|
|
37
38
|
|
|
38
|
-
/** The version a `schedule` job starts. */
|
|
39
|
+
/** The version a `schedule` or `eventStart` job starts. */
|
|
39
40
|
@Column({ type: "text", nullable: true })
|
|
40
41
|
versionId: string | null = null;
|
|
41
42
|
|
|
@@ -47,12 +48,12 @@ export class WfJob {
|
|
|
47
48
|
@Column({ type: "json", nullable: true })
|
|
48
49
|
payload: unknown = null;
|
|
49
50
|
|
|
50
|
-
/** Jobs with one key merge into the last: `debounce` of an event start
|
|
51
|
+
/** Jobs with one key merge into the last: `debounce` of an event start. */
|
|
51
52
|
@Index()
|
|
52
53
|
@Column({ type: "text", nullable: true })
|
|
53
54
|
dedupeKey: string | null = null;
|
|
54
55
|
|
|
55
|
-
/** The partition and the start node of a `schedule`
|
|
56
|
+
/** The partition and the start node of a `schedule` or `eventStart` job. */
|
|
56
57
|
@Column({ type: "text", nullable: true })
|
|
57
58
|
partition: string | null = null;
|
|
58
59
|
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { AccessFilter } from "../../../access";
|
|
2
2
|
import type { FormIssue } from "../../../../library/workflow";
|
|
3
|
+
import type { OrmTransaction } from "../../../orm";
|
|
3
4
|
import type { WorkflowActor, WorkflowAuditOptions, WorkflowPage, WorkflowPaging } from "../../contracts/types";
|
|
4
5
|
import type { HistoryType } from "../model/WfHistory.model";
|
|
5
6
|
import type { InstanceStatus, StartedVia } from "../model/WfInstance.model";
|
|
@@ -99,13 +100,23 @@ export interface HistoryEntry {
|
|
|
99
100
|
readonly at: Date;
|
|
100
101
|
}
|
|
101
102
|
|
|
103
|
+
/** What led to an event: `$trigger.cause` of the instances it starts. `instanceId` makes `ignoreOwn` work. */
|
|
104
|
+
export interface EmitCause {
|
|
105
|
+
readonly instanceId?: string;
|
|
106
|
+
readonly [key: string]: unknown;
|
|
107
|
+
}
|
|
108
|
+
|
|
102
109
|
export interface EmitOptions extends WorkflowAuditOptions {
|
|
103
110
|
/** Which waits it is for: `order:42`. Waits without a correlation take any event of the key. */
|
|
104
111
|
readonly correlation?: string | null;
|
|
105
|
-
/**
|
|
112
|
+
/**
|
|
113
|
+
* Only the waits of this partition; it is also the partition of instances the
|
|
114
|
+
* event starts, and only processes `WorkflowPartitions` connects to it start.
|
|
115
|
+
*/
|
|
106
116
|
readonly partition?: string | null;
|
|
107
117
|
/** On whose behalf started instances run; `{ kind: "system", id: "event:<key>" }` by default. */
|
|
108
118
|
readonly actor?: WorkflowActor;
|
|
119
|
+
readonly cause?: EmitCause | null;
|
|
109
120
|
}
|
|
110
121
|
|
|
111
122
|
export interface EmitResult {
|
|
@@ -113,6 +124,8 @@ export interface EmitResult {
|
|
|
113
124
|
readonly started: readonly string[];
|
|
114
125
|
/** Processes with `sameKey: "skip"` that did not start: an instance with the key is not finished. */
|
|
115
126
|
readonly skipped: readonly { readonly processKey: string; readonly instanceId: string }[];
|
|
127
|
+
/** Processes that start later: `debounce` or `delay` of their `eventStart`. */
|
|
128
|
+
readonly deferred: readonly { readonly processKey: string }[];
|
|
116
129
|
/** Instances whose `eventWait` or `boundaryEvent` took the event. */
|
|
117
130
|
readonly continued: readonly string[];
|
|
118
131
|
/** Processes with an `eventStart` whose input the payload did not pass. */
|
|
@@ -150,6 +163,13 @@ export abstract class IWorkflowRuntime {
|
|
|
150
163
|
* `eventStart` for the key, and moves the instances waiting for it.
|
|
151
164
|
*/
|
|
152
165
|
abstract emit(eventKey: string, payload?: unknown, options?: EmitOptions): Promise<EmitResult>;
|
|
166
|
+
/**
|
|
167
|
+
* Writes an event in the application's transaction: it is delivered as `emit`
|
|
168
|
+
* after the commit and is gone with a rollback. Needs the same `DatabaseProvider`
|
|
169
|
+
* as the process tables. Who it reached is not known at this point: a payload a
|
|
170
|
+
* process refuses goes to the log.
|
|
171
|
+
*/
|
|
172
|
+
abstract emitLater(tx: OrmTransaction, eventKey: string, payload?: unknown, options?: EmitOptions): Promise<void>;
|
|
153
173
|
/** Instances, newest first. */
|
|
154
174
|
abstract instances(query?: InstanceListQuery): Promise<WorkflowPage<InstanceSummary>>;
|
|
155
175
|
abstract instance(instanceId: string): Promise<InstanceDetails>;
|
|
@@ -1,16 +1,18 @@
|
|
|
1
1
|
import { grantsToWhere } from "../../../access";
|
|
2
|
-
import { paginate } from "../../../orm";
|
|
3
|
-
import { inputSpecOf, type
|
|
2
|
+
import { paginate, type OrmTransaction } from "../../../orm";
|
|
3
|
+
import { inputSpecOf, type FormSpec } from "../../../../library/workflow";
|
|
4
4
|
import { isUuid, pageQueryOf, type WorkflowActor, type WorkflowPage } from "../../contracts/types";
|
|
5
5
|
import { WorkflowError } from "../../contracts/WorkflowError";
|
|
6
6
|
import type { ServiceProvider } from "../../../di";
|
|
7
7
|
import { WorkflowInputValidator } from "../../contracts/WorkflowInputValidator";
|
|
8
8
|
import type { IWorkflowDefinitions } from "../../definitions_module/services/IWorkflowDefinitions.service";
|
|
9
9
|
import type { WorkflowLanguages } from "../../internal/WorkflowLanguages";
|
|
10
|
+
import type { EventDelivery, EventMessage } from "../internal/EventDelivery";
|
|
10
11
|
import type { WorkflowEngine } from "../internal/WorkflowEngine";
|
|
11
12
|
import type { RuntimeDbContext } from "../model/Runtime.dbContext";
|
|
12
13
|
import type { WfHistory } from "../model/WfHistory.model";
|
|
13
14
|
import type { WfInstance } from "../model/WfInstance.model";
|
|
15
|
+
import { WfJob } from "../model/WfJob.model";
|
|
14
16
|
import type { WfToken } from "../model/WfToken.model";
|
|
15
17
|
import {
|
|
16
18
|
IWorkflowRuntime,
|
|
@@ -35,6 +37,7 @@ export class WorkflowRuntime extends IWorkflowRuntime {
|
|
|
35
37
|
constructor(
|
|
36
38
|
private readonly db: RuntimeDbContext,
|
|
37
39
|
private readonly engine: WorkflowEngine,
|
|
40
|
+
private readonly delivery: EventDelivery,
|
|
38
41
|
private readonly definitions: IWorkflowDefinitions,
|
|
39
42
|
/** The scope: the input validator is asked here, so that the application's own registration replaces the default. */
|
|
40
43
|
private readonly services: ServiceProvider,
|
|
@@ -135,45 +138,14 @@ export class WorkflowRuntime extends IWorkflowRuntime {
|
|
|
135
138
|
}
|
|
136
139
|
|
|
137
140
|
async emit(eventKey: string, payload: unknown = {}, options: EmitOptions = {}): Promise<EmitResult> {
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
const data = payload as Record<string, unknown>;
|
|
141
|
-
const correlation = optionalText(options.correlation, "correlation");
|
|
142
|
-
const partition = optionalText(options.partition, "partition");
|
|
143
|
-
const actor = options.actor ?? { kind: "system" as const, id: `event:${eventKey}` };
|
|
144
|
-
actorOf(actor);
|
|
145
|
-
|
|
146
|
-
let waits = this.db.subscriptions.asNoTracking().where((s) => s.eventKey.eq(eventKey));
|
|
147
|
-
waits = correlation === null ? waits.where((s) => s.correlation.isNull()) : waits.where((s) => s.correlation.isNull().or(s.correlation.eq(correlation)));
|
|
148
|
-
if (partition !== null) waits = waits.where((s) => s.partition.eq(partition));
|
|
149
|
-
const continued = new Set<string>();
|
|
150
|
-
for (const wait of await waits.orderBy((s) => s.id).toList()) {
|
|
151
|
-
const moved = await this.engine.inStep(wait.instanceId, async (step) => {
|
|
152
|
-
await this.db.subscriptions.asNoTracking().where((s) => s.id.eq(wait.id)).executeDelete();
|
|
153
|
-
return await this.engine.eventReceived(step, wait.tokenId, eventKey, data);
|
|
154
|
-
});
|
|
155
|
-
if (moved === true) continued.add(wait.instanceId);
|
|
156
|
-
}
|
|
141
|
+
return await this.delivery.emit(messageOf(eventKey, payload, options));
|
|
142
|
+
}
|
|
157
143
|
|
|
158
|
-
|
|
159
|
-
const
|
|
160
|
-
const
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
if (node.kind !== "eventStart" || node.config?.event !== eventKey) continue;
|
|
164
|
-
// An event carries more than a process needs: fields the input does not declare are dropped, not refused.
|
|
165
|
-
const { value, issues: all } = await this.validator().validate(inputSpecOf(version.document), data, { purpose: "input", processKey: version.key, partition, language: this.languages.default, actor });
|
|
166
|
-
const issues = all.filter((issue) => issue.code !== "unknown");
|
|
167
|
-
if (issues.length > 0) {
|
|
168
|
-
rejected.push({ processKey: version.key, issues });
|
|
169
|
-
continue;
|
|
170
|
-
}
|
|
171
|
-
const begun = await this.engine.begin(this.engine.cache(version), nodeId, { input: value, partition, businessKey: correlation, startedVia: "event", actor, request: options.request ?? null, event: { key: eventKey, payload: data } });
|
|
172
|
-
if (begun.skipped) skipped.push({ processKey: version.key, instanceId: begun.id });
|
|
173
|
-
else started.push(begun.id);
|
|
174
|
-
}
|
|
175
|
-
}
|
|
176
|
-
return { started, skipped, continued: [...continued], rejected };
|
|
144
|
+
async emitLater(tx: OrmTransaction, eventKey: string, payload: unknown = {}, options: EmitOptions = {}): Promise<void> {
|
|
145
|
+
const message = messageOf(eventKey, payload, options);
|
|
146
|
+
const now = this.engine.now();
|
|
147
|
+
const job = Object.assign(new WfJob(), { id: Bun.randomUUIDv7(), kind: "event" as const, partition: message.partition, dueAt: now, payload: message, createdAt: now });
|
|
148
|
+
await tx.use(this.db, (db) => db.jobs.insertIfAbsent(job, { conflictBy: (j) => [j.id] }));
|
|
177
149
|
}
|
|
178
150
|
|
|
179
151
|
async instances(query: InstanceListQuery = {}): Promise<WorkflowPage<InstanceSummary>> {
|
|
@@ -214,6 +186,23 @@ export class WorkflowRuntime extends IWorkflowRuntime {
|
|
|
214
186
|
}
|
|
215
187
|
}
|
|
216
188
|
|
|
189
|
+
/** An event as the delivery takes it; what cannot be delivered is refused here. */
|
|
190
|
+
function messageOf(eventKey: string, payload: unknown, options: EmitOptions): EventMessage {
|
|
191
|
+
if (typeof eventKey !== "string" || !EVENT_KEY.test(eventKey)) throw new WorkflowError("invalid_input", "eventKey must look like payments.received");
|
|
192
|
+
if (typeof payload !== "object" || payload === null || Array.isArray(payload)) throw new WorkflowError("invalid_input", "payload must be a JSON object");
|
|
193
|
+
const actor = options.actor ?? { kind: "system" as const, id: `event:${eventKey}` };
|
|
194
|
+
actorOf(actor);
|
|
195
|
+
return {
|
|
196
|
+
eventKey,
|
|
197
|
+
payload: payload as Record<string, unknown>,
|
|
198
|
+
correlation: optionalText(options.correlation, "correlation"),
|
|
199
|
+
partition: optionalText(options.partition, "partition"),
|
|
200
|
+
actor,
|
|
201
|
+
request: options.request ?? null,
|
|
202
|
+
cause: options.cause ?? null,
|
|
203
|
+
};
|
|
204
|
+
}
|
|
205
|
+
|
|
217
206
|
function notFound(instanceId: string): WorkflowError {
|
|
218
207
|
return new WorkflowError("not_found", `Instance ${instanceId} does not exist`);
|
|
219
208
|
}
|