@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,392 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { hostname } from "node:os";
|
|
3
|
+
import { networkLockKey } from "../domain/canonical-lock-key.js";
|
|
4
|
+
import { KintoneApiError, KintoneClient, KintoneTransportError, } from "./kintone/client.js";
|
|
5
|
+
import { KINTONE_DATETIME_TRUNCATION_MS } from "./kintone/design-notes.js";
|
|
6
|
+
import { releaseTombstoneRecordKey } from "./network-lock.js";
|
|
7
|
+
import { ownerInstanceIdFromStatusReason } from "./network-lock-reader.js";
|
|
8
|
+
export const manualStopConfirmation = {
|
|
9
|
+
async confirm(input) {
|
|
10
|
+
const confirmed = input.stopMethod === "manual" &&
|
|
11
|
+
input.stopConfirmedBy.trim() !== "" &&
|
|
12
|
+
input.stopEvidenceRef.trim() !== "";
|
|
13
|
+
return {
|
|
14
|
+
confirmed,
|
|
15
|
+
method: "manual",
|
|
16
|
+
detail: confirmed
|
|
17
|
+
? "manual stop evidence was provided"
|
|
18
|
+
: "manual stop confirmation fields are invalid",
|
|
19
|
+
};
|
|
20
|
+
},
|
|
21
|
+
};
|
|
22
|
+
const localPidPattern = /^local-pid:\/\/([^/]+)\/([^/]+)$/u;
|
|
23
|
+
const cloudRunExecutionPattern = /^projects\/[^/]+\/locations\/[^/]+\/jobs\/[^/]+\/executions\/[^/]+$/u;
|
|
24
|
+
const terminalCloudRunStates = new Set(["SUCCEEDED", "FAILED", "CANCELLED"]);
|
|
25
|
+
const nonTerminalCloudRunStates = new Set(["RUNNING", "PENDING"]);
|
|
26
|
+
function detailTime(now) {
|
|
27
|
+
return now().toISOString();
|
|
28
|
+
}
|
|
29
|
+
export function createLocalPidStopConfirmation(dependencies = {}) {
|
|
30
|
+
const host = dependencies.host ?? process.env.KSQL_FLOWNET_HOST ?? hostname();
|
|
31
|
+
const processKill = dependencies.processKill ?? ((pid, signal) => process.kill(pid, signal));
|
|
32
|
+
const now = dependencies.now ?? (() => new Date());
|
|
33
|
+
return {
|
|
34
|
+
async confirm(input) {
|
|
35
|
+
const match = localPidPattern.exec(input.ownerInstanceId);
|
|
36
|
+
if (match === null) {
|
|
37
|
+
return {
|
|
38
|
+
confirmed: false,
|
|
39
|
+
method: "local_pid",
|
|
40
|
+
detail: "owner_instance_id is not a valid local-pid resource",
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
const ownerHost = match[1];
|
|
44
|
+
const pidText = match[2];
|
|
45
|
+
if (ownerHost !== host) {
|
|
46
|
+
return {
|
|
47
|
+
confirmed: false,
|
|
48
|
+
method: "local_pid",
|
|
49
|
+
detail: `owner host '${ownerHost}' differs from current host '${host}'; use manual recovery because lease expiry is not stop confirmation`,
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
const pid = Number(pidText);
|
|
53
|
+
if (!/^[1-9]\d*$/u.test(pidText) || !Number.isSafeInteger(pid)) {
|
|
54
|
+
return {
|
|
55
|
+
confirmed: false,
|
|
56
|
+
method: "local_pid",
|
|
57
|
+
detail: "owner_instance_id PID is not a positive safe integer",
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
try {
|
|
61
|
+
processKill(pid, 0);
|
|
62
|
+
return {
|
|
63
|
+
confirmed: false,
|
|
64
|
+
method: "local_pid",
|
|
65
|
+
detail: `PID ${pid} exists on host '${host}'`,
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
catch (error) {
|
|
69
|
+
const code = errorCodeOf(error);
|
|
70
|
+
if (code === "ESRCH") {
|
|
71
|
+
return {
|
|
72
|
+
confirmed: true,
|
|
73
|
+
method: "local_pid",
|
|
74
|
+
detail: `PID ${pid} was absent at ${detailTime(now)}; PID reuse remains a residual risk`,
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
return {
|
|
78
|
+
confirmed: false,
|
|
79
|
+
method: "local_pid",
|
|
80
|
+
detail: code === "EPERM"
|
|
81
|
+
? `PID ${pid} may exist but permission was denied`
|
|
82
|
+
: `PID ${pid} existence check failed${code === undefined ? "" : ` (${code})`}`,
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
},
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
export function createCloudRunJobExecutionStopConfirmation(dependencies = {}) {
|
|
89
|
+
const fetchImplementation = dependencies.fetch ?? globalThis.fetch;
|
|
90
|
+
const accessToken = dependencies.gcpAccessToken ?? process.env.KSQL_FLOWNET_GCP_ACCESS_TOKEN;
|
|
91
|
+
return {
|
|
92
|
+
async confirm(input) {
|
|
93
|
+
const resourceName = input.ownerInstanceId;
|
|
94
|
+
if (!cloudRunExecutionPattern.test(resourceName)) {
|
|
95
|
+
return cloudRunResult(false, "owner_instance_id is not a valid Cloud Run Job Execution resource");
|
|
96
|
+
}
|
|
97
|
+
if (accessToken === undefined || accessToken.trim() === "") {
|
|
98
|
+
return cloudRunResult(false, "KSQL_FLOWNET_GCP_ACCESS_TOKEN is not configured");
|
|
99
|
+
}
|
|
100
|
+
let response;
|
|
101
|
+
try {
|
|
102
|
+
response = await fetchImplementation(`https://run.googleapis.com/v2/${resourceName}`, {
|
|
103
|
+
method: "GET",
|
|
104
|
+
headers: { Authorization: `Bearer ${accessToken}` },
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
catch {
|
|
108
|
+
return cloudRunResult(false, "Cloud Run API request failed");
|
|
109
|
+
}
|
|
110
|
+
if (!response.ok) {
|
|
111
|
+
return cloudRunResult(false, `Cloud Run API returned HTTP ${response.status}`);
|
|
112
|
+
}
|
|
113
|
+
let body;
|
|
114
|
+
try {
|
|
115
|
+
body = await response.json();
|
|
116
|
+
}
|
|
117
|
+
catch {
|
|
118
|
+
return cloudRunResult(false, `Cloud Run API returned an unknown response shape (HTTP ${response.status})`);
|
|
119
|
+
}
|
|
120
|
+
if (!isRecord(body) || body.name !== resourceName) {
|
|
121
|
+
return cloudRunResult(false, `Cloud Run API returned an unknown response shape (HTTP ${response.status})`);
|
|
122
|
+
}
|
|
123
|
+
const state = executionState(body);
|
|
124
|
+
const stateDetail = state ?? "UNSPECIFIED";
|
|
125
|
+
const completionTime = body.completionTime;
|
|
126
|
+
if (completionTime === undefined ||
|
|
127
|
+
completionTime === null ||
|
|
128
|
+
completionTime === "") {
|
|
129
|
+
return cloudRunResult(false, `Cloud Run Execution is not terminal (state=${stateDetail}, completionTime=unset, HTTP ${response.status})`);
|
|
130
|
+
}
|
|
131
|
+
if (typeof completionTime !== "string" ||
|
|
132
|
+
!Number.isFinite(Date.parse(completionTime))) {
|
|
133
|
+
return cloudRunResult(false, `Cloud Run API returned an unknown completionTime shape (state=${stateDetail}, HTTP ${response.status})`);
|
|
134
|
+
}
|
|
135
|
+
if (state !== null && !terminalCloudRunStates.has(state)) {
|
|
136
|
+
const verdict = nonTerminalCloudRunStates.has(state)
|
|
137
|
+
? "not terminal"
|
|
138
|
+
: "an unknown state";
|
|
139
|
+
return cloudRunResult(false, `Cloud Run Execution has ${verdict} (state=${state}, completionTime=${completionTime}, HTTP ${response.status})`);
|
|
140
|
+
}
|
|
141
|
+
const countDetail = executionCountDetail(body);
|
|
142
|
+
return cloudRunResult(true, `Cloud Run Execution is terminal (state=${stateDetail}, completionTime=${completionTime}, HTTP ${response.status}${countDetail})`);
|
|
143
|
+
},
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
function cloudRunResult(confirmed, detail) {
|
|
147
|
+
return { confirmed, method: "cloud_run_job_execution", detail };
|
|
148
|
+
}
|
|
149
|
+
function isRecord(value) {
|
|
150
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
151
|
+
}
|
|
152
|
+
function errorCodeOf(error) {
|
|
153
|
+
return isRecord(error) && typeof error.code === "string"
|
|
154
|
+
? error.code
|
|
155
|
+
: undefined;
|
|
156
|
+
}
|
|
157
|
+
function executionState(body) {
|
|
158
|
+
if (typeof body.state === "string")
|
|
159
|
+
return body.state;
|
|
160
|
+
if (typeof body.status === "string")
|
|
161
|
+
return body.status;
|
|
162
|
+
return isRecord(body.terminalCondition) &&
|
|
163
|
+
typeof body.terminalCondition.state === "string"
|
|
164
|
+
? body.terminalCondition.state
|
|
165
|
+
: null;
|
|
166
|
+
}
|
|
167
|
+
function executionCountDetail(body) {
|
|
168
|
+
const names = [
|
|
169
|
+
"taskCount",
|
|
170
|
+
"succeededCount",
|
|
171
|
+
"failedCount",
|
|
172
|
+
"cancelledCount",
|
|
173
|
+
];
|
|
174
|
+
const counts = names.map((name) => body[name]);
|
|
175
|
+
if (!counts.every((value) => Number.isSafeInteger(value) && Number(value) >= 0))
|
|
176
|
+
return "";
|
|
177
|
+
const [taskCount, succeededCount, failedCount, cancelledCount] = counts.map(Number);
|
|
178
|
+
const completed = succeededCount + failedCount + cancelledCount;
|
|
179
|
+
return `, taskCounts=${completed === taskCount ? "consistent" : "inconsistent"} (${completed}/${taskCount})`;
|
|
180
|
+
}
|
|
181
|
+
export function defaultStopConfirmations(dependencies = {}) {
|
|
182
|
+
return new Map([
|
|
183
|
+
["manual", manualStopConfirmation],
|
|
184
|
+
["local_pid", createLocalPidStopConfirmation(dependencies)],
|
|
185
|
+
[
|
|
186
|
+
"cloud_run_job_execution",
|
|
187
|
+
createCloudRunJobExecutionStopConfirmation(dependencies),
|
|
188
|
+
],
|
|
189
|
+
]);
|
|
190
|
+
}
|
|
191
|
+
export class NetworkLockRecoveryError extends Error {
|
|
192
|
+
code;
|
|
193
|
+
causeDetail;
|
|
194
|
+
constructor(code, message, causeDetail) {
|
|
195
|
+
super(message);
|
|
196
|
+
this.name = "NetworkLockRecoveryError";
|
|
197
|
+
this.code = code;
|
|
198
|
+
this.causeDetail = causeDetail;
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
const field = (value) => ({ value });
|
|
202
|
+
const text = (record, code) => String(record[code]?.value ?? "");
|
|
203
|
+
const revisionOf = (record) => Number(record.$revision?.value);
|
|
204
|
+
function quote(value) {
|
|
205
|
+
return `"${value.replaceAll("\\", "\\\\").replaceAll('"', '\\"')}"`;
|
|
206
|
+
}
|
|
207
|
+
function snapshot(record) {
|
|
208
|
+
const result = {
|
|
209
|
+
record,
|
|
210
|
+
recordId: text(record, "$id"),
|
|
211
|
+
revision: revisionOf(record),
|
|
212
|
+
businessRevision: Number(record.revision?.value),
|
|
213
|
+
ownerInvocationId: text(record, "owner_invocation_id"),
|
|
214
|
+
ownerInstanceId: ownerInstanceIdFromStatusReason(text(record, "status_reason")),
|
|
215
|
+
leaseToken: text(record, "lease_token"),
|
|
216
|
+
heartbeatAt: text(record, "heartbeat_at"),
|
|
217
|
+
leaseExpiresAt: text(record, "lease_expires_at"),
|
|
218
|
+
};
|
|
219
|
+
if (result.recordId === "" ||
|
|
220
|
+
!Number.isSafeInteger(result.revision) ||
|
|
221
|
+
!Number.isSafeInteger(result.businessRevision) ||
|
|
222
|
+
result.leaseToken === "" ||
|
|
223
|
+
!Number.isFinite(Date.parse(result.leaseExpiresAt))) {
|
|
224
|
+
throw new NetworkLockRecoveryError("LOCK_READ_FAILED", "network lock record is malformed");
|
|
225
|
+
}
|
|
226
|
+
return result;
|
|
227
|
+
}
|
|
228
|
+
async function getRecords(client, recordKey) {
|
|
229
|
+
return client.getRecords(`record_key in (${quote(recordKey)})`);
|
|
230
|
+
}
|
|
231
|
+
async function getInitialLock(client, recordKey) {
|
|
232
|
+
let records;
|
|
233
|
+
try {
|
|
234
|
+
records = await getRecords(client, recordKey);
|
|
235
|
+
}
|
|
236
|
+
catch (error) {
|
|
237
|
+
throw new NetworkLockRecoveryError("LOCK_READ_FAILED", "network lock read failed", error);
|
|
238
|
+
}
|
|
239
|
+
if (records.length === 0) {
|
|
240
|
+
throw new NetworkLockRecoveryError("LOCK_NOT_FOUND", "network lock was not found");
|
|
241
|
+
}
|
|
242
|
+
if (records.length !== 1) {
|
|
243
|
+
throw new NetworkLockRecoveryError("LOCK_READ_FAILED", `network lock read returned ${records.length} records`);
|
|
244
|
+
}
|
|
245
|
+
return snapshot(records[0]);
|
|
246
|
+
}
|
|
247
|
+
function requireNonEmpty(value, name) {
|
|
248
|
+
if (value.trim() === "") {
|
|
249
|
+
throw new NetworkLockRecoveryError("INVALID_ARGUMENT", `${name} must not be empty`);
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
function releaseConfirmed(record, initial, tombstone) {
|
|
253
|
+
// finished_at is DATETIME with minute precision, so do not use it for round-trip comparison.
|
|
254
|
+
return (text(record, "$id") === initial.recordId &&
|
|
255
|
+
text(record, "record_key") === tombstone &&
|
|
256
|
+
text(record, "lock_key") === "" &&
|
|
257
|
+
text(record, "owner_invocation_id") === "" &&
|
|
258
|
+
text(record, "lease_token") === "" &&
|
|
259
|
+
text(record, "status") === "CANCELLED" &&
|
|
260
|
+
text(record, "status_reason") === "NETWORK_LOCK_FORCE_RELEASED" &&
|
|
261
|
+
revisionOf(record) === initial.revision + 1);
|
|
262
|
+
}
|
|
263
|
+
export async function forceUnlockNetwork(input, dependencies) {
|
|
264
|
+
for (const [name, value] of Object.entries(input))
|
|
265
|
+
requireNonEmpty(value, name);
|
|
266
|
+
const now = dependencies.now ?? (() => new Date());
|
|
267
|
+
const uuid = dependencies.uuid ?? randomUUID;
|
|
268
|
+
const confirmations = dependencies.stopConfirmations ?? defaultStopConfirmations();
|
|
269
|
+
const lockKey = networkLockKey(input.profile, input.networkId);
|
|
270
|
+
const recordKey = `LOCK:${lockKey}`;
|
|
271
|
+
const client = new KintoneClient({
|
|
272
|
+
baseUrl: dependencies.baseUrl,
|
|
273
|
+
appId: dependencies.stateAppId,
|
|
274
|
+
apiToken: dependencies.stateApiToken,
|
|
275
|
+
...(dependencies.fetch === undefined ? {} : { fetch: dependencies.fetch }),
|
|
276
|
+
});
|
|
277
|
+
const initial = await getInitialLock(client, recordKey);
|
|
278
|
+
if (initial.ownerInvocationId !== input.expectedOwnerInvocationId) {
|
|
279
|
+
throw new NetworkLockRecoveryError("OWNER_MISMATCH", "network lock owner does not match the expected invocation");
|
|
280
|
+
}
|
|
281
|
+
// The persisted lease expiry may be truncated by up to 59 seconds, so add the upper bound before allowing recovery.
|
|
282
|
+
if (Date.parse(initial.leaseExpiresAt) + KINTONE_DATETIME_TRUNCATION_MS >
|
|
283
|
+
now().getTime()) {
|
|
284
|
+
throw new NetworkLockRecoveryError("LEASE_STILL_ACTIVE", "network lock lease has not expired");
|
|
285
|
+
}
|
|
286
|
+
const confirmation = confirmations.get(input.stopMethod);
|
|
287
|
+
if (confirmation === undefined) {
|
|
288
|
+
throw new NetworkLockRecoveryError("STOP_NOT_CONFIRMED", `unknown stop confirmation method '${input.stopMethod}'`);
|
|
289
|
+
}
|
|
290
|
+
let confirmed;
|
|
291
|
+
try {
|
|
292
|
+
confirmed = await confirmation.confirm({
|
|
293
|
+
profile: input.profile,
|
|
294
|
+
networkId: input.networkId,
|
|
295
|
+
expectedOwnerInvocationId: input.expectedOwnerInvocationId,
|
|
296
|
+
ownerInstanceId: initial.ownerInstanceId,
|
|
297
|
+
stopConfirmedBy: input.stopConfirmedBy,
|
|
298
|
+
stopMethod: input.stopMethod,
|
|
299
|
+
stopEvidenceRef: input.stopEvidenceRef,
|
|
300
|
+
});
|
|
301
|
+
}
|
|
302
|
+
catch (error) {
|
|
303
|
+
throw new NetworkLockRecoveryError("STOP_NOT_CONFIRMED", "stop confirmation failed", error);
|
|
304
|
+
}
|
|
305
|
+
if (!confirmed.confirmed) {
|
|
306
|
+
throw new NetworkLockRecoveryError("STOP_NOT_CONFIRMED", confirmed.detail || "stop was not confirmed");
|
|
307
|
+
}
|
|
308
|
+
let checked;
|
|
309
|
+
try {
|
|
310
|
+
checked = await getInitialLock(client, recordKey);
|
|
311
|
+
}
|
|
312
|
+
catch (error) {
|
|
313
|
+
throw new NetworkLockRecoveryError("HEARTBEAT_ADVANCED", "network lock changed while stop confirmation was in progress", error);
|
|
314
|
+
}
|
|
315
|
+
if (checked.revision !== initial.revision ||
|
|
316
|
+
checked.heartbeatAt !== initial.heartbeatAt ||
|
|
317
|
+
checked.ownerInvocationId !== initial.ownerInvocationId ||
|
|
318
|
+
checked.leaseToken !== initial.leaseToken) {
|
|
319
|
+
throw new NetworkLockRecoveryError("HEARTBEAT_ADVANCED", "network lock changed while stop confirmation was in progress");
|
|
320
|
+
}
|
|
321
|
+
const releasedAt = now().toISOString();
|
|
322
|
+
const tombstone = releaseTombstoneRecordKey(`${lockKey}:${initial.leaseToken}`);
|
|
323
|
+
const terminalRecord = {
|
|
324
|
+
record_key: field(tombstone),
|
|
325
|
+
lock_key: field(""),
|
|
326
|
+
owner_invocation_id: field(""),
|
|
327
|
+
lease_token: field(""),
|
|
328
|
+
status: field("CANCELLED"),
|
|
329
|
+
status_reason: field("NETWORK_LOCK_FORCE_RELEASED"),
|
|
330
|
+
finished_at: field(releasedAt),
|
|
331
|
+
revision: field(initial.businessRevision + 1),
|
|
332
|
+
};
|
|
333
|
+
let postReleaseRevision;
|
|
334
|
+
try {
|
|
335
|
+
postReleaseRevision = await client.putRecordById(initial.recordId, initial.revision, terminalRecord);
|
|
336
|
+
}
|
|
337
|
+
catch (error) {
|
|
338
|
+
if (error instanceof KintoneApiError &&
|
|
339
|
+
(error.status === 409 || error.apiCode === "GAIA_CO02")) {
|
|
340
|
+
throw new NetworkLockRecoveryError("REVISION_CONFLICT", "network lock release revision conflicted", error);
|
|
341
|
+
}
|
|
342
|
+
if (!(error instanceof KintoneTransportError)) {
|
|
343
|
+
throw new NetworkLockRecoveryError("RELEASE_UNCONFIRMED", "network lock release failed", error);
|
|
344
|
+
}
|
|
345
|
+
let records;
|
|
346
|
+
try {
|
|
347
|
+
records = await getRecords(client, tombstone);
|
|
348
|
+
}
|
|
349
|
+
catch (adjudicationError) {
|
|
350
|
+
throw new NetworkLockRecoveryError("RELEASE_UNCONFIRMED", "network lock release outcome could not be confirmed", { release: error, adjudication: adjudicationError });
|
|
351
|
+
}
|
|
352
|
+
if (records.length !== 1 ||
|
|
353
|
+
!releaseConfirmed(records[0], initial, tombstone)) {
|
|
354
|
+
throw new NetworkLockRecoveryError("RELEASE_UNCONFIRMED", "network lock release outcome could not be confirmed", { release: error, adjudication: "released tombstone did not match" });
|
|
355
|
+
}
|
|
356
|
+
postReleaseRevision = revisionOf(records[0]);
|
|
357
|
+
}
|
|
358
|
+
const audit = {
|
|
359
|
+
event_id: `net_unlock_${uuid()}`,
|
|
360
|
+
event_type: "NETWORK_LOCK_FORCE_RELEASED",
|
|
361
|
+
network_id: input.networkId,
|
|
362
|
+
profile: input.profile,
|
|
363
|
+
lock_key: lockKey,
|
|
364
|
+
record_id: initial.recordId,
|
|
365
|
+
previous_owner_invocation_id: initial.ownerInvocationId,
|
|
366
|
+
previous_lease_token: initial.leaseToken,
|
|
367
|
+
previous_heartbeat_at: initial.heartbeatAt,
|
|
368
|
+
previous_lease_expires_at: initial.leaseExpiresAt,
|
|
369
|
+
service_principal: input.servicePrincipal,
|
|
370
|
+
requested_by: input.requestedBy,
|
|
371
|
+
stop_confirmed_by: input.stopConfirmedBy,
|
|
372
|
+
stop_method: confirmed.method,
|
|
373
|
+
stop_evidence_ref: input.stopEvidenceRef,
|
|
374
|
+
reason: input.reason,
|
|
375
|
+
evidence_ref: input.evidenceRef,
|
|
376
|
+
released_at: releasedAt,
|
|
377
|
+
post_release_revision: postReleaseRevision,
|
|
378
|
+
};
|
|
379
|
+
try {
|
|
380
|
+
await dependencies.repository.appendOperationAudit(audit);
|
|
381
|
+
}
|
|
382
|
+
catch (error) {
|
|
383
|
+
throw new NetworkLockRecoveryError("AUDIT_FAILED", "network lock was released but its audit could not be confirmed", error);
|
|
384
|
+
}
|
|
385
|
+
return {
|
|
386
|
+
lockKey,
|
|
387
|
+
previousOwnerInvocationId: initial.ownerInvocationId,
|
|
388
|
+
postReleaseRevision,
|
|
389
|
+
audit,
|
|
390
|
+
};
|
|
391
|
+
}
|
|
392
|
+
//# sourceMappingURL=network-lock-recovery.js.map
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import { type CanonicalLockKey } from "../domain/canonical-lock-key.js";
|
|
2
|
+
export type NetworkLockErrorCode = "LOCK_CONFLICT" | "LOCK_UNAVAILABLE" | "LEASE_TOKEN_MISMATCH" | "LEASE_REVISION_MISMATCH" | "LEASE_REVISION_CONFLICT";
|
|
3
|
+
export declare class NetworkLockError extends Error {
|
|
4
|
+
readonly code: NetworkLockErrorCode;
|
|
5
|
+
readonly causeDetail: unknown;
|
|
6
|
+
constructor(code: NetworkLockErrorCode, message: string, causeDetail?: unknown);
|
|
7
|
+
}
|
|
8
|
+
export interface NetworkLockReference {
|
|
9
|
+
readonly lockKey: CanonicalLockKey<"N1">;
|
|
10
|
+
readonly recordId: string;
|
|
11
|
+
readonly originalRecordKey: string;
|
|
12
|
+
readonly leaseToken: string;
|
|
13
|
+
readonly ownerInvocationId: string;
|
|
14
|
+
readonly leaseDurationSec: number;
|
|
15
|
+
revision: number;
|
|
16
|
+
businessRevision: number;
|
|
17
|
+
heartbeatAt: string;
|
|
18
|
+
leaseExpiresAt: string;
|
|
19
|
+
}
|
|
20
|
+
export interface NetworkLockManagerConfig {
|
|
21
|
+
baseUrl: string;
|
|
22
|
+
appId: number;
|
|
23
|
+
apiToken: string;
|
|
24
|
+
profile: string;
|
|
25
|
+
networkId: string;
|
|
26
|
+
ownerInvocationId: string;
|
|
27
|
+
ownerInstanceId: string;
|
|
28
|
+
leaseDurationSec: number;
|
|
29
|
+
fetch?: typeof fetch;
|
|
30
|
+
now?: () => Date;
|
|
31
|
+
uuid?: () => string;
|
|
32
|
+
}
|
|
33
|
+
export declare function releaseTombstoneRecordKey(uniqueValue: string): string;
|
|
34
|
+
export declare class NetworkLockManager {
|
|
35
|
+
private readonly client;
|
|
36
|
+
private readonly config;
|
|
37
|
+
private readonly now;
|
|
38
|
+
private readonly uuid;
|
|
39
|
+
readonly lockKey: CanonicalLockKey<"N1">;
|
|
40
|
+
readonly recordKey: string;
|
|
41
|
+
constructor(config: NetworkLockManagerConfig);
|
|
42
|
+
acquire(): Promise<NetworkLockReference>;
|
|
43
|
+
heartbeat(reference: NetworkLockReference): Promise<NetworkLockReference>;
|
|
44
|
+
release(reference: NetworkLockReference, status?: string, resultCode?: string): Promise<{
|
|
45
|
+
released: true;
|
|
46
|
+
recordKey: string;
|
|
47
|
+
}>;
|
|
48
|
+
private getUniqueAfterAcquireFailure;
|
|
49
|
+
private getUnique;
|
|
50
|
+
private assertFence;
|
|
51
|
+
}
|
|
52
|
+
export type LeaseMonitorState = "HELD" | "LEASE_UNCERTAIN" | "FINAL_WRITE_CONFIRMED";
|
|
53
|
+
export interface LeaseMonitorEvent {
|
|
54
|
+
previous: LeaseMonitorState;
|
|
55
|
+
current: LeaseMonitorState;
|
|
56
|
+
reason: "FAILURE_THRESHOLD" | "LEASE_SAFETY_THRESHOLD" | "FENCE_LOST" | "FINAL_WRITE_CONFIRMED";
|
|
57
|
+
at: string;
|
|
58
|
+
}
|
|
59
|
+
export interface LeaseMonitorConfig {
|
|
60
|
+
leaseDurationSec: number;
|
|
61
|
+
heartbeatIntervalSec: number;
|
|
62
|
+
consecutiveFailureThreshold?: number;
|
|
63
|
+
remainingLeaseSafetySec?: number;
|
|
64
|
+
now?: () => Date;
|
|
65
|
+
schedule?: (callback: () => void, intervalMs: number) => unknown;
|
|
66
|
+
cancelSchedule?: (handle: unknown) => void;
|
|
67
|
+
}
|
|
68
|
+
export declare class LeaseMonitor {
|
|
69
|
+
private readonly manager;
|
|
70
|
+
private readonly reference;
|
|
71
|
+
private readonly config;
|
|
72
|
+
private state;
|
|
73
|
+
private failures;
|
|
74
|
+
private timer;
|
|
75
|
+
private tickInFlight;
|
|
76
|
+
private readonly listeners;
|
|
77
|
+
private readonly now;
|
|
78
|
+
private readonly failureThreshold;
|
|
79
|
+
private readonly safetyMs;
|
|
80
|
+
private readonly schedule;
|
|
81
|
+
private readonly cancelSchedule;
|
|
82
|
+
constructor(manager: NetworkLockManager, reference: NetworkLockReference, config: LeaseMonitorConfig);
|
|
83
|
+
get currentState(): LeaseMonitorState;
|
|
84
|
+
canStartNewNode(): boolean;
|
|
85
|
+
canPersistResults(): boolean;
|
|
86
|
+
requiresNetworkLeaseInterruptedFinalization(): boolean;
|
|
87
|
+
markControlPlaneUnreachable(): void;
|
|
88
|
+
subscribe(listener: (event: LeaseMonitorEvent) => void): () => void;
|
|
89
|
+
tick(): Promise<boolean>;
|
|
90
|
+
private performTick;
|
|
91
|
+
confirmLeaseForFinalWrite(): Promise<boolean>;
|
|
92
|
+
start(): void;
|
|
93
|
+
stop(): void;
|
|
94
|
+
private remainingLeaseMs;
|
|
95
|
+
private transition;
|
|
96
|
+
}
|
|
97
|
+
//# sourceMappingURL=network-lock.d.ts.map
|