@rex0220/ksql-flownet 1.0.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/LICENSE +21 -0
- package/README.ja.md +81 -0
- package/README.md +117 -0
- package/dist/bundle/bundle-builder.d.ts +49 -0
- package/dist/bundle/bundle-builder.js +208 -0
- package/dist/bundle/index.d.ts +4 -0
- package/dist/bundle/index.js +4 -0
- package/dist/bundle/upload.d.ts +28 -0
- package/dist/bundle/upload.js +72 -0
- package/dist/bundle/zip-store.d.ts +17 -0
- package/dist/bundle/zip-store.js +175 -0
- package/dist/cli/archive-run-command.d.ts +12 -0
- package/dist/cli/archive-run-command.js +131 -0
- package/dist/cli/cancel-run-command.d.ts +9 -0
- package/dist/cli/cancel-run-command.js +77 -0
- package/dist/cli/force-unlock-network-command.d.ts +8 -0
- package/dist/cli/force-unlock-network-command.js +129 -0
- package/dist/cli/index.d.ts +3 -0
- package/dist/cli/index.js +132 -0
- package/dist/cli/plan-command.d.ts +2 -0
- package/dist/cli/plan-command.js +100 -0
- package/dist/cli/poll-requests-command.d.ts +3 -0
- package/dist/cli/poll-requests-command.js +74 -0
- package/dist/cli/record-job-unlock-command.d.ts +11 -0
- package/dist/cli/record-job-unlock-command.js +78 -0
- package/dist/cli/resolve-node-command.d.ts +13 -0
- package/dist/cli/resolve-node-command.js +174 -0
- package/dist/cli/run-network-command.d.ts +14 -0
- package/dist/cli/run-network-command.js +418 -0
- package/dist/cli/status-command.d.ts +5 -0
- package/dist/cli/status-command.js +137 -0
- package/dist/cli/validate-command.d.ts +6 -0
- package/dist/cli/validate-command.js +17 -0
- package/dist/dag/descendants.d.ts +9 -0
- package/dist/dag/descendants.js +67 -0
- package/dist/dag/topological-sort.d.ts +7 -0
- package/dist/dag/topological-sort.js +32 -0
- package/dist/domain/business-key.d.ts +15 -0
- package/dist/domain/business-key.js +249 -0
- package/dist/domain/canonical-lock-key.d.ts +17 -0
- package/dist/domain/canonical-lock-key.js +60 -0
- package/dist/domain/canonical-record-key.d.ts +18 -0
- package/dist/domain/canonical-record-key.js +70 -0
- package/dist/domain/load-network.d.ts +4 -0
- package/dist/domain/load-network.js +47 -0
- package/dist/domain/network-definition.d.ts +47 -0
- package/dist/domain/network-definition.js +7 -0
- package/dist/domain/persistence-model.d.ts +198 -0
- package/dist/domain/persistence-model.js +2 -0
- package/dist/domain/run-aggregate.d.ts +3 -0
- package/dist/domain/run-aggregate.js +15 -0
- package/dist/domain/validate-network-path.d.ts +11 -0
- package/dist/domain/validate-network-path.js +33 -0
- package/dist/domain/validate-network.d.ts +3 -0
- package/dist/domain/validate-network.js +180 -0
- package/dist/executor/attempt-executor.d.ts +43 -0
- package/dist/executor/attempt-executor.js +236 -0
- package/dist/executor/job-log-reader.d.ts +37 -0
- package/dist/executor/job-log-reader.js +91 -0
- package/dist/executor/ksql-flow-cli.d.ts +86 -0
- package/dist/executor/ksql-flow-cli.js +161 -0
- package/dist/executor/preflight.d.ts +41 -0
- package/dist/executor/preflight.js +150 -0
- package/dist/executor/result-classifier.d.ts +69 -0
- package/dist/executor/result-classifier.js +237 -0
- package/dist/executor/run-subprocess.d.ts +64 -0
- package/dist/executor/run-subprocess.js +175 -0
- package/dist/io/input-baseline.d.ts +19 -0
- package/dist/io/input-baseline.js +214 -0
- package/dist/io/io-config.d.ts +6 -0
- package/dist/io/io-config.js +33 -0
- package/dist/io/io-path.d.ts +37 -0
- package/dist/io/io-path.js +279 -0
- package/dist/orchestration/archive-run.d.ts +53 -0
- package/dist/orchestration/archive-run.js +323 -0
- package/dist/orchestration/cancel-request.d.ts +18 -0
- package/dist/orchestration/cancel-request.js +67 -0
- package/dist/orchestration/ensure-run.d.ts +83 -0
- package/dist/orchestration/ensure-run.js +632 -0
- package/dist/orchestration/job-lock-audit.d.ts +24 -0
- package/dist/orchestration/job-lock-audit.js +123 -0
- package/dist/orchestration/orphan-attempt-adjudication.d.ts +39 -0
- package/dist/orchestration/orphan-attempt-adjudication.js +167 -0
- package/dist/orchestration/reconciliation.d.ts +41 -0
- package/dist/orchestration/reconciliation.js +526 -0
- package/dist/orchestration/resolve-node.d.ts +29 -0
- package/dist/orchestration/resolve-node.js +105 -0
- package/dist/orchestration/run-activity.d.ts +13 -0
- package/dist/orchestration/run-activity.js +14 -0
- package/dist/orchestration/sequential-scheduler.d.ts +79 -0
- package/dist/orchestration/sequential-scheduler.js +706 -0
- package/dist/orchestration/status.d.ts +91 -0
- package/dist/orchestration/status.js +171 -0
- package/dist/persistence/in-memory-repository.d.ts +41 -0
- package/dist/persistence/in-memory-repository.js +333 -0
- package/dist/persistence/kintone/client.d.ts +36 -0
- package/dist/persistence/kintone/client.js +93 -0
- package/dist/persistence/kintone/design-notes.d.ts +38 -0
- package/dist/persistence/kintone/design-notes.js +38 -0
- package/dist/persistence/kintone/repository.d.ts +44 -0
- package/dist/persistence/kintone/repository.js +836 -0
- package/dist/persistence/kintone/schema.d.ts +116 -0
- package/dist/persistence/kintone/schema.js +115 -0
- package/dist/persistence/network-lock-reader.d.ts +24 -0
- package/dist/persistence/network-lock-reader.js +43 -0
- package/dist/persistence/network-lock-recovery.d.ts +66 -0
- package/dist/persistence/network-lock-recovery.js +392 -0
- package/dist/persistence/network-lock.d.ts +97 -0
- package/dist/persistence/network-lock.js +408 -0
- package/dist/persistence/repository.d.ts +101 -0
- package/dist/persistence/repository.js +11 -0
- package/dist/persistence/state-transition.d.ts +4 -0
- package/dist/persistence/state-transition.js +25 -0
- package/dist/requests/flownet-child-client.d.ts +78 -0
- package/dist/requests/flownet-child-client.js +243 -0
- package/dist/requests/kintone-request-store.d.ts +44 -0
- package/dist/requests/kintone-request-store.js +202 -0
- package/dist/requests/poll-requests-config.d.ts +26 -0
- package/dist/requests/poll-requests-config.js +105 -0
- package/dist/requests/request-model.d.ts +57 -0
- package/dist/requests/request-model.js +305 -0
- package/dist/requests/request-poller.d.ts +33 -0
- package/dist/requests/request-poller.js +353 -0
- package/dist/requests/request-result.d.ts +18 -0
- package/dist/requests/request-result.js +215 -0
- package/dist/requests/start-request.d.ts +26 -0
- package/dist/requests/start-request.js +83 -0
- package/package.json +63 -0
- package/schemas/.gitkeep +1 -0
- package/schemas/network-definition.schema.json +121 -0
|
@@ -0,0 +1,305 @@
|
|
|
1
|
+
export const REQUEST_TYPES = [
|
|
2
|
+
"RERUN",
|
|
3
|
+
"STOP",
|
|
4
|
+
"RELEASE",
|
|
5
|
+
"START",
|
|
6
|
+
"CLOSE",
|
|
7
|
+
];
|
|
8
|
+
export const REQUEST_STATES = [
|
|
9
|
+
"REQUESTED",
|
|
10
|
+
"ACCEPTED",
|
|
11
|
+
"DONE",
|
|
12
|
+
"REJECTED",
|
|
13
|
+
"CANCELLED",
|
|
14
|
+
];
|
|
15
|
+
export const REQUEST_VALUE_LIMITS = {
|
|
16
|
+
runId: 128,
|
|
17
|
+
networkId: 128,
|
|
18
|
+
businessKey: 128,
|
|
19
|
+
rerunFromNode: 128,
|
|
20
|
+
reason: 65_535,
|
|
21
|
+
creatorCode: 256,
|
|
22
|
+
claimedHost: 256,
|
|
23
|
+
resultCode: 128,
|
|
24
|
+
resultMessage: 65_535,
|
|
25
|
+
};
|
|
26
|
+
export class RequestValidationError extends Error {
|
|
27
|
+
issues;
|
|
28
|
+
constructor(issues) {
|
|
29
|
+
super(issues.map(({ code, field }) => `${field}:${code}`).join(", "));
|
|
30
|
+
this.name = "RequestValidationError";
|
|
31
|
+
this.issues = issues;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
function rawValue(record, field) {
|
|
35
|
+
return record[field]?.value;
|
|
36
|
+
}
|
|
37
|
+
function stringValue(record, field) {
|
|
38
|
+
const value = rawValue(record, field);
|
|
39
|
+
if (typeof value !== "string") {
|
|
40
|
+
throw new RequestValidationError([
|
|
41
|
+
{ code: "FIELD_TYPE_INVALID", field, message: "must be a string" },
|
|
42
|
+
]);
|
|
43
|
+
}
|
|
44
|
+
return value;
|
|
45
|
+
}
|
|
46
|
+
function optionalString(record, field) {
|
|
47
|
+
const value = stringValue(record, field);
|
|
48
|
+
return value === "" ? null : value;
|
|
49
|
+
}
|
|
50
|
+
function additiveOptionalString(record, field) {
|
|
51
|
+
if (!Object.hasOwn(record, field))
|
|
52
|
+
return null;
|
|
53
|
+
return optionalString(record, field);
|
|
54
|
+
}
|
|
55
|
+
function creatorCode(record) {
|
|
56
|
+
const value = rawValue(record, "作成者");
|
|
57
|
+
if (typeof value !== "object" ||
|
|
58
|
+
value === null ||
|
|
59
|
+
!("code" in value) ||
|
|
60
|
+
typeof value.code !== "string") {
|
|
61
|
+
throw new RequestValidationError([
|
|
62
|
+
{
|
|
63
|
+
code: "CREATOR_INVALID",
|
|
64
|
+
field: "作成者",
|
|
65
|
+
message: "creator code is missing",
|
|
66
|
+
},
|
|
67
|
+
]);
|
|
68
|
+
}
|
|
69
|
+
return value.code;
|
|
70
|
+
}
|
|
71
|
+
function cancelRequested(record) {
|
|
72
|
+
if (!Object.hasOwn(record, "cancel_requested"))
|
|
73
|
+
return false;
|
|
74
|
+
const value = rawValue(record, "cancel_requested");
|
|
75
|
+
if (!Array.isArray(value) ||
|
|
76
|
+
value.length > 1 ||
|
|
77
|
+
value.some((item) => item !== "取消")) {
|
|
78
|
+
throw new RequestValidationError([
|
|
79
|
+
{
|
|
80
|
+
code: "FIELD_TYPE_INVALID",
|
|
81
|
+
field: "cancel_requested",
|
|
82
|
+
message: 'must be [] or ["取消"]',
|
|
83
|
+
},
|
|
84
|
+
]);
|
|
85
|
+
}
|
|
86
|
+
return value.length === 1;
|
|
87
|
+
}
|
|
88
|
+
function choice(value, allowed, field) {
|
|
89
|
+
if (!allowed.includes(value)) {
|
|
90
|
+
throw new RequestValidationError([
|
|
91
|
+
{
|
|
92
|
+
code: "UNKNOWN_CHOICE",
|
|
93
|
+
field,
|
|
94
|
+
message: `unsupported value: ${value}`,
|
|
95
|
+
},
|
|
96
|
+
]);
|
|
97
|
+
}
|
|
98
|
+
return value;
|
|
99
|
+
}
|
|
100
|
+
function isIsoDateTime(value) {
|
|
101
|
+
if (!/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{3})?Z$/.test(value)) {
|
|
102
|
+
return false;
|
|
103
|
+
}
|
|
104
|
+
const milliseconds = Date.parse(value);
|
|
105
|
+
if (Number.isNaN(milliseconds))
|
|
106
|
+
return false;
|
|
107
|
+
const canonical = new Date(milliseconds).toISOString();
|
|
108
|
+
return value === canonical || value === canonical.replace(".000Z", "Z");
|
|
109
|
+
}
|
|
110
|
+
function addLengthIssue(issues, field, value, maximum) {
|
|
111
|
+
if (value !== null && Array.from(value).length > maximum) {
|
|
112
|
+
issues.push({
|
|
113
|
+
code: "VALUE_TOO_LONG",
|
|
114
|
+
field,
|
|
115
|
+
message: `must be at most ${maximum} Unicode characters`,
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
export function validateRequestRecord(record) {
|
|
120
|
+
const issues = [];
|
|
121
|
+
if (!/^\d+$/.test(record.id)) {
|
|
122
|
+
issues.push({
|
|
123
|
+
code: "ID_INVALID",
|
|
124
|
+
field: "$id",
|
|
125
|
+
message: "must be decimal",
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
if (!Number.isSafeInteger(record.revision) || record.revision < 1) {
|
|
129
|
+
issues.push({
|
|
130
|
+
code: "REVISION_INVALID",
|
|
131
|
+
field: "$revision",
|
|
132
|
+
message: "must be a positive integer",
|
|
133
|
+
});
|
|
134
|
+
}
|
|
135
|
+
if (!isIsoDateTime(record.createdAt)) {
|
|
136
|
+
issues.push({
|
|
137
|
+
code: "DATETIME_INVALID",
|
|
138
|
+
field: "作成日時",
|
|
139
|
+
message: "must be an ISO UTC datetime",
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
if (record.requestType !== "START" && record.runId.trim() === "") {
|
|
143
|
+
issues.push({
|
|
144
|
+
code: "REQUIRED",
|
|
145
|
+
field: "run_id",
|
|
146
|
+
message: "must not be blank",
|
|
147
|
+
});
|
|
148
|
+
}
|
|
149
|
+
if (record.reason.trim() === "") {
|
|
150
|
+
issues.push({
|
|
151
|
+
code: "REQUIRED",
|
|
152
|
+
field: "reason",
|
|
153
|
+
message: "must not be blank",
|
|
154
|
+
});
|
|
155
|
+
}
|
|
156
|
+
if (record.creatorCode.trim() === "") {
|
|
157
|
+
issues.push({
|
|
158
|
+
code: "REQUIRED",
|
|
159
|
+
field: "作成者",
|
|
160
|
+
message: "must not be blank",
|
|
161
|
+
});
|
|
162
|
+
}
|
|
163
|
+
addLengthIssue(issues, "run_id", record.runId, REQUEST_VALUE_LIMITS.runId);
|
|
164
|
+
addLengthIssue(issues, "network_id", record.networkId, REQUEST_VALUE_LIMITS.networkId);
|
|
165
|
+
addLengthIssue(issues, "business_key", record.businessKey, REQUEST_VALUE_LIMITS.businessKey);
|
|
166
|
+
addLengthIssue(issues, "rerun_from_node", record.rerunFromNode, REQUEST_VALUE_LIMITS.rerunFromNode);
|
|
167
|
+
addLengthIssue(issues, "reason", record.reason, REQUEST_VALUE_LIMITS.reason);
|
|
168
|
+
addLengthIssue(issues, "作成者", record.creatorCode, REQUEST_VALUE_LIMITS.creatorCode);
|
|
169
|
+
addLengthIssue(issues, "claimed_host", record.claimedHost, REQUEST_VALUE_LIMITS.claimedHost);
|
|
170
|
+
addLengthIssue(issues, "result_code", record.resultCode, REQUEST_VALUE_LIMITS.resultCode);
|
|
171
|
+
addLengthIssue(issues, "result_message", record.resultMessage, REQUEST_VALUE_LIMITS.resultMessage);
|
|
172
|
+
if (record.requestType !== "RERUN" && record.rerunFromNode !== null) {
|
|
173
|
+
issues.push({
|
|
174
|
+
code: "FIELD_NOT_ALLOWED",
|
|
175
|
+
field: "rerun_from_node",
|
|
176
|
+
message: "is only allowed for RERUN",
|
|
177
|
+
});
|
|
178
|
+
}
|
|
179
|
+
const claimValues = [
|
|
180
|
+
record.claimedAt,
|
|
181
|
+
record.claimedHost,
|
|
182
|
+
record.claimHeartbeatAt,
|
|
183
|
+
];
|
|
184
|
+
const claimCount = claimValues.filter((value) => value !== null).length;
|
|
185
|
+
if (claimCount !== 0 && claimCount !== claimValues.length) {
|
|
186
|
+
issues.push({
|
|
187
|
+
code: "CLAIM_FIELDS_INCOMPLETE",
|
|
188
|
+
field: "claimed_at",
|
|
189
|
+
message: "claim fields must be all empty or all populated",
|
|
190
|
+
});
|
|
191
|
+
}
|
|
192
|
+
if (record.claimedHost !== null && record.claimedHost.trim() === "") {
|
|
193
|
+
issues.push({
|
|
194
|
+
code: "REQUIRED",
|
|
195
|
+
field: "claimed_host",
|
|
196
|
+
message: "must not be blank when populated",
|
|
197
|
+
});
|
|
198
|
+
}
|
|
199
|
+
for (const [field, value] of [
|
|
200
|
+
["claimed_at", record.claimedAt],
|
|
201
|
+
["claim_heartbeat_at", record.claimHeartbeatAt],
|
|
202
|
+
]) {
|
|
203
|
+
if (value !== null && !isIsoDateTime(value)) {
|
|
204
|
+
issues.push({
|
|
205
|
+
code: "DATETIME_INVALID",
|
|
206
|
+
field,
|
|
207
|
+
message: "must be ISO UTC",
|
|
208
|
+
});
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
const hasResult = record.resultCode !== null || record.resultMessage !== null;
|
|
212
|
+
if (record.requestState === "REQUESTED") {
|
|
213
|
+
if (claimCount !== 0 || hasResult) {
|
|
214
|
+
issues.push({
|
|
215
|
+
code: "STATE_FIELDS_INVALID",
|
|
216
|
+
field: "request_state",
|
|
217
|
+
message: "REQUESTED must not contain machine-owned values",
|
|
218
|
+
});
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
else if (record.requestState === "ACCEPTED") {
|
|
222
|
+
if (claimCount !== claimValues.length || hasResult) {
|
|
223
|
+
issues.push({
|
|
224
|
+
code: "STATE_FIELDS_INVALID",
|
|
225
|
+
field: "request_state",
|
|
226
|
+
message: "ACCEPTED requires claim fields and no result",
|
|
227
|
+
});
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
else if (record.resultCode === null || record.resultCode.trim() === "") {
|
|
231
|
+
issues.push({
|
|
232
|
+
code: "RESULT_REQUIRED",
|
|
233
|
+
field: "result_code",
|
|
234
|
+
message: "terminal requests require a result code",
|
|
235
|
+
});
|
|
236
|
+
}
|
|
237
|
+
if (record.requestState === "CANCELLED" && claimCount !== 0) {
|
|
238
|
+
issues.push({
|
|
239
|
+
code: "STATE_FIELDS_INVALID",
|
|
240
|
+
field: "request_state",
|
|
241
|
+
message: `${record.requestState} must not contain claim fields`,
|
|
242
|
+
});
|
|
243
|
+
}
|
|
244
|
+
return issues;
|
|
245
|
+
}
|
|
246
|
+
export function parseRequestEnvelope(record) {
|
|
247
|
+
const revisionText = stringValue(record, "$revision");
|
|
248
|
+
let cancellation;
|
|
249
|
+
try {
|
|
250
|
+
cancellation = cancelRequested(record);
|
|
251
|
+
}
|
|
252
|
+
catch {
|
|
253
|
+
cancellation = "INVALID";
|
|
254
|
+
}
|
|
255
|
+
const envelope = {
|
|
256
|
+
id: stringValue(record, "$id"),
|
|
257
|
+
revision: Number(revisionText),
|
|
258
|
+
requestState: choice(stringValue(record, "request_state"), REQUEST_STATES, "request_state"),
|
|
259
|
+
cancelRequested: cancellation,
|
|
260
|
+
};
|
|
261
|
+
const issues = [];
|
|
262
|
+
if (!/^\d+$/.test(envelope.id))
|
|
263
|
+
issues.push({
|
|
264
|
+
code: "ID_INVALID",
|
|
265
|
+
field: "$id",
|
|
266
|
+
message: "must be decimal",
|
|
267
|
+
});
|
|
268
|
+
if (!Number.isSafeInteger(envelope.revision) || envelope.revision < 1)
|
|
269
|
+
issues.push({
|
|
270
|
+
code: "REVISION_INVALID",
|
|
271
|
+
field: "$revision",
|
|
272
|
+
message: "must be a positive integer",
|
|
273
|
+
});
|
|
274
|
+
if (issues.length > 0)
|
|
275
|
+
throw new RequestValidationError(issues);
|
|
276
|
+
return envelope;
|
|
277
|
+
}
|
|
278
|
+
export function parseRequestRecord(record) {
|
|
279
|
+
const revisionText = stringValue(record, "$revision");
|
|
280
|
+
const parsed = {
|
|
281
|
+
id: stringValue(record, "$id"),
|
|
282
|
+
revision: Number(revisionText),
|
|
283
|
+
creatorCode: creatorCode(record),
|
|
284
|
+
createdAt: stringValue(record, "作成日時"),
|
|
285
|
+
requestType: choice(stringValue(record, "request_type"), REQUEST_TYPES, "request_type"),
|
|
286
|
+
runId: stringValue(record, "run_id"),
|
|
287
|
+
networkId: additiveOptionalString(record, "network_id"),
|
|
288
|
+
businessKey: additiveOptionalString(record, "business_key"),
|
|
289
|
+
scheduledFor: additiveOptionalString(record, "scheduled_for"),
|
|
290
|
+
rerunFromNode: optionalString(record, "rerun_from_node"),
|
|
291
|
+
reason: stringValue(record, "reason"),
|
|
292
|
+
requestState: choice(stringValue(record, "request_state"), REQUEST_STATES, "request_state"),
|
|
293
|
+
cancelRequested: cancelRequested(record),
|
|
294
|
+
claimedAt: optionalString(record, "claimed_at"),
|
|
295
|
+
claimedHost: optionalString(record, "claimed_host"),
|
|
296
|
+
claimHeartbeatAt: optionalString(record, "claim_heartbeat_at"),
|
|
297
|
+
resultCode: optionalString(record, "result_code"),
|
|
298
|
+
resultMessage: optionalString(record, "result_message"),
|
|
299
|
+
};
|
|
300
|
+
const issues = validateRequestRecord(parsed);
|
|
301
|
+
if (issues.length > 0)
|
|
302
|
+
throw new RequestValidationError(issues);
|
|
303
|
+
return parsed;
|
|
304
|
+
}
|
|
305
|
+
//# sourceMappingURL=request-model.js.map
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import type { StatusOutput, RunStatusOutput } from "../orchestration/status.js";
|
|
2
|
+
import { type FlownetChildClient } from "./flownet-child-client.js";
|
|
3
|
+
import type { KintoneRequestStore, RequestResult } from "./kintone-request-store.js";
|
|
4
|
+
import type { PollRequestsConfig, PollRequestsNetwork } from "./poll-requests-config.js";
|
|
5
|
+
import type { RequestRecord } from "./request-model.js";
|
|
6
|
+
export interface RequestPollerDependencies {
|
|
7
|
+
readonly store: Pick<KintoneRequestStore, "listRequested" | "listAccepted" | "rejectInvalid" | "cancelBeforeClaim" | "claim" | "heartbeat" | "getById" | "writeResult">;
|
|
8
|
+
readonly child: Pick<FlownetChildClient, "status" | "runNetwork" | "startNetwork" | "cancelRun" | "archiveRun">;
|
|
9
|
+
readonly config: PollRequestsConfig;
|
|
10
|
+
readonly host: string;
|
|
11
|
+
readonly now?: () => Date;
|
|
12
|
+
readonly wait?: (milliseconds: number) => Promise<void>;
|
|
13
|
+
readonly log?: (code: string, detail: string) => void;
|
|
14
|
+
}
|
|
15
|
+
export interface PollRequestsSummary {
|
|
16
|
+
readonly requested: number;
|
|
17
|
+
readonly claimed: number;
|
|
18
|
+
readonly completed: number;
|
|
19
|
+
readonly cancelled: number;
|
|
20
|
+
readonly invalid: number;
|
|
21
|
+
readonly skippedMalformed: number;
|
|
22
|
+
readonly stale: number;
|
|
23
|
+
}
|
|
24
|
+
interface ResolvedRun {
|
|
25
|
+
readonly network: PollRequestsNetwork;
|
|
26
|
+
readonly status: StatusOutput;
|
|
27
|
+
readonly run: RunStatusOutput;
|
|
28
|
+
}
|
|
29
|
+
export declare function pollRequests(dependencies: RequestPollerDependencies): Promise<PollRequestsSummary>;
|
|
30
|
+
export declare function reviewRequest(request: Pick<RequestRecord, "requestType">, resolved: Pick<ResolvedRun, "status" | "run">, nowMs: number, leaseAllowanceMs: number): RequestResult | null;
|
|
31
|
+
export declare function hasLiveOwner(status: StatusOutput, run: RunStatusOutput, nowMs: number, leaseAllowanceMs: number): boolean;
|
|
32
|
+
export {};
|
|
33
|
+
//# sourceMappingURL=request-poller.d.ts.map
|
|
@@ -0,0 +1,353 @@
|
|
|
1
|
+
import { requestedBy, } from "./flownet-child-client.js";
|
|
2
|
+
import { classifyCancelResult, classifyArchiveRun, classifyRunNetworkResult, classifyStartNetworkResult, rejected, } from "./request-result.js";
|
|
3
|
+
import { prepareStartRequest, startNetworkNotAllowed, } from "./start-request.js";
|
|
4
|
+
export async function pollRequests(dependencies) {
|
|
5
|
+
const now = dependencies.now ?? (() => new Date());
|
|
6
|
+
const log = dependencies.log ?? (() => undefined);
|
|
7
|
+
const listed = await dependencies.store.listRequested();
|
|
8
|
+
// Establish the REQUESTED read boundary before performing any write. A failed
|
|
9
|
+
// primary GET must leave both requests and stale candidates untouched.
|
|
10
|
+
const stale = await recoverStale(dependencies, now, log);
|
|
11
|
+
if (listed.skipped > 0) {
|
|
12
|
+
log("REQUEST_RECORD_UNIDENTIFIABLE", `count=${listed.skipped}`);
|
|
13
|
+
}
|
|
14
|
+
let cancelled = 0;
|
|
15
|
+
for (const invalid of listed.invalid) {
|
|
16
|
+
if (invalid.cancelRequested === true) {
|
|
17
|
+
if (await cancelBeforeClaim(dependencies, invalid, log))
|
|
18
|
+
cancelled += 1;
|
|
19
|
+
continue;
|
|
20
|
+
}
|
|
21
|
+
await rejectInvalid(dependencies, invalid);
|
|
22
|
+
}
|
|
23
|
+
let claimedCount = 0;
|
|
24
|
+
let completed = 0;
|
|
25
|
+
for (const request of listed.valid) {
|
|
26
|
+
if (request.cancelRequested) {
|
|
27
|
+
if (await cancelBeforeClaim(dependencies, request, log))
|
|
28
|
+
cancelled += 1;
|
|
29
|
+
continue;
|
|
30
|
+
}
|
|
31
|
+
const claimed = await dependencies.store.claim(request, dependencies.host, now().toISOString());
|
|
32
|
+
if (claimed === null)
|
|
33
|
+
continue;
|
|
34
|
+
claimedCount += 1;
|
|
35
|
+
const result = await processClaimed(dependencies, claimed, now, log);
|
|
36
|
+
if (await finalizeResult(dependencies, result.request, result.result, log))
|
|
37
|
+
completed += 1;
|
|
38
|
+
}
|
|
39
|
+
return {
|
|
40
|
+
requested: listed.valid.length,
|
|
41
|
+
claimed: claimedCount,
|
|
42
|
+
completed,
|
|
43
|
+
cancelled,
|
|
44
|
+
invalid: listed.invalid.length,
|
|
45
|
+
skippedMalformed: listed.skipped,
|
|
46
|
+
stale,
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
async function cancelBeforeClaim(dependencies, request, log) {
|
|
50
|
+
const cancelled = await dependencies.store.cancelBeforeClaim(request, {
|
|
51
|
+
state: "CANCELLED",
|
|
52
|
+
code: "CANCELLED_BY_REQUESTER",
|
|
53
|
+
message: "requester cancelled before claim",
|
|
54
|
+
});
|
|
55
|
+
if (!cancelled)
|
|
56
|
+
log("CANCEL_FINALIZE_CONFLICT", `request_id=${request.id}`);
|
|
57
|
+
return cancelled;
|
|
58
|
+
}
|
|
59
|
+
async function finalizeResult(dependencies, request, result, log) {
|
|
60
|
+
let current;
|
|
61
|
+
try {
|
|
62
|
+
current = await dependencies.store.getById(request.id);
|
|
63
|
+
}
|
|
64
|
+
catch {
|
|
65
|
+
log("RESULT_FINALIZE_ABANDONED", `request_id=${request.id}`);
|
|
66
|
+
return false;
|
|
67
|
+
}
|
|
68
|
+
if (current?.requestState !== "ACCEPTED") {
|
|
69
|
+
log("RESULT_STATE_MISMATCH", `request_id=${request.id}`);
|
|
70
|
+
return false;
|
|
71
|
+
}
|
|
72
|
+
const finalized = current.cancelRequested
|
|
73
|
+
? { ...result, message: `${result.message} (cancel_ignored)` }
|
|
74
|
+
: result;
|
|
75
|
+
try {
|
|
76
|
+
await dependencies.store.writeResult(current, finalized);
|
|
77
|
+
return true;
|
|
78
|
+
}
|
|
79
|
+
catch {
|
|
80
|
+
log("RESULT_FINALIZE_ABANDONED", `request_id=${request.id}`);
|
|
81
|
+
return false;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
async function rejectInvalid(dependencies, invalid) {
|
|
85
|
+
const codes = [...new Set(invalid.issues.map(({ code }) => code))].sort();
|
|
86
|
+
await dependencies.store.rejectInvalid(invalid, {
|
|
87
|
+
state: "REJECTED",
|
|
88
|
+
code: "REQUEST_INVALID",
|
|
89
|
+
message: `request validation failed: ${codes.join(",")}`,
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
async function processClaimed(dependencies, claimed, now, log) {
|
|
93
|
+
if (claimed.requestType === "START") {
|
|
94
|
+
return processStart(dependencies, claimed, now, log);
|
|
95
|
+
}
|
|
96
|
+
try {
|
|
97
|
+
requestedBy(claimed);
|
|
98
|
+
}
|
|
99
|
+
catch {
|
|
100
|
+
return {
|
|
101
|
+
request: claimed,
|
|
102
|
+
result: rejected("REQUESTED_BY_INVALID", "Request creator correlation is invalid or too long"),
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
let resolved;
|
|
106
|
+
try {
|
|
107
|
+
const matches = await resolveRun(dependencies, claimed.runId);
|
|
108
|
+
if (matches.length === 0) {
|
|
109
|
+
return {
|
|
110
|
+
request: claimed,
|
|
111
|
+
result: rejected("RUN_NOT_FOUND", "Run was not found in the allowlist"),
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
if (matches.length > 1) {
|
|
115
|
+
return {
|
|
116
|
+
request: claimed,
|
|
117
|
+
result: rejected("RUN_ID_AMBIGUOUS", "Run ID matched multiple allowed networks"),
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
resolved = matches[0];
|
|
121
|
+
}
|
|
122
|
+
catch {
|
|
123
|
+
return {
|
|
124
|
+
request: claimed,
|
|
125
|
+
result: rejected("STATUS_UNAVAILABLE", "Run status could not be verified"),
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
const review = reviewRequest(claimed, resolved, now().getTime(), dependencies.config.stalePrecisionAllowanceMs);
|
|
129
|
+
if (review !== null)
|
|
130
|
+
return { request: claimed, result: review };
|
|
131
|
+
switch (claimed.requestType) {
|
|
132
|
+
case "RERUN":
|
|
133
|
+
return withHeartbeat(dependencies, claimed, () => dependencies.child.runNetwork(resolved.network, claimed), classifyRunNetworkResult, now, log);
|
|
134
|
+
case "STOP":
|
|
135
|
+
case "RELEASE":
|
|
136
|
+
return withHeartbeat(dependencies, claimed, () => dependencies.child.cancelRun(claimed, claimed.requestType === "RELEASE"), (result) => classifyCancelResult(result, claimed.requestType === "RELEASE"), now, log);
|
|
137
|
+
case "CLOSE":
|
|
138
|
+
return withHeartbeat(dependencies, claimed, () => dependencies.child.archiveRun(resolved.network, claimed), classifyArchiveRun, now, log);
|
|
139
|
+
default: {
|
|
140
|
+
const exhaustive = claimed.requestType;
|
|
141
|
+
return exhaustive;
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
async function processStart(dependencies, claimed, now, log) {
|
|
146
|
+
if (claimed.runId.trim() !== "") {
|
|
147
|
+
return {
|
|
148
|
+
request: claimed,
|
|
149
|
+
result: rejected("RUN_ID_NOT_ALLOWED", "STARTではrun_idを指定できません。"),
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
const network = claimed.networkId === null
|
|
153
|
+
? undefined
|
|
154
|
+
: dependencies.config.networks.find(({ networkId }) => networkId === claimed.networkId);
|
|
155
|
+
if (network === undefined) {
|
|
156
|
+
return {
|
|
157
|
+
request: claimed,
|
|
158
|
+
result: startNetworkNotAllowed("NOT_IN_ALLOWLIST"),
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
if (!network.appStart) {
|
|
162
|
+
return {
|
|
163
|
+
request: claimed,
|
|
164
|
+
result: startNetworkNotAllowed("APP_START_DISABLED"),
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
let prepared = prepareStartRequest(network, claimed);
|
|
168
|
+
if (!prepared.ok)
|
|
169
|
+
return { request: claimed, result: prepared.result };
|
|
170
|
+
try {
|
|
171
|
+
requestedBy(claimed);
|
|
172
|
+
}
|
|
173
|
+
catch {
|
|
174
|
+
return {
|
|
175
|
+
request: claimed,
|
|
176
|
+
result: rejected("REQUESTED_BY_INVALID", "Request creator correlation is invalid or too long"),
|
|
177
|
+
};
|
|
178
|
+
}
|
|
179
|
+
// Minimize the definition gate-to-spawn window by re-reading immediately
|
|
180
|
+
// before constructing the child invocation.
|
|
181
|
+
prepared = prepareStartRequest(network, claimed);
|
|
182
|
+
if (!prepared.ok)
|
|
183
|
+
return { request: claimed, result: prepared.result };
|
|
184
|
+
const input = prepared.value.input;
|
|
185
|
+
return withHeartbeat(dependencies, claimed, () => dependencies.child.startNetwork(network, claimed, input), classifyStartNetworkResult, now, log);
|
|
186
|
+
}
|
|
187
|
+
async function withHeartbeat(dependencies, initial, start, classify, now, log) {
|
|
188
|
+
const child = start().then((value) => ({ kind: "done", value }), () => ({ kind: "failed" }));
|
|
189
|
+
let current = initial;
|
|
190
|
+
for (;;) {
|
|
191
|
+
const event = await waitForChildOrHeartbeat(child, dependencies.config.heartbeatIntervalMs, dependencies.wait);
|
|
192
|
+
if (event.kind === "done") {
|
|
193
|
+
return { request: current, result: classify(event.value) };
|
|
194
|
+
}
|
|
195
|
+
if (event.kind === "failed") {
|
|
196
|
+
return {
|
|
197
|
+
request: current,
|
|
198
|
+
result: rejected("CHILD_EXECUTION_FAILED", "child execution failed before a result was available"),
|
|
199
|
+
};
|
|
200
|
+
}
|
|
201
|
+
try {
|
|
202
|
+
current = await dependencies.store.heartbeat(current, now().toISOString());
|
|
203
|
+
}
|
|
204
|
+
catch {
|
|
205
|
+
log("CLAIM_HEARTBEAT_FAILED", `request_id=${current.id}`);
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
async function resolveRun(dependencies, runId) {
|
|
210
|
+
const matches = [];
|
|
211
|
+
for (const network of dependencies.config.networks) {
|
|
212
|
+
const status = await dependencies.child.status(network, runId);
|
|
213
|
+
if (status === null)
|
|
214
|
+
continue;
|
|
215
|
+
const matchingRuns = status.runs.filter((run) => run.run_id === runId);
|
|
216
|
+
for (const run of matchingRuns)
|
|
217
|
+
matches.push({ network, status, run });
|
|
218
|
+
}
|
|
219
|
+
return matches;
|
|
220
|
+
}
|
|
221
|
+
export function reviewRequest(request, resolved, nowMs, leaseAllowanceMs) {
|
|
222
|
+
const run = resolved.run;
|
|
223
|
+
const live = hasLiveOwner(resolved.status, run, nowMs, leaseAllowanceMs);
|
|
224
|
+
switch (request.requestType) {
|
|
225
|
+
case "RERUN":
|
|
226
|
+
if (!["CREATED", "RUNNING", "FAILED", "CANCELLED"].includes(run.status))
|
|
227
|
+
return rejected("RUN_STATUS_NOT_RERUNNABLE", `Run status ${run.status} is not rerunnable`);
|
|
228
|
+
if (!run.resume_allowed || run.lifecycle_status !== "ACTIVE")
|
|
229
|
+
return rejected("RUN_NOT_RESUMABLE", "Run is not active and resumable");
|
|
230
|
+
if (run.hold !== null || run.activity === "STOPPED")
|
|
231
|
+
return rejected("RUN_ON_HOLD", "Run is on hold");
|
|
232
|
+
if (run.activity === "LIVE" || live)
|
|
233
|
+
return rejected("RUN_LIVE", "Run has a live Invocation owner");
|
|
234
|
+
return null;
|
|
235
|
+
case "STOP":
|
|
236
|
+
if (["SUCCESS", "FAILED", "CANCELLED", "UNKNOWN"].includes(run.status))
|
|
237
|
+
return rejected("RUN_TERMINAL", `Run status ${run.status} is terminal`);
|
|
238
|
+
if (run.activity === "STOPPED")
|
|
239
|
+
return rejected("RUN_ALREADY_ON_HOLD", "Run is already on hold");
|
|
240
|
+
return null;
|
|
241
|
+
case "RELEASE":
|
|
242
|
+
return run.hold === null
|
|
243
|
+
? rejected("RUN_NOT_ON_HOLD", "Run is not on hold")
|
|
244
|
+
: null;
|
|
245
|
+
case "START":
|
|
246
|
+
return null;
|
|
247
|
+
case "CLOSE":
|
|
248
|
+
if (run.lifecycle_status === "ARCHIVED")
|
|
249
|
+
return {
|
|
250
|
+
state: "DONE",
|
|
251
|
+
code: "RUN_ALREADY_ARCHIVED",
|
|
252
|
+
message: "Run is already archived",
|
|
253
|
+
};
|
|
254
|
+
if (run.status === "SUCCESS")
|
|
255
|
+
return rejected("RUN_STATUS_NOT_CLOSABLE", "Successful Runs cannot be closed");
|
|
256
|
+
if (run.status === "UNKNOWN")
|
|
257
|
+
return rejected("RUN_UNKNOWN_NOT_CLOSABLE", "Unknown Runs cannot be closed");
|
|
258
|
+
if (run.status === "CREATED" || run.status === "RUNNING")
|
|
259
|
+
return rejected("RUN_NOT_TERMINAL", "Run is not terminal");
|
|
260
|
+
if (run.hold !== null)
|
|
261
|
+
return rejected("RUN_ON_HOLD", "Run is on hold");
|
|
262
|
+
if (live)
|
|
263
|
+
return rejected("RUN_LIVE", "Run has a live Invocation owner");
|
|
264
|
+
return null;
|
|
265
|
+
default: {
|
|
266
|
+
const exhaustive = request.requestType;
|
|
267
|
+
return exhaustive;
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
export function hasLiveOwner(status, run, nowMs, leaseAllowanceMs) {
|
|
272
|
+
if (status.lock === null)
|
|
273
|
+
return false;
|
|
274
|
+
const invocationIds = new Set((run.invocations ?? []).map(({ invocation_id }) => invocation_id));
|
|
275
|
+
return (invocationIds.has(status.lock.owner_invocation_id) &&
|
|
276
|
+
nowMs <= Date.parse(status.lock.lease_expires_at) + leaseAllowanceMs);
|
|
277
|
+
}
|
|
278
|
+
async function recoverStale(dependencies, now, log) {
|
|
279
|
+
const accepted = await dependencies.store.listAccepted();
|
|
280
|
+
let recovered = 0;
|
|
281
|
+
const nowMs = now().getTime();
|
|
282
|
+
for (const request of accepted) {
|
|
283
|
+
const heartbeat = request.claimHeartbeatAt;
|
|
284
|
+
if (heartbeat === null ||
|
|
285
|
+
nowMs <=
|
|
286
|
+
Date.parse(heartbeat) +
|
|
287
|
+
dependencies.config.staleAfterMs +
|
|
288
|
+
dependencies.config.stalePrecisionAllowanceMs) {
|
|
289
|
+
continue;
|
|
290
|
+
}
|
|
291
|
+
try {
|
|
292
|
+
if (request.requestType === "START") {
|
|
293
|
+
const recoveredStart = await recoverStaleStart(dependencies, request, nowMs);
|
|
294
|
+
if (!recoveredStart)
|
|
295
|
+
continue;
|
|
296
|
+
await dependencies.store.writeResult(request, rejected("STALE", "Execution and result are unknown; do not request another operation until Run and audit records are reconciled"));
|
|
297
|
+
recovered += 1;
|
|
298
|
+
continue;
|
|
299
|
+
}
|
|
300
|
+
const matches = await resolveRun(dependencies, request.runId);
|
|
301
|
+
if (matches.length !== 1)
|
|
302
|
+
continue;
|
|
303
|
+
const resolved = matches[0];
|
|
304
|
+
if (resolved.run.activity === "LIVE" ||
|
|
305
|
+
hasLiveOwner(resolved.status, resolved.run, nowMs, dependencies.config.stalePrecisionAllowanceMs)) {
|
|
306
|
+
continue;
|
|
307
|
+
}
|
|
308
|
+
await dependencies.store.writeResult(request, rejected("STALE", "Execution and result are unknown; do not request another operation until Run and audit records are reconciled"));
|
|
309
|
+
recovered += 1;
|
|
310
|
+
}
|
|
311
|
+
catch {
|
|
312
|
+
log("STALE_STATUS_UNAVAILABLE", `request_id=${request.id}`);
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
return recovered;
|
|
316
|
+
}
|
|
317
|
+
async function recoverStaleStart(dependencies, request, nowMs) {
|
|
318
|
+
if (request.networkId === null)
|
|
319
|
+
return false;
|
|
320
|
+
const network = dependencies.config.networks.find(({ networkId }) => networkId === request.networkId);
|
|
321
|
+
if (network === undefined)
|
|
322
|
+
return false;
|
|
323
|
+
const prepared = prepareStartRequest(network, request);
|
|
324
|
+
if (!prepared.ok)
|
|
325
|
+
return false;
|
|
326
|
+
const status = await dependencies.child.status(network, {
|
|
327
|
+
businessKey: prepared.value.businessKey,
|
|
328
|
+
});
|
|
329
|
+
if (status === null)
|
|
330
|
+
return false;
|
|
331
|
+
const matches = status.runs.filter(({ business_key }) => business_key === prepared.value.businessKey);
|
|
332
|
+
if (matches.length !== 1)
|
|
333
|
+
return false;
|
|
334
|
+
const run = matches[0];
|
|
335
|
+
return (run.activity !== "LIVE" &&
|
|
336
|
+
!hasLiveOwner(status, run, nowMs, dependencies.config.stalePrecisionAllowanceMs));
|
|
337
|
+
}
|
|
338
|
+
function waitForChildOrHeartbeat(child, milliseconds, customWait) {
|
|
339
|
+
if (customWait !== undefined) {
|
|
340
|
+
return Promise.race([
|
|
341
|
+
child,
|
|
342
|
+
customWait(milliseconds).then(() => ({ kind: "heartbeat" })),
|
|
343
|
+
]);
|
|
344
|
+
}
|
|
345
|
+
return new Promise((resolve) => {
|
|
346
|
+
const timer = setTimeout(() => resolve({ kind: "heartbeat" }), milliseconds);
|
|
347
|
+
void child.then((event) => {
|
|
348
|
+
clearTimeout(timer);
|
|
349
|
+
resolve(event);
|
|
350
|
+
});
|
|
351
|
+
});
|
|
352
|
+
}
|
|
353
|
+
//# sourceMappingURL=request-poller.js.map
|