@oatscenter/trellis 0.100.0-rc.1
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/README.md +59 -0
- package/auth/authorization/client_context.d.ts +61 -0
- package/auth/authorization/client_context.js +266 -0
- package/auth/authorization/nats_registry.d.ts +41 -0
- package/auth/authorization/nats_registry.js +283 -0
- package/auth/authorization/provider_cache.d.ts +90 -0
- package/auth/authorization/provider_cache.js +783 -0
- package/auth/authorization/refresh.d.ts +46 -0
- package/auth/authorization/refresh.js +246 -0
- package/auth/authorization/types.d.ts +207 -0
- package/auth/authorization/types.js +60 -0
- package/auth/authorization_context.d.ts +11 -0
- package/auth/authorization_context.js +10 -0
- package/auth/browser/flow_types.d.ts +84 -0
- package/auth/browser/flow_types.js +70 -0
- package/auth/browser/portal.d.ts +21 -0
- package/auth/browser/portal.js +307 -0
- package/auth/browser/storage.d.ts +26 -0
- package/auth/browser/storage.js +224 -0
- package/auth/browser.d.ts +11 -0
- package/auth/browser.js +10 -0
- package/auth/browser_recovery.d.ts +13 -0
- package/auth/browser_recovery.js +38 -0
- package/auth/device_activation.d.ts +134 -0
- package/auth/device_activation.js +376 -0
- package/auth/http_error.d.ts +12 -0
- package/auth/http_error.js +32 -0
- package/auth/keys.d.ts +5 -0
- package/auth/keys.js +63 -0
- package/auth/mod.d.ts +16 -0
- package/auth/mod.js +16 -0
- package/auth/proof.d.ts +31 -0
- package/auth/proof.js +95 -0
- package/auth/protocol_wasm/trellis_protocol_wasm.d.ts +138 -0
- package/auth/protocol_wasm/trellis_protocol_wasm.js +605 -0
- package/auth/protocol_wasm/trellis_protocol_wasm_bg.wasm +0 -0
- package/auth/protocol_wasm/trellis_protocol_wasm_bytes.d.ts +1 -0
- package/auth/protocol_wasm/trellis_protocol_wasm_bytes.js +2 -0
- package/auth/protocol_wasm.d.ts +179 -0
- package/auth/protocol_wasm.js +133 -0
- package/auth/session_auth.d.ts +30 -0
- package/auth/session_auth.js +77 -0
- package/auth/session_proof.d.ts +53 -0
- package/auth/session_proof.js +43 -0
- package/auth/time.d.ts +6 -0
- package/auth/time.js +7 -0
- package/auth/trellis_id.d.ts +1 -0
- package/auth/trellis_id.js +5 -0
- package/auth/utils.d.ts +10 -0
- package/auth/utils.js +69 -0
- package/auth.d.ts +1 -0
- package/auth.js +1 -0
- package/caller.d.ts +111 -0
- package/caller.js +85 -0
- package/client.d.ts +14 -0
- package/client.js +1 -0
- package/client_connect.d.ts +143 -0
- package/client_connect.js +1097 -0
- package/codec.d.ts +13 -0
- package/codec.js +192 -0
- package/connection.d.ts +107 -0
- package/connection.js +396 -0
- package/device/activation.d.ts +42 -0
- package/device/activation.js +319 -0
- package/device.d.ts +80 -0
- package/device.js +687 -0
- package/env.d.ts +2 -0
- package/env.js +10 -0
- package/errors/AuthError.d.ts +30 -0
- package/errors/AuthError.js +34 -0
- package/errors/KVError.d.ts +30 -0
- package/errors/KVError.js +36 -0
- package/errors/OperationLifecycleError.d.ts +110 -0
- package/errors/OperationLifecycleError.js +142 -0
- package/errors/RemoteError.d.ts +61 -0
- package/errors/RemoteError.js +72 -0
- package/errors/SchemaValidationError.d.ts +48 -0
- package/errors/SchemaValidationError.js +49 -0
- package/errors/StoreError.d.ts +21 -0
- package/errors/StoreError.js +31 -0
- package/errors/TransferError.d.ts +21 -0
- package/errors/TransferError.js +31 -0
- package/errors/TransportError.d.ts +29 -0
- package/errors/TransportError.js +33 -0
- package/errors/TrellisError.d.ts +15 -0
- package/errors/TrellisError.js +15 -0
- package/errors/ValidationError.d.ts +50 -0
- package/errors/ValidationError.js +59 -0
- package/errors/index.d.ts +254 -0
- package/errors/index.js +238 -0
- package/generated.d.ts +4 -0
- package/generated.js +3 -0
- package/generated_support.d.ts +120 -0
- package/generated_support.js +359 -0
- package/globals.d.ts +9 -0
- package/globals.js +16 -0
- package/health_transport.d.ts +18 -0
- package/health_transport.js +32 -0
- package/helpers.d.ts +11 -0
- package/helpers.js +53 -0
- package/index.d.ts +2 -0
- package/index.js +2 -0
- package/internal_sdk/generated/apis/auth/mod.d.ts +805 -0
- package/internal_sdk/generated/apis/auth/mod.js +958 -0
- package/internal_sdk/generated/apis/core/mod.d.ts +79 -0
- package/internal_sdk/generated/apis/core/mod.js +93 -0
- package/internal_sdk/generated/apis/events/mod.d.ts +224 -0
- package/internal_sdk/generated/apis/events/mod.js +313 -0
- package/internal_sdk/generated/apis/health/mod.d.ts +106 -0
- package/internal_sdk/generated/apis/health/mod.js +132 -0
- package/internal_sdk/generated/apis/index.d.ts +7 -0
- package/internal_sdk/generated/apis/index.js +8 -0
- package/internal_sdk/generated/apis/jobs/mod.d.ts +176 -0
- package/internal_sdk/generated/apis/jobs/mod.js +209 -0
- package/internal_sdk/generated/apis/state/mod.d.ts +104 -0
- package/internal_sdk/generated/apis/state/mod.js +139 -0
- package/internal_sdk/generated/index.d.ts +4 -0
- package/internal_sdk/generated/index.js +5 -0
- package/internal_sdk/generated/package.json +18 -0
- package/internal_sdk/generated/participants/cli/mod.d.ts +402 -0
- package/internal_sdk/generated/participants/cli/mod.js +460 -0
- package/internal_sdk/generated/participants/cli/types.d.ts +2 -0
- package/internal_sdk/generated/participants/cli/types.js +3 -0
- package/internal_sdk/generated/participants/console/mod.d.ts +489 -0
- package/internal_sdk/generated/participants/console/mod.js +579 -0
- package/internal_sdk/generated/participants/console/types.d.ts +2 -0
- package/internal_sdk/generated/participants/console/types.js +3 -0
- package/internal_sdk/generated/participants/eventsRuntime/mod.d.ts +64 -0
- package/internal_sdk/generated/participants/eventsRuntime/mod.js +42 -0
- package/internal_sdk/generated/participants/eventsRuntime/types.d.ts +2 -0
- package/internal_sdk/generated/participants/eventsRuntime/types.js +3 -0
- package/internal_sdk/generated/participants/healthRuntime/mod.d.ts +58 -0
- package/internal_sdk/generated/participants/healthRuntime/mod.js +36 -0
- package/internal_sdk/generated/participants/healthRuntime/types.d.ts +2 -0
- package/internal_sdk/generated/participants/healthRuntime/types.js +3 -0
- package/internal_sdk/generated/participants/index.d.ts +8 -0
- package/internal_sdk/generated/participants/index.js +9 -0
- package/internal_sdk/generated/participants/jobsRuntime/mod.d.ts +64 -0
- package/internal_sdk/generated/participants/jobsRuntime/mod.js +42 -0
- package/internal_sdk/generated/participants/jobsRuntime/types.d.ts +2 -0
- package/internal_sdk/generated/participants/jobsRuntime/types.js +3 -0
- package/internal_sdk/generated/participants/platform/mod.d.ts +164 -0
- package/internal_sdk/generated/participants/platform/mod.js +149 -0
- package/internal_sdk/generated/participants/platform/types.d.ts +7 -0
- package/internal_sdk/generated/participants/platform/types.js +5 -0
- package/internal_sdk/generated/participants/portal/mod.d.ts +131 -0
- package/internal_sdk/generated/participants/portal/mod.js +109 -0
- package/internal_sdk/generated/participants/portal/types.d.ts +2 -0
- package/internal_sdk/generated/participants/portal/types.js +3 -0
- package/internal_sdk/generated/types/_internal/p0.d.ts +13511 -0
- package/internal_sdk/generated/types/_internal/p0.js +6105 -0
- package/internal_sdk/generated/types/index.d.ts +2951 -0
- package/internal_sdk/generated/types/index.js +1477 -0
- package/jobs.d.ts +309 -0
- package/jobs.js +389 -0
- package/kv.d.ts +85 -0
- package/kv.js +330 -0
- package/operations.d.ts +189 -0
- package/operations.js +847 -0
- package/package.json +73 -0
- package/pagination.d.ts +25 -0
- package/pagination.js +117 -0
- package/participant.d.ts +6 -0
- package/participant.js +6 -0
- package/participant_runtime/api.d.ts +131 -0
- package/participant_runtime/api.js +42 -0
- package/participant_runtime/json.d.ts +16 -0
- package/participant_runtime/json.js +193 -0
- package/participant_runtime/metadata.d.ts +34 -0
- package/participant_runtime/metadata.js +4 -0
- package/participant_runtime/participant.d.ts +200 -0
- package/participant_runtime/participant.js +330 -0
- package/participant_runtime/schemas.d.ts +347 -0
- package/participant_runtime/schemas.js +211 -0
- package/participant_runtime/surface_names.d.ts +16 -0
- package/participant_runtime/surface_names.js +34 -0
- package/portable.d.ts +30 -0
- package/portable.js +14 -0
- package/provider.d.ts +69 -0
- package/provider.js +68 -0
- package/runtime_transport.d.ts +25 -0
- package/runtime_transport.js +48 -0
- package/service/mod.d.ts +14 -0
- package/service/mod.js +13 -0
- package/service/outbox_inbox.d.ts +259 -0
- package/service/outbox_inbox.js +908 -0
- package/service/runtime/bootstrap.d.ts +56 -0
- package/service/runtime/bootstrap.js +226 -0
- package/service/runtime/core.d.ts +46 -0
- package/service/runtime/core.js +1747 -0
- package/service/runtime/health.d.ts +97 -0
- package/service/runtime/health.js +153 -0
- package/service/runtime/health_schemas.d.ts +23 -0
- package/service/runtime/health_schemas.js +20 -0
- package/service/runtime/internal_jobs/active-job.d.ts +40 -0
- package/service/runtime/internal_jobs/active-job.js +91 -0
- package/service/runtime/internal_jobs/bindings.d.ts +40 -0
- package/service/runtime/internal_jobs/bindings.js +1 -0
- package/service/runtime/internal_jobs/cancellation-registry.d.ts +13 -0
- package/service/runtime/internal_jobs/cancellation-registry.js +55 -0
- package/service/runtime/internal_jobs/heartbeat.d.ts +33 -0
- package/service/runtime/internal_jobs/heartbeat.js +65 -0
- package/service/runtime/internal_jobs/job-manager.d.ts +126 -0
- package/service/runtime/internal_jobs/job-manager.js +821 -0
- package/service/runtime/internal_jobs/key-coordinator.d.ts +280 -0
- package/service/runtime/internal_jobs/key-coordinator.js +747 -0
- package/service/runtime/internal_jobs/projection.d.ts +3 -0
- package/service/runtime/internal_jobs/projection.js +42 -0
- package/service/runtime/internal_jobs/runtime-worker.d.ts +126 -0
- package/service/runtime/internal_jobs/runtime-worker.js +454 -0
- package/service/runtime/internal_jobs/types.d.ts +246 -0
- package/service/runtime/internal_jobs/types.js +163 -0
- package/service/runtime/logger.d.ts +2 -0
- package/service/runtime/logger.js +7 -0
- package/service/runtime/runtime.d.ts +16 -0
- package/service/runtime/runtime.js +1 -0
- package/service/runtime/service.d.ts +372 -0
- package/service/runtime/service.js +1896 -0
- package/service/runtime/transfer/download.d.ts +27 -0
- package/service/runtime/transfer/download.js +1 -0
- package/service/runtime/transfer/protocol.d.ts +15 -0
- package/service/runtime/transfer/protocol.js +46 -0
- package/service/runtime/transfer/queue.d.ts +18 -0
- package/service/runtime/transfer/queue.js +136 -0
- package/service/runtime/transfer/upload.d.ts +40 -0
- package/service/runtime/transfer/upload.js +27 -0
- package/service/runtime/transfer.d.ts +62 -0
- package/service/runtime/transfer.js +763 -0
- package/session.d.ts +862 -0
- package/session.js +3407 -0
- package/store.d.ts +71 -0
- package/store.js +412 -0
- package/store_entry.d.ts +20 -0
- package/store_entry.js +75 -0
- package/tasks.d.ts +10 -0
- package/tasks.js +28 -0
- package/telemetry/carrier.d.ts +8 -0
- package/telemetry/carrier.js +20 -0
- package/telemetry/core.d.ts +7 -0
- package/telemetry/core.js +14 -0
- package/telemetry/env.d.ts +2 -0
- package/telemetry/env.js +10 -0
- package/telemetry/init.d.ts +2 -0
- package/telemetry/init.js +7 -0
- package/telemetry/metrics.d.ts +81 -0
- package/telemetry/metrics.js +267 -0
- package/telemetry/mod.d.ts +13 -0
- package/telemetry/mod.js +8 -0
- package/telemetry/nats.d.ts +6 -0
- package/telemetry/nats.js +6 -0
- package/telemetry/result.d.ts +1 -0
- package/telemetry/result.js +18 -0
- package/telemetry/runtime.d.ts +1 -0
- package/telemetry/runtime.js +141 -0
- package/telemetry/trace.d.ts +4 -0
- package/telemetry/trace.js +26 -0
- package/telemetry/trellis.d.ts +1 -0
- package/telemetry/trellis.js +1 -0
- package/telemetry.d.ts +1 -0
- package/telemetry.js +1 -0
- package/transfer.d.ts +112 -0
- package/transfer.js +525 -0
- package/transfer_protocol.d.ts +1 -0
- package/transfer_protocol.js +13 -0
|
@@ -0,0 +1,1747 @@
|
|
|
1
|
+
import { createInbox, headers, } from "@nats-io/nats-core";
|
|
2
|
+
import { Pointer, Value } from "typebox/value";
|
|
3
|
+
import { routeQueueGroup, } from "../../participant_runtime/api.js";
|
|
4
|
+
import { AsyncResult, BaseError, err, isErr, ok, Result, } from "@oatscenter/result";
|
|
5
|
+
import { ulid } from "ulid";
|
|
6
|
+
import { encodeSchema, parseSchema } from "../../codec.js";
|
|
7
|
+
import { canonicalizeJson, digestJson, } from "../../participant_runtime/json.js";
|
|
8
|
+
import { AuthError, OperationAlreadyTerminalError, OperationNotFoundError, TransferError, UnexpectedError, ValidationError, } from "../../errors/index.js";
|
|
9
|
+
import { serviceRuntimeLogger } from "./logger.js";
|
|
10
|
+
import { recordTrellisError, } from "../../telemetry/mod.js";
|
|
11
|
+
import { annotateHandlerBoundaryError, buildRuntimeOperationSnapshot, isOperationDeferred, isResultLike, isTerminalRuntimeOperationSnapshot, safeJson, Trellis, verifyLocalAuthorization, } from "../../session.js";
|
|
12
|
+
import { FileInfoSchema, } from "../../transfer.js";
|
|
13
|
+
const CANONICAL_POSITIVE_INTEGER = /^[1-9][0-9]*$/u;
|
|
14
|
+
function asStringPointerValue(operation, input, pointer, field) {
|
|
15
|
+
const value = Pointer.Get(input, pointer);
|
|
16
|
+
if (typeof value !== "string" || value.length === 0) {
|
|
17
|
+
return err(new TransferError({
|
|
18
|
+
operation: "transfer",
|
|
19
|
+
context: { reason: "invalid_input", operation, field, pointer },
|
|
20
|
+
}));
|
|
21
|
+
}
|
|
22
|
+
return ok(value);
|
|
23
|
+
}
|
|
24
|
+
function asOptionalStringPointerValue(input, pointer) {
|
|
25
|
+
if (!pointer) {
|
|
26
|
+
return ok(undefined);
|
|
27
|
+
}
|
|
28
|
+
const value = Pointer.Get(input, pointer);
|
|
29
|
+
if (value === undefined) {
|
|
30
|
+
return ok(undefined);
|
|
31
|
+
}
|
|
32
|
+
if (typeof value !== "string" || value.length === 0) {
|
|
33
|
+
return err(new TransferError({
|
|
34
|
+
operation: "transfer",
|
|
35
|
+
context: { reason: "invalid_input", field: pointer, pointer },
|
|
36
|
+
}));
|
|
37
|
+
}
|
|
38
|
+
return ok(value);
|
|
39
|
+
}
|
|
40
|
+
function asOptionalStringRecordPointerValue(input, pointer) {
|
|
41
|
+
if (!pointer) {
|
|
42
|
+
return ok(undefined);
|
|
43
|
+
}
|
|
44
|
+
const value = Pointer.Get(input, pointer);
|
|
45
|
+
if (value === undefined) {
|
|
46
|
+
return ok(undefined);
|
|
47
|
+
}
|
|
48
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
49
|
+
return err(new TransferError({
|
|
50
|
+
operation: "transfer",
|
|
51
|
+
context: { reason: "invalid_input", field: pointer, pointer },
|
|
52
|
+
}));
|
|
53
|
+
}
|
|
54
|
+
const entries = Object.entries(value);
|
|
55
|
+
if (entries.some(([key, item]) => key.length === 0 || typeof item !== "string")) {
|
|
56
|
+
return err(new TransferError({
|
|
57
|
+
operation: "transfer",
|
|
58
|
+
context: { reason: "invalid_input", field: pointer, pointer },
|
|
59
|
+
}));
|
|
60
|
+
}
|
|
61
|
+
return ok(Object.fromEntries(entries));
|
|
62
|
+
}
|
|
63
|
+
function traceIdFromTraceparent(traceparent) {
|
|
64
|
+
const [version, traceId, parentId, flags, extra] = traceparent?.split("-") ??
|
|
65
|
+
[];
|
|
66
|
+
if (extra !== undefined ||
|
|
67
|
+
!/^[0-9a-f]{2}$/u.test(version ?? "") ||
|
|
68
|
+
version === "ff" ||
|
|
69
|
+
!/^[0-9a-f]{32}$/u.test(traceId ?? "") ||
|
|
70
|
+
traceId === "00000000000000000000000000000000" ||
|
|
71
|
+
!/^[0-9a-f]{16}$/u.test(parentId ?? "") ||
|
|
72
|
+
parentId === "0000000000000000" ||
|
|
73
|
+
!/^[0-9a-f]{2}$/u.test(flags ?? "")) {
|
|
74
|
+
return undefined;
|
|
75
|
+
}
|
|
76
|
+
return traceId;
|
|
77
|
+
}
|
|
78
|
+
function recordOperationServiceError(error, attributes) {
|
|
79
|
+
recordTrellisError(error, {
|
|
80
|
+
messagingSystem: "nats",
|
|
81
|
+
surface: "operation",
|
|
82
|
+
direction: "server",
|
|
83
|
+
...attributes,
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
function isOperationRevisionConflict(cause) {
|
|
87
|
+
const message = cause instanceof Error
|
|
88
|
+
? `${cause.message} ${cause.cause instanceof Error ? cause.cause.message : ""}`
|
|
89
|
+
.toLowerCase()
|
|
90
|
+
: "";
|
|
91
|
+
return message.includes("operation revision conflict") ||
|
|
92
|
+
message.includes("wrong last sequence") ||
|
|
93
|
+
message.includes("wrong last revision") ||
|
|
94
|
+
message.includes("revision mismatch") ||
|
|
95
|
+
message.includes("sequence mismatch");
|
|
96
|
+
}
|
|
97
|
+
export class TrellisServiceRuntime extends Trellis {
|
|
98
|
+
#nats;
|
|
99
|
+
#version;
|
|
100
|
+
#log;
|
|
101
|
+
#operations = new Map();
|
|
102
|
+
#activeOperationFences = new Map();
|
|
103
|
+
#operationUpdateSequences = new Map();
|
|
104
|
+
#mountedOperationControls = new Set();
|
|
105
|
+
#stopPromise;
|
|
106
|
+
#transferSupport;
|
|
107
|
+
#operationOwnerId;
|
|
108
|
+
#operationConnectionId;
|
|
109
|
+
#operationDeploymentId;
|
|
110
|
+
operations;
|
|
111
|
+
constructor(name, nats, auth, opts) {
|
|
112
|
+
super(name, nats, auth, {
|
|
113
|
+
...opts,
|
|
114
|
+
log: opts?.log ?? serviceRuntimeLogger,
|
|
115
|
+
});
|
|
116
|
+
if (opts?.operationDeploymentId) {
|
|
117
|
+
this.setOperationDeploymentId(opts.operationDeploymentId);
|
|
118
|
+
}
|
|
119
|
+
if (opts?.feedOwnerId)
|
|
120
|
+
this.setFeedOwnerId(opts.feedOwnerId);
|
|
121
|
+
this.#nats = nats;
|
|
122
|
+
this.#version = opts?.version;
|
|
123
|
+
this.#log = (opts?.log ?? serviceRuntimeLogger).child({
|
|
124
|
+
lib: "trellis-service-runtime",
|
|
125
|
+
});
|
|
126
|
+
this.#transferSupport = opts?.transferSupport;
|
|
127
|
+
this.#operationOwnerId = ulid();
|
|
128
|
+
this.#operationConnectionId = opts?.operationConnectionId ?? "";
|
|
129
|
+
this.#operationDeploymentId = opts?.operationDeploymentId;
|
|
130
|
+
this.operations = {
|
|
131
|
+
get: (operationId) => AsyncResult.from((async () => {
|
|
132
|
+
const runtime = await this.#resolveOperation(operationId);
|
|
133
|
+
if (!runtime) {
|
|
134
|
+
return err(this.#operationNotFoundError(operationId));
|
|
135
|
+
}
|
|
136
|
+
return ok(runtime.snapshot);
|
|
137
|
+
})()),
|
|
138
|
+
started: (operationId) => this.#applyOwnedOperationUpdate(operationId, "running", {
|
|
139
|
+
event: { type: "started" },
|
|
140
|
+
}),
|
|
141
|
+
progress: (operationId, progress) => this.#applyOwnedOperationUpdate(operationId, "running", {
|
|
142
|
+
patch: { progress },
|
|
143
|
+
event: { type: "progress", progress },
|
|
144
|
+
}),
|
|
145
|
+
complete: (operationId, output) => this.#applyOwnedOperationUpdate(operationId, "completed", {
|
|
146
|
+
patch: { output },
|
|
147
|
+
event: { type: "completed" },
|
|
148
|
+
}),
|
|
149
|
+
fail: (operationId, error) => this.#applyOwnedOperationUpdate(operationId, "failed", {
|
|
150
|
+
patch: { error: error.toSerializable() },
|
|
151
|
+
event: { type: "failed" },
|
|
152
|
+
}),
|
|
153
|
+
cancel: (operationId) => this.#applyOwnedOperationUpdate(operationId, "cancelled", {
|
|
154
|
+
event: { type: "cancelled" },
|
|
155
|
+
}),
|
|
156
|
+
signals: (operationId) => this.#signals(operationId),
|
|
157
|
+
nextSignal: (operationId, name) => this.#nextSignal(operationId, name),
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
async *#signals(operationId) {
|
|
161
|
+
let cursor = 0;
|
|
162
|
+
while (true) {
|
|
163
|
+
const next = await this.#nextSignalAfter(operationId, cursor).take();
|
|
164
|
+
if (isErr(next)) {
|
|
165
|
+
throw next.error;
|
|
166
|
+
}
|
|
167
|
+
cursor = next.sequence;
|
|
168
|
+
yield next;
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
#nextSignal(operationId, name) {
|
|
172
|
+
return AsyncResult.from((async () => {
|
|
173
|
+
let cursor = 0;
|
|
174
|
+
while (true) {
|
|
175
|
+
const next = await this.#nextSignalAfter(operationId, cursor).take();
|
|
176
|
+
if (isErr(next))
|
|
177
|
+
return next;
|
|
178
|
+
cursor = next.sequence;
|
|
179
|
+
if (!name || next.signal === name)
|
|
180
|
+
return ok(next);
|
|
181
|
+
}
|
|
182
|
+
})());
|
|
183
|
+
}
|
|
184
|
+
#nextSignalAfter(operationId, afterSequence) {
|
|
185
|
+
return AsyncResult.from((async () => {
|
|
186
|
+
while (true) {
|
|
187
|
+
const runtime = await this.#resolveOperation(operationId);
|
|
188
|
+
if (!runtime) {
|
|
189
|
+
return err(this.#operationNotFoundError(operationId));
|
|
190
|
+
}
|
|
191
|
+
const durable = await this.loadOperationRecord(operationId);
|
|
192
|
+
if (durable && durable.revision > runtime.revision) {
|
|
193
|
+
runtime.revision = durable.revision;
|
|
194
|
+
runtime.snapshot = durable.snapshot;
|
|
195
|
+
runtime.sequence = durable.sequence;
|
|
196
|
+
runtime.signalSequence = durable.signalSequence;
|
|
197
|
+
runtime.signals = durable.signals;
|
|
198
|
+
runtime.terminal = durable.snapshot.state === "completed" ||
|
|
199
|
+
durable.snapshot.state === "failed" ||
|
|
200
|
+
durable.snapshot.state === "cancelled";
|
|
201
|
+
if (durable.cancelRequestedAt) {
|
|
202
|
+
runtime.cancelRequestedAt = durable.cancelRequestedAt;
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
const queued = runtime.signals.find((signal) => !signal.acknowledged && signal.sequence > afterSequence);
|
|
206
|
+
if (queued) {
|
|
207
|
+
return ok(queued);
|
|
208
|
+
}
|
|
209
|
+
if (runtime.terminal) {
|
|
210
|
+
return err(this.#operationAlreadyTerminalError(runtime));
|
|
211
|
+
}
|
|
212
|
+
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
213
|
+
}
|
|
214
|
+
})());
|
|
215
|
+
}
|
|
216
|
+
async #resolveOperation(operationId) {
|
|
217
|
+
const existing = this.#operations.get(operationId);
|
|
218
|
+
const durable = await this.loadOperationRecord(operationId);
|
|
219
|
+
if (!durable)
|
|
220
|
+
return null;
|
|
221
|
+
if (existing?.ownerInstanceId === this.#operationOwnerId &&
|
|
222
|
+
durable.ownerInstanceId === this.#operationOwnerId &&
|
|
223
|
+
durable.ownerEpoch === existing.ownerEpoch) {
|
|
224
|
+
if (durable.revision > existing.revision) {
|
|
225
|
+
existing.revision = durable.revision;
|
|
226
|
+
existing.leaseExpiresAt = durable.leaseExpiresAt;
|
|
227
|
+
existing.cancelRequestedAt = durable.cancelRequestedAt;
|
|
228
|
+
existing.snapshot = durable.snapshot;
|
|
229
|
+
existing.sequence = durable.sequence;
|
|
230
|
+
existing.signalSequence = durable.signalSequence;
|
|
231
|
+
existing.signals = durable.signals;
|
|
232
|
+
existing.terminal = durable.snapshot.state === "completed" ||
|
|
233
|
+
durable.snapshot.state === "failed" ||
|
|
234
|
+
durable.snapshot.state === "cancelled";
|
|
235
|
+
}
|
|
236
|
+
if (existing.cancelRequestedAt || existing.terminal) {
|
|
237
|
+
existing.cancellation.abort("operation cancelled");
|
|
238
|
+
}
|
|
239
|
+
return existing;
|
|
240
|
+
}
|
|
241
|
+
if (existing) {
|
|
242
|
+
if (existing.ownerInstanceId === this.#operationOwnerId) {
|
|
243
|
+
existing.cancellation.abort("operation superseded by another owner");
|
|
244
|
+
}
|
|
245
|
+
this.#operations.delete(operationId);
|
|
246
|
+
}
|
|
247
|
+
const cancellation = new AbortController();
|
|
248
|
+
const runtime = {
|
|
249
|
+
id: durable.invocationId,
|
|
250
|
+
service: durable.snapshot.service,
|
|
251
|
+
operation: durable.snapshot.operation,
|
|
252
|
+
callerSessionKey: durable.callerSessionKey,
|
|
253
|
+
invocationDigest: durable.invocationDigest,
|
|
254
|
+
caller: durable.caller,
|
|
255
|
+
creatorPrincipalId: durable.creatorPrincipalId,
|
|
256
|
+
creatorParticipantId: durable.creatorParticipantId,
|
|
257
|
+
apiId: durable.apiId,
|
|
258
|
+
input: durable.input,
|
|
259
|
+
revision: durable.revision,
|
|
260
|
+
ownerInstanceId: durable.ownerInstanceId,
|
|
261
|
+
ownerConnectionId: durable.ownerConnectionId,
|
|
262
|
+
ownerEpoch: durable.ownerEpoch,
|
|
263
|
+
leaseExpiresAt: durable.leaseExpiresAt,
|
|
264
|
+
...(durable.cancelRequestedAt
|
|
265
|
+
? { cancelRequestedAt: durable.cancelRequestedAt }
|
|
266
|
+
: {}),
|
|
267
|
+
...(durable.transferGrant
|
|
268
|
+
? { transferGrant: durable.transferGrant }
|
|
269
|
+
: {}),
|
|
270
|
+
snapshot: durable.snapshot,
|
|
271
|
+
sequence: durable.sequence,
|
|
272
|
+
terminal: durable.snapshot.state === "completed" ||
|
|
273
|
+
durable.snapshot.state === "failed" ||
|
|
274
|
+
durable.snapshot.state === "cancelled",
|
|
275
|
+
signalSequence: durable.signalSequence,
|
|
276
|
+
signals: durable.signals,
|
|
277
|
+
watchers: new Map(),
|
|
278
|
+
frameQueue: Promise.resolve(),
|
|
279
|
+
signalWaiters: new Set(),
|
|
280
|
+
cancellation,
|
|
281
|
+
};
|
|
282
|
+
if (runtime.cancelRequestedAt || runtime.terminal) {
|
|
283
|
+
cancellation.abort("operation cancelled");
|
|
284
|
+
}
|
|
285
|
+
this.#operations.set(operationId, runtime);
|
|
286
|
+
return runtime;
|
|
287
|
+
}
|
|
288
|
+
async #acquireOperation(durable) {
|
|
289
|
+
if (durable.snapshot.state === "completed" ||
|
|
290
|
+
durable.snapshot.state === "failed" ||
|
|
291
|
+
durable.snapshot.state === "cancelled" ||
|
|
292
|
+
Date.parse(durable.leaseExpiresAt) > Date.now())
|
|
293
|
+
return null;
|
|
294
|
+
const existing = await this.#resolveOperation(durable.invocationId);
|
|
295
|
+
if (!existing || existing.revision !== durable.revision)
|
|
296
|
+
return null;
|
|
297
|
+
// Supersede any prior local execution before taking a new epoch so an
|
|
298
|
+
// earlier execution's continuations cannot receive this execution's
|
|
299
|
+
// cancellation or borrow its owner fence.
|
|
300
|
+
if (existing.ownerInstanceId === this.#operationOwnerId) {
|
|
301
|
+
existing.cancellation.abort("operation superseded by new acquisition");
|
|
302
|
+
this.#operations.delete(existing.id);
|
|
303
|
+
}
|
|
304
|
+
const acquired = await this.#resolveOperation(durable.invocationId);
|
|
305
|
+
if (!acquired || acquired.revision !== durable.revision)
|
|
306
|
+
return null;
|
|
307
|
+
const runtime = acquired;
|
|
308
|
+
runtime.ownerInstanceId = this.#operationOwnerId;
|
|
309
|
+
runtime.ownerConnectionId = this.#operationConnectionId;
|
|
310
|
+
runtime.ownerEpoch = durable.ownerEpoch + 1;
|
|
311
|
+
runtime.leaseExpiresAt = new Date(Date.now() + 30_000).toISOString();
|
|
312
|
+
try {
|
|
313
|
+
await this.saveOperationRecord(runtime);
|
|
314
|
+
}
|
|
315
|
+
catch {
|
|
316
|
+
if (this.#operations.get(runtime.id) === runtime) {
|
|
317
|
+
this.#operations.delete(runtime.id);
|
|
318
|
+
}
|
|
319
|
+
return null;
|
|
320
|
+
}
|
|
321
|
+
runtime.reclaimed = true;
|
|
322
|
+
this.#activeOperationFences.set(runtime.id, this.#operationFence(runtime));
|
|
323
|
+
return runtime;
|
|
324
|
+
}
|
|
325
|
+
#operationFence(runtime) {
|
|
326
|
+
return {
|
|
327
|
+
ownerInstanceId: runtime.ownerInstanceId,
|
|
328
|
+
ownerConnectionId: runtime.ownerConnectionId,
|
|
329
|
+
ownerEpoch: runtime.ownerEpoch,
|
|
330
|
+
};
|
|
331
|
+
}
|
|
332
|
+
#ownsOperation(durable, fence) {
|
|
333
|
+
return durable.ownerInstanceId === fence.ownerInstanceId &&
|
|
334
|
+
durable.ownerConnectionId === fence.ownerConnectionId &&
|
|
335
|
+
durable.ownerEpoch === fence.ownerEpoch &&
|
|
336
|
+
Date.parse(durable.leaseExpiresAt) > Date.now();
|
|
337
|
+
}
|
|
338
|
+
#releaseOperationFence(operationId, fence) {
|
|
339
|
+
const active = this.#activeOperationFences.get(operationId);
|
|
340
|
+
if (active?.ownerInstanceId === fence.ownerInstanceId &&
|
|
341
|
+
active.ownerConnectionId === fence.ownerConnectionId &&
|
|
342
|
+
active.ownerEpoch === fence.ownerEpoch) {
|
|
343
|
+
this.#activeOperationFences.delete(operationId);
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
#applyOperationUpdate(runtime, fence, state, opts) {
|
|
347
|
+
return AsyncResult.from((async () => {
|
|
348
|
+
return await this.#queueOperationFrame(runtime, async () => {
|
|
349
|
+
for (let retry = 0;; retry++) {
|
|
350
|
+
const durable = await this.loadOperationRecord(runtime.id);
|
|
351
|
+
if (!durable || !this.#ownsOperation(durable, fence)) {
|
|
352
|
+
runtime.cancellation.abort("operation ownership lost");
|
|
353
|
+
return err(this.#operationAlreadyTerminalError(runtime));
|
|
354
|
+
}
|
|
355
|
+
if (durable.snapshot.state === "completed" ||
|
|
356
|
+
durable.snapshot.state === "failed" ||
|
|
357
|
+
durable.snapshot.state === "cancelled" ||
|
|
358
|
+
durable.cancelRequestedAt ||
|
|
359
|
+
runtime.cancellation.signal.aborted)
|
|
360
|
+
return err(this.#operationAlreadyTerminalError(runtime));
|
|
361
|
+
runtime.revision = durable.revision;
|
|
362
|
+
runtime.snapshot = durable.snapshot;
|
|
363
|
+
runtime.sequence = durable.sequence + 1;
|
|
364
|
+
runtime.signalSequence = durable.signalSequence;
|
|
365
|
+
runtime.signals = durable.signals;
|
|
366
|
+
runtime.leaseExpiresAt = new Date(Date.now() + 30_000).toISOString();
|
|
367
|
+
runtime.snapshot = buildRuntimeOperationSnapshot(runtime, state, opts.patch);
|
|
368
|
+
runtime.terminal = state === "completed" || state === "failed" ||
|
|
369
|
+
state === "cancelled";
|
|
370
|
+
try {
|
|
371
|
+
await this.saveOperationRecord(runtime);
|
|
372
|
+
break;
|
|
373
|
+
}
|
|
374
|
+
catch (cause) {
|
|
375
|
+
if (retry >= 3 || !isOperationRevisionConflict(cause)) {
|
|
376
|
+
runtime.cancellation.abort("operation ownership lost");
|
|
377
|
+
throw cause;
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
if (runtime.terminal) {
|
|
382
|
+
this.#releaseOperationFence(runtime.id, fence);
|
|
383
|
+
this.#rejectSignalWaiters(runtime);
|
|
384
|
+
}
|
|
385
|
+
return ok(runtime.snapshot);
|
|
386
|
+
});
|
|
387
|
+
})());
|
|
388
|
+
}
|
|
389
|
+
#applyOwnedOperationUpdate(operationId, state, opts) {
|
|
390
|
+
return AsyncResult.from((async () => {
|
|
391
|
+
const runtime = await this.#resolveOperation(operationId);
|
|
392
|
+
if (!runtime)
|
|
393
|
+
return err(this.#operationNotFoundError(operationId));
|
|
394
|
+
const fence = this.#activeOperationFences.get(operationId);
|
|
395
|
+
if (!fence)
|
|
396
|
+
return err(this.#operationNotFoundError(operationId));
|
|
397
|
+
return await this.#applyOperationUpdate(runtime, fence, state, opts);
|
|
398
|
+
})());
|
|
399
|
+
}
|
|
400
|
+
async #queueOperationFrame(runtime, task) {
|
|
401
|
+
const previous = runtime.frameQueue;
|
|
402
|
+
let release;
|
|
403
|
+
runtime.frameQueue = new Promise((resolve) => {
|
|
404
|
+
release = resolve;
|
|
405
|
+
});
|
|
406
|
+
await previous;
|
|
407
|
+
try {
|
|
408
|
+
return await task();
|
|
409
|
+
}
|
|
410
|
+
finally {
|
|
411
|
+
release?.();
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
#emitOperationUpdate(runtime, fence, ctx, update) {
|
|
415
|
+
return this.#queueOperationFrame(runtime, async () => {
|
|
416
|
+
const durable = await this.loadOperationRecord(runtime.id);
|
|
417
|
+
if (runtime.terminal || runtime.cancellation.signal.aborted || !durable ||
|
|
418
|
+
durable.cancelRequestedAt || durable.snapshot.state === "cancelled" ||
|
|
419
|
+
!this.#ownsOperation(durable, fence)) {
|
|
420
|
+
runtime.cancellation.abort("operation cancelled or ownership lost");
|
|
421
|
+
return err(this.#operationAlreadyTerminalError(runtime));
|
|
422
|
+
}
|
|
423
|
+
if (ctx.update === undefined) {
|
|
424
|
+
return err(new ValidationError({
|
|
425
|
+
errors: [{
|
|
426
|
+
path: "/",
|
|
427
|
+
message: `Operation '${runtime.operation}' does not declare updates`,
|
|
428
|
+
}],
|
|
429
|
+
context: { operation: runtime.operation },
|
|
430
|
+
}));
|
|
431
|
+
}
|
|
432
|
+
const parsed = this.#encodeOperationValue(ctx, "update", update).take();
|
|
433
|
+
if (isErr(parsed))
|
|
434
|
+
return parsed;
|
|
435
|
+
const key = `${runtime.id}:${fence.ownerEpoch}`;
|
|
436
|
+
const sequence = (this.#operationUpdateSequences.get(key) ?? 0) + 1;
|
|
437
|
+
this.#operationUpdateSequences.set(key, sequence);
|
|
438
|
+
const metadata = headers();
|
|
439
|
+
const occurredAt = new Date().toISOString();
|
|
440
|
+
const subject = `${ctx.subject}.updates.${runtime.id}`;
|
|
441
|
+
const payload = JSON.stringify({
|
|
442
|
+
operationId: runtime.id,
|
|
443
|
+
apiId: runtime.apiId,
|
|
444
|
+
operation: runtime.operation,
|
|
445
|
+
deploymentId: this.#operationDeploymentId,
|
|
446
|
+
ownerExecutorId: fence.ownerInstanceId,
|
|
447
|
+
ownerConnectionId: runtime.ownerConnectionId,
|
|
448
|
+
ownerEpoch: String(fence.ownerEpoch),
|
|
449
|
+
sequence: String(sequence),
|
|
450
|
+
occurredAt,
|
|
451
|
+
update: parsed,
|
|
452
|
+
});
|
|
453
|
+
const contextDigest = typeof this.auth.contextDigest === "function"
|
|
454
|
+
? this.auth.contextDigest()
|
|
455
|
+
: this.auth.contextDigest;
|
|
456
|
+
const cache = this.auth.authorizationProviderCache;
|
|
457
|
+
if (!contextDigest || !cache) {
|
|
458
|
+
return err(new AuthError({ reason: "authorization_unavailable" }));
|
|
459
|
+
}
|
|
460
|
+
const ownContext = await cache.resolveContext(contextDigest);
|
|
461
|
+
const reply = createInbox(ownContext.context.inboxPrefix);
|
|
462
|
+
const authorization = await this.createRequestProof(subject, payload, reply);
|
|
463
|
+
metadata.set("proof", authorization.proof);
|
|
464
|
+
metadata.set("iat", String(authorization.iat));
|
|
465
|
+
metadata.set("request-id", authorization.requestId);
|
|
466
|
+
metadata.set("authorization-context", authorization.contextDigest);
|
|
467
|
+
metadata.set("session-key", this.auth.sessionKey);
|
|
468
|
+
this.#log.info({
|
|
469
|
+
operationId: runtime.id,
|
|
470
|
+
operation: runtime.operation,
|
|
471
|
+
ownerExecutorId: fence.ownerInstanceId,
|
|
472
|
+
ownerConnectionId: runtime.ownerConnectionId,
|
|
473
|
+
ownerEpoch: fence.ownerEpoch,
|
|
474
|
+
sequence,
|
|
475
|
+
subject,
|
|
476
|
+
reply,
|
|
477
|
+
}, "Publishing transient operation update");
|
|
478
|
+
await this.#nats.publish(subject, payload, { headers: metadata, reply });
|
|
479
|
+
await this.#nats.flush();
|
|
480
|
+
this.#log.info({
|
|
481
|
+
operationId: runtime.id,
|
|
482
|
+
ownerEpoch: fence.ownerEpoch,
|
|
483
|
+
sequence,
|
|
484
|
+
}, "Published transient operation update");
|
|
485
|
+
return ok(undefined);
|
|
486
|
+
});
|
|
487
|
+
}
|
|
488
|
+
#encodeOperationValue(ctx, kind, value) {
|
|
489
|
+
const schema = kind === "progress"
|
|
490
|
+
? ctx.progress
|
|
491
|
+
: kind === "update"
|
|
492
|
+
? ctx.update
|
|
493
|
+
: ctx.output;
|
|
494
|
+
if (schema === undefined)
|
|
495
|
+
return ok(value);
|
|
496
|
+
try {
|
|
497
|
+
if (schema && typeof schema === "object" &&
|
|
498
|
+
typeof Reflect.get(schema, "encode") === "function") {
|
|
499
|
+
return ok(Reflect.get(schema, "encode").call(schema, value));
|
|
500
|
+
}
|
|
501
|
+
const encoded = encodeSchema(schema, value).take();
|
|
502
|
+
return isErr(encoded) ? encoded : ok(JSON.parse(encoded));
|
|
503
|
+
}
|
|
504
|
+
catch (cause) {
|
|
505
|
+
return err(new UnexpectedError({ cause }));
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
#decodeOperationValue(ctx, kind, value) {
|
|
509
|
+
const schema = kind === "progress"
|
|
510
|
+
? ctx.progress
|
|
511
|
+
: kind === "update"
|
|
512
|
+
? ctx.update
|
|
513
|
+
: ctx.output;
|
|
514
|
+
if (schema === undefined)
|
|
515
|
+
return ok(value);
|
|
516
|
+
try {
|
|
517
|
+
if (schema && typeof schema === "object" &&
|
|
518
|
+
typeof Reflect.get(schema, "decode") === "function") {
|
|
519
|
+
return ok(Reflect.get(schema, "decode").call(schema, value));
|
|
520
|
+
}
|
|
521
|
+
return parseSchema(schema, value);
|
|
522
|
+
}
|
|
523
|
+
catch (cause) {
|
|
524
|
+
return err(new UnexpectedError({ cause }));
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
#applyControlledOperationUpdate(runtime, fence, ctx, state, opts) {
|
|
528
|
+
return AsyncResult.from((async () => {
|
|
529
|
+
if (runtime.ownerInstanceId !== fence.ownerInstanceId ||
|
|
530
|
+
runtime.ownerEpoch !== fence.ownerEpoch) {
|
|
531
|
+
return err(new UnexpectedError({
|
|
532
|
+
cause: new Error("operation update rejected by owner fence"),
|
|
533
|
+
}));
|
|
534
|
+
}
|
|
535
|
+
runtime.leaseExpiresAt = new Date(Date.now() + 30_000).toISOString();
|
|
536
|
+
if (opts.patch?.progress !== undefined) {
|
|
537
|
+
const parsed = this.#encodeOperationValue(ctx, "progress", opts.patch.progress).take();
|
|
538
|
+
if (isErr(parsed))
|
|
539
|
+
return parsed;
|
|
540
|
+
opts.patch.progress = parsed;
|
|
541
|
+
if ("progress" in opts.event)
|
|
542
|
+
opts.event.progress = parsed;
|
|
543
|
+
}
|
|
544
|
+
if (opts.patch?.output !== undefined) {
|
|
545
|
+
const parsed = this.#encodeOperationValue(ctx, "output", opts.patch.output).take();
|
|
546
|
+
if (isErr(parsed))
|
|
547
|
+
return parsed;
|
|
548
|
+
opts.patch.output = parsed;
|
|
549
|
+
}
|
|
550
|
+
return await this.#applyOperationUpdate(runtime, fence, state, opts);
|
|
551
|
+
})());
|
|
552
|
+
}
|
|
553
|
+
#controlOperation(operation, ctx, operationId) {
|
|
554
|
+
return AsyncResult.from((async () => {
|
|
555
|
+
const runtime = await this.#resolveOperation(operationId);
|
|
556
|
+
if (!runtime) {
|
|
557
|
+
return err(this.#operationNotFoundError(operationId));
|
|
558
|
+
}
|
|
559
|
+
if (!this.#matchesOperationRoute(runtime, operation, ctx)) {
|
|
560
|
+
return err(this.#operationNotFoundError(operationId));
|
|
561
|
+
}
|
|
562
|
+
const fence = this.#activeOperationFences.get(operationId);
|
|
563
|
+
if (!fence)
|
|
564
|
+
return err(this.#operationNotFoundError(operationId));
|
|
565
|
+
return ok(this.#makeControlledOperation(runtime, fence, ctx));
|
|
566
|
+
})());
|
|
567
|
+
}
|
|
568
|
+
#makeControlledOperation(runtime, fence, ctx) {
|
|
569
|
+
return {
|
|
570
|
+
id: runtime.id,
|
|
571
|
+
started: () => this.#applyControlledOperationUpdate(runtime, fence, ctx, "running", {
|
|
572
|
+
event: { type: "started" },
|
|
573
|
+
}),
|
|
574
|
+
progress: (value) => this.#applyControlledOperationUpdate(runtime, fence, ctx, "running", {
|
|
575
|
+
patch: { progress: value },
|
|
576
|
+
event: { type: "progress", progress: value },
|
|
577
|
+
}),
|
|
578
|
+
emitUpdate: (value) => AsyncResult.from(this.#emitOperationUpdate(runtime, fence, ctx, value)),
|
|
579
|
+
complete: (value) => this.#applyControlledOperationUpdate(runtime, fence, ctx, "completed", {
|
|
580
|
+
patch: { output: value },
|
|
581
|
+
event: { type: "completed" },
|
|
582
|
+
}),
|
|
583
|
+
fail: (error) => this.#applyControlledOperationUpdate(runtime, fence, ctx, "failed", {
|
|
584
|
+
patch: { error: error.toSerializable() },
|
|
585
|
+
event: { type: "failed" },
|
|
586
|
+
}),
|
|
587
|
+
cancel: () => this.#applyControlledOperationUpdate(runtime, fence, ctx, "cancelled", {
|
|
588
|
+
event: { type: "cancelled" },
|
|
589
|
+
}),
|
|
590
|
+
attach: (job) => AsyncResult.from((async () => {
|
|
591
|
+
const waited = await job.wait();
|
|
592
|
+
const waitedValue = waited.take();
|
|
593
|
+
if (isErr(waitedValue)) {
|
|
594
|
+
return err(new UnexpectedError({ cause: waitedValue.error }));
|
|
595
|
+
}
|
|
596
|
+
const finalRuntime = await this.#resolveOperation(runtime.id);
|
|
597
|
+
if (!finalRuntime || !finalRuntime.terminal) {
|
|
598
|
+
return err(new UnexpectedError({
|
|
599
|
+
cause: new Error("attached job completed without terminal operation state"),
|
|
600
|
+
}));
|
|
601
|
+
}
|
|
602
|
+
return ok(finalRuntime.snapshot);
|
|
603
|
+
})()),
|
|
604
|
+
signals: () => this.#signals(runtime.id),
|
|
605
|
+
nextSignal: (name) => this.#nextSignal(runtime.id, name),
|
|
606
|
+
acknowledgeSignal: (sequence) => this.#acknowledgeSignal(runtime, fence, sequence),
|
|
607
|
+
defer: () => ({ kind: "deferred" }),
|
|
608
|
+
};
|
|
609
|
+
}
|
|
610
|
+
#operationNotFoundError(operationId) {
|
|
611
|
+
return new OperationNotFoundError({ operationId });
|
|
612
|
+
}
|
|
613
|
+
#operationAlreadyTerminalError(runtime) {
|
|
614
|
+
return new OperationAlreadyTerminalError({
|
|
615
|
+
operationId: runtime.id,
|
|
616
|
+
state: runtime.snapshot.state,
|
|
617
|
+
operation: runtime.operation,
|
|
618
|
+
service: runtime.service,
|
|
619
|
+
});
|
|
620
|
+
}
|
|
621
|
+
#matchesOperationRoute(runtime, operation, ctx, caller) {
|
|
622
|
+
const invocationId = "invocationId" in runtime
|
|
623
|
+
? runtime.invocationId
|
|
624
|
+
: runtime.id;
|
|
625
|
+
const apiId = `${ctx.permissions?.invoke.apiId ?? ""}@${ctx.permissions?.invoke.apiVersion ?? ""}`;
|
|
626
|
+
return runtime.apiId === apiId && runtime.operation === operation &&
|
|
627
|
+
runtime.snapshot.id === invocationId &&
|
|
628
|
+
runtime.snapshot.operation === operation &&
|
|
629
|
+
(!caller ||
|
|
630
|
+
(runtime.creatorPrincipalId === caller.principalId &&
|
|
631
|
+
runtime.creatorParticipantId === caller.participantId));
|
|
632
|
+
}
|
|
633
|
+
#rejectSignalWaiters(runtime) {
|
|
634
|
+
const result = err(this.#operationAlreadyTerminalError(runtime));
|
|
635
|
+
for (const waiter of runtime.signalWaiters) {
|
|
636
|
+
waiter(result);
|
|
637
|
+
}
|
|
638
|
+
runtime.signalWaiters.clear();
|
|
639
|
+
}
|
|
640
|
+
async #acceptSignal(runtime, ctx, control, requestId, retry = 0) {
|
|
641
|
+
if (runtime.terminal) {
|
|
642
|
+
return err(this.#operationAlreadyTerminalError(runtime));
|
|
643
|
+
}
|
|
644
|
+
const replay = runtime.signals.find((signal) => signal.requestId === requestId);
|
|
645
|
+
if (replay) {
|
|
646
|
+
if (replay.signal !== control.signal ||
|
|
647
|
+
canonicalizeJson((replay.input ?? null)) !==
|
|
648
|
+
canonicalizeJson((control.input ?? null))) {
|
|
649
|
+
return err(new ValidationError({
|
|
650
|
+
errors: [{
|
|
651
|
+
path: "/requestId",
|
|
652
|
+
message: "Signal request id was already accepted with different input",
|
|
653
|
+
}],
|
|
654
|
+
}));
|
|
655
|
+
}
|
|
656
|
+
return ok({
|
|
657
|
+
kind: "signal-accepted",
|
|
658
|
+
operationId: runtime.id,
|
|
659
|
+
signal: replay.signal,
|
|
660
|
+
signalSequence: replay.sequence,
|
|
661
|
+
acceptedAt: replay.acceptedAt,
|
|
662
|
+
snapshot: runtime.snapshot,
|
|
663
|
+
});
|
|
664
|
+
}
|
|
665
|
+
if (runtime.signals.length >= 100) {
|
|
666
|
+
return err(new ValidationError({
|
|
667
|
+
errors: [{
|
|
668
|
+
path: "/signal",
|
|
669
|
+
message: "Operation signal limit exceeded",
|
|
670
|
+
}],
|
|
671
|
+
}));
|
|
672
|
+
}
|
|
673
|
+
const descriptor = ctx.signals?.[control.signal];
|
|
674
|
+
if (!descriptor) {
|
|
675
|
+
return err(new ValidationError({
|
|
676
|
+
errors: [{
|
|
677
|
+
path: "/signal",
|
|
678
|
+
message: `Unknown operation signal '${control.signal}'`,
|
|
679
|
+
}],
|
|
680
|
+
context: { operation: runtime.operation, signal: control.signal },
|
|
681
|
+
}));
|
|
682
|
+
}
|
|
683
|
+
const input = control.input;
|
|
684
|
+
const parsed = parseSchema(descriptor.input, input).take();
|
|
685
|
+
if (isErr(parsed)) {
|
|
686
|
+
return err(parsed.error);
|
|
687
|
+
}
|
|
688
|
+
const encodedInput = new TextEncoder().encode(JSON.stringify(control.input ?? null)).byteLength;
|
|
689
|
+
if (encodedInput > 64 * 1024) {
|
|
690
|
+
return err(new ValidationError({
|
|
691
|
+
errors: [{
|
|
692
|
+
path: "/input",
|
|
693
|
+
message: "Operation signal payload exceeds 64 KiB",
|
|
694
|
+
}],
|
|
695
|
+
}));
|
|
696
|
+
}
|
|
697
|
+
runtime.signalSequence += 1;
|
|
698
|
+
const acceptedAt = new Date().toISOString();
|
|
699
|
+
const signal = {
|
|
700
|
+
operationId: runtime.id,
|
|
701
|
+
sequence: runtime.signalSequence,
|
|
702
|
+
requestId,
|
|
703
|
+
signal: control.signal,
|
|
704
|
+
...(control.input !== undefined ? { input: control.input } : {}),
|
|
705
|
+
acceptedAt,
|
|
706
|
+
acknowledged: false,
|
|
707
|
+
};
|
|
708
|
+
runtime.signals.push(signal);
|
|
709
|
+
try {
|
|
710
|
+
await this.saveOperationRecord(runtime);
|
|
711
|
+
}
|
|
712
|
+
catch (cause) {
|
|
713
|
+
this.#operations.delete(runtime.id);
|
|
714
|
+
if (retry < 3 && isOperationRevisionConflict(cause)) {
|
|
715
|
+
const current = await this.#resolveOperation(runtime.id);
|
|
716
|
+
if (current) {
|
|
717
|
+
return await this.#acceptSignal(current, ctx, control, requestId, retry + 1);
|
|
718
|
+
}
|
|
719
|
+
}
|
|
720
|
+
throw cause;
|
|
721
|
+
}
|
|
722
|
+
const result = ok(signal);
|
|
723
|
+
for (const waiter of runtime.signalWaiters) {
|
|
724
|
+
waiter(result);
|
|
725
|
+
}
|
|
726
|
+
return ok({
|
|
727
|
+
kind: "signal-accepted",
|
|
728
|
+
operationId: runtime.id,
|
|
729
|
+
signal: signal.signal,
|
|
730
|
+
signalSequence: signal.sequence,
|
|
731
|
+
acceptedAt,
|
|
732
|
+
snapshot: runtime.snapshot,
|
|
733
|
+
});
|
|
734
|
+
}
|
|
735
|
+
#acknowledgeSignal(runtime, fence, sequence) {
|
|
736
|
+
return AsyncResult.from((async () => {
|
|
737
|
+
return await this.#queueOperationFrame(runtime, async () => {
|
|
738
|
+
for (let retry = 0;; retry++) {
|
|
739
|
+
const durable = await this.loadOperationRecord(runtime.id);
|
|
740
|
+
if (!durable || !this.#ownsOperation(durable, fence) ||
|
|
741
|
+
durable.snapshot.state === "completed" ||
|
|
742
|
+
durable.snapshot.state === "failed" ||
|
|
743
|
+
durable.snapshot.state === "cancelled" || durable.cancelRequestedAt) {
|
|
744
|
+
runtime.cancellation.abort("operation ownership lost");
|
|
745
|
+
return err(this.#operationAlreadyTerminalError(runtime));
|
|
746
|
+
}
|
|
747
|
+
runtime.revision = durable.revision;
|
|
748
|
+
runtime.snapshot = durable.snapshot;
|
|
749
|
+
runtime.sequence = durable.sequence;
|
|
750
|
+
runtime.signalSequence = durable.signalSequence;
|
|
751
|
+
runtime.signals = durable.signals;
|
|
752
|
+
const signal = runtime.signals.find((item) => item.sequence === sequence);
|
|
753
|
+
if (!signal) {
|
|
754
|
+
return err(new ValidationError({
|
|
755
|
+
errors: [{
|
|
756
|
+
path: "/sequence",
|
|
757
|
+
message: `Operation signal ${sequence} was not found`,
|
|
758
|
+
}],
|
|
759
|
+
}));
|
|
760
|
+
}
|
|
761
|
+
if (signal.acknowledged)
|
|
762
|
+
return ok(undefined);
|
|
763
|
+
signal.acknowledged = true;
|
|
764
|
+
try {
|
|
765
|
+
await this.saveOperationRecord(runtime);
|
|
766
|
+
return ok(undefined);
|
|
767
|
+
}
|
|
768
|
+
catch (cause) {
|
|
769
|
+
if (retry >= 3 || !isOperationRevisionConflict(cause)) {
|
|
770
|
+
runtime.cancellation.abort("operation ownership lost");
|
|
771
|
+
throw cause;
|
|
772
|
+
}
|
|
773
|
+
}
|
|
774
|
+
}
|
|
775
|
+
});
|
|
776
|
+
})());
|
|
777
|
+
}
|
|
778
|
+
async #authenticateOperationMessage(msg, ctx, parseInput, permission = ctx.permissions?.invoke, requiredCapabilities = ctx.callerCapabilities ?? []) {
|
|
779
|
+
const jsonData = safeJson(msg).take();
|
|
780
|
+
if (isErr(jsonData))
|
|
781
|
+
return jsonData;
|
|
782
|
+
let parsedInput;
|
|
783
|
+
let invocationId;
|
|
784
|
+
if (parseInput) {
|
|
785
|
+
if (!jsonData || typeof jsonData !== "object" || Array.isArray(jsonData) ||
|
|
786
|
+
typeof jsonData.invocationId !==
|
|
787
|
+
"string" ||
|
|
788
|
+
!/^[0-9A-HJKMNP-TV-Z]{26}$/u.test(jsonData.invocationId) || !("input" in jsonData)) {
|
|
789
|
+
return err(new ValidationError({
|
|
790
|
+
errors: [{
|
|
791
|
+
path: "",
|
|
792
|
+
message: "Operation start requires a ULID invocationId and input",
|
|
793
|
+
}],
|
|
794
|
+
}));
|
|
795
|
+
}
|
|
796
|
+
invocationId = jsonData.invocationId;
|
|
797
|
+
const parsedInputResult = parseSchema(ctx.input, jsonData.input).take();
|
|
798
|
+
if (isErr(parsedInputResult)) {
|
|
799
|
+
return err(parsedInputResult.error);
|
|
800
|
+
}
|
|
801
|
+
parsedInput = parsedInputResult;
|
|
802
|
+
}
|
|
803
|
+
else {
|
|
804
|
+
parsedInput = jsonData;
|
|
805
|
+
}
|
|
806
|
+
const auth = await verifyLocalAuthorization({
|
|
807
|
+
kind: "request",
|
|
808
|
+
cache: this.auth.authorizationProviderCache,
|
|
809
|
+
message: msg,
|
|
810
|
+
permission,
|
|
811
|
+
requiredCapabilities,
|
|
812
|
+
});
|
|
813
|
+
const authValue = auth.take();
|
|
814
|
+
if (isErr(authValue))
|
|
815
|
+
return err(authValue.error);
|
|
816
|
+
return ok({
|
|
817
|
+
input: parsedInput,
|
|
818
|
+
...(invocationId ? { invocationId } : {}),
|
|
819
|
+
caller: authValue,
|
|
820
|
+
sessionKey: authValue.sessionKey,
|
|
821
|
+
});
|
|
822
|
+
}
|
|
823
|
+
#ensureOperationControlLoop(operation, ctx) {
|
|
824
|
+
const controlSubject = `${ctx.subject}.control`;
|
|
825
|
+
if (this.#mountedOperationControls.has(controlSubject)) {
|
|
826
|
+
return;
|
|
827
|
+
}
|
|
828
|
+
this.#mountedOperationControls.add(controlSubject);
|
|
829
|
+
const publishFrame = async (reply, frame) => {
|
|
830
|
+
await this.#nats.publish(reply, JSON.stringify(frame));
|
|
831
|
+
};
|
|
832
|
+
const publishSnapshot = async (reply, snapshot) => {
|
|
833
|
+
await publishFrame(reply, { kind: "snapshot", snapshot });
|
|
834
|
+
};
|
|
835
|
+
const respondControlError = (msg, error) => {
|
|
836
|
+
const trellisError = error instanceof BaseError
|
|
837
|
+
? error
|
|
838
|
+
: new UnexpectedError({ cause: error });
|
|
839
|
+
recordOperationServiceError(trellisError, {
|
|
840
|
+
operation,
|
|
841
|
+
phase: "control",
|
|
842
|
+
});
|
|
843
|
+
msg.respond(JSON.stringify({
|
|
844
|
+
kind: "error",
|
|
845
|
+
error: trellisError.toSerializable(),
|
|
846
|
+
}));
|
|
847
|
+
};
|
|
848
|
+
const controlSub = this.#nats.subscribe(controlSubject, {
|
|
849
|
+
queue: routeQueueGroup(controlSubject),
|
|
850
|
+
});
|
|
851
|
+
void (async () => {
|
|
852
|
+
for await (const msg of controlSub) {
|
|
853
|
+
const request = safeJson(msg).take();
|
|
854
|
+
if (isErr(request)) {
|
|
855
|
+
respondControlError(msg, request.error);
|
|
856
|
+
continue;
|
|
857
|
+
}
|
|
858
|
+
if (!request ||
|
|
859
|
+
typeof request !== "object" ||
|
|
860
|
+
!["get", "watch", "signal", "cancel"].includes(String(request.action)) ||
|
|
861
|
+
typeof request.operationId !==
|
|
862
|
+
"string" ||
|
|
863
|
+
(request.action === "signal" &&
|
|
864
|
+
typeof request.signal !== "string")) {
|
|
865
|
+
respondControlError(msg, new UnexpectedError({
|
|
866
|
+
cause: new Error("Invalid operation control request"),
|
|
867
|
+
}));
|
|
868
|
+
continue;
|
|
869
|
+
}
|
|
870
|
+
const control = request;
|
|
871
|
+
if (control.action === "signal" && !ctx.signals?.[control.signal]) {
|
|
872
|
+
respondControlError(msg, new ValidationError({
|
|
873
|
+
errors: [{
|
|
874
|
+
path: "/signal",
|
|
875
|
+
message: `Unknown operation signal '${control.signal}'`,
|
|
876
|
+
}],
|
|
877
|
+
}));
|
|
878
|
+
continue;
|
|
879
|
+
}
|
|
880
|
+
let permission = ctx.permissions?.observe;
|
|
881
|
+
let capabilities = ctx.observeCapabilities ?? [];
|
|
882
|
+
if (control.action === "cancel") {
|
|
883
|
+
permission = ctx.permissions?.cancel;
|
|
884
|
+
capabilities = ctx.cancelCapabilities ?? [];
|
|
885
|
+
}
|
|
886
|
+
else if (control.action === "signal") {
|
|
887
|
+
permission = ctx.permissions?.control[control.signal];
|
|
888
|
+
capabilities = ctx.controlCapabilities ?? [];
|
|
889
|
+
}
|
|
890
|
+
const validated = await this.#authenticateOperationMessage(msg, ctx, false, permission, capabilities);
|
|
891
|
+
const value = validated.take();
|
|
892
|
+
if (isErr(value)) {
|
|
893
|
+
respondControlError(msg, value.error);
|
|
894
|
+
continue;
|
|
895
|
+
}
|
|
896
|
+
let runtime;
|
|
897
|
+
try {
|
|
898
|
+
runtime = await this.#resolveOperation(control.operationId);
|
|
899
|
+
}
|
|
900
|
+
catch (cause) {
|
|
901
|
+
respondControlError(msg, cause instanceof Error ? cause : new Error(String(cause)));
|
|
902
|
+
continue;
|
|
903
|
+
}
|
|
904
|
+
if (!runtime) {
|
|
905
|
+
respondControlError(msg, this.#operationNotFoundError(control.operationId));
|
|
906
|
+
continue;
|
|
907
|
+
}
|
|
908
|
+
if (!this.#matchesOperationRoute(runtime, operation, ctx, value.caller)) {
|
|
909
|
+
respondControlError(msg, this.#operationNotFoundError(control.operationId));
|
|
910
|
+
continue;
|
|
911
|
+
}
|
|
912
|
+
const snapshot = runtime.snapshot;
|
|
913
|
+
if (control.action === "watch") {
|
|
914
|
+
if (msg.reply) {
|
|
915
|
+
const reply = msg.reply;
|
|
916
|
+
const updateSub = control.includeUpdates
|
|
917
|
+
? this.#nats.subscribe(`${ctx.subject}.updates.${control.operationId}`)
|
|
918
|
+
: undefined;
|
|
919
|
+
void (async () => {
|
|
920
|
+
let ownerEpoch = 0;
|
|
921
|
+
let updateSequence = 0;
|
|
922
|
+
let revision = runtime.revision;
|
|
923
|
+
const durableWatch = (await (await this.operationStoreHandle())
|
|
924
|
+
.watch(control.operationId).orThrow())[Symbol.asyncIterator]();
|
|
925
|
+
if (updateSub)
|
|
926
|
+
await this.#nats.flush();
|
|
927
|
+
await publishSnapshot(reply, runtime.snapshot);
|
|
928
|
+
const updates = updateSub
|
|
929
|
+
? (async () => {
|
|
930
|
+
for await (const updateMsg of updateSub) {
|
|
931
|
+
try {
|
|
932
|
+
const cache = this.auth.authorizationProviderCache;
|
|
933
|
+
if (!cache)
|
|
934
|
+
throw new Error("authorization unavailable");
|
|
935
|
+
await cache.resolveContext(value.caller.contextDigest);
|
|
936
|
+
}
|
|
937
|
+
catch {
|
|
938
|
+
updateSub.unsubscribe();
|
|
939
|
+
await durableWatch.return?.();
|
|
940
|
+
break;
|
|
941
|
+
}
|
|
942
|
+
const providerAuthorization = await verifyLocalAuthorization({
|
|
943
|
+
kind: "request",
|
|
944
|
+
cache: this.auth.authorizationProviderCache,
|
|
945
|
+
message: updateMsg,
|
|
946
|
+
permission: undefined,
|
|
947
|
+
requiredCapabilities: [],
|
|
948
|
+
identityOnly: true,
|
|
949
|
+
});
|
|
950
|
+
const provider = providerAuthorization.take();
|
|
951
|
+
if (isErr(provider))
|
|
952
|
+
continue;
|
|
953
|
+
const decoded = safeJson(updateMsg).take();
|
|
954
|
+
if (isErr(decoded) || !decoded ||
|
|
955
|
+
typeof decoded !== "object" || Array.isArray(decoded))
|
|
956
|
+
continue;
|
|
957
|
+
const envelope = decoded;
|
|
958
|
+
const current = await this.loadOperationRecord(control.operationId);
|
|
959
|
+
const executor = envelope.ownerExecutorId;
|
|
960
|
+
const connectionId = envelope.ownerConnectionId;
|
|
961
|
+
const epoch = typeof envelope.ownerEpoch === "string" &&
|
|
962
|
+
CANONICAL_POSITIVE_INTEGER.test(envelope.ownerEpoch)
|
|
963
|
+
? Number(envelope.ownerEpoch)
|
|
964
|
+
: NaN;
|
|
965
|
+
const sequence = typeof envelope.sequence === "string" &&
|
|
966
|
+
CANONICAL_POSITIVE_INTEGER.test(envelope.sequence)
|
|
967
|
+
? Number(envelope.sequence)
|
|
968
|
+
: NaN;
|
|
969
|
+
const parsedUpdate = this.#decodeOperationValue(ctx, "update", envelope.update).take();
|
|
970
|
+
if (isErr(parsedUpdate) ||
|
|
971
|
+
typeof executor !== "string" ||
|
|
972
|
+
typeof connectionId !== "string" ||
|
|
973
|
+
typeof envelope.occurredAt !== "string" ||
|
|
974
|
+
envelope.operationId !== control.operationId ||
|
|
975
|
+
envelope.apiId !== current?.apiId ||
|
|
976
|
+
envelope.operation !== current?.operation ||
|
|
977
|
+
envelope.deploymentId !== this.#operationDeploymentId ||
|
|
978
|
+
!current ||
|
|
979
|
+
!this.#matchesOperationRoute(current, operation, ctx, value.caller) || current.snapshot.state === "completed" ||
|
|
980
|
+
current.snapshot.state === "failed" ||
|
|
981
|
+
current.snapshot.state === "cancelled" ||
|
|
982
|
+
current.cancelRequestedAt ||
|
|
983
|
+
Date.parse(current.leaseExpiresAt) <= Date.now() ||
|
|
984
|
+
current.ownerEpoch !== epoch ||
|
|
985
|
+
current.ownerInstanceId !== executor ||
|
|
986
|
+
current.ownerConnectionId !== connectionId ||
|
|
987
|
+
provider.participantId !== this.contractId ||
|
|
988
|
+
provider.deploymentId !== this.#operationDeploymentId ||
|
|
989
|
+
provider.connectionId !== current.ownerConnectionId ||
|
|
990
|
+
!Number.isSafeInteger(epoch) ||
|
|
991
|
+
!Number.isSafeInteger(sequence) ||
|
|
992
|
+
epoch < ownerEpoch ||
|
|
993
|
+
(epoch === ownerEpoch && sequence <= updateSequence))
|
|
994
|
+
continue;
|
|
995
|
+
ownerEpoch = epoch;
|
|
996
|
+
updateSequence = sequence;
|
|
997
|
+
const wireUpdate = this.#encodeOperationValue(ctx, "update", parsedUpdate).take();
|
|
998
|
+
if (isErr(wireUpdate))
|
|
999
|
+
continue;
|
|
1000
|
+
await publishFrame(reply, {
|
|
1001
|
+
kind: "event",
|
|
1002
|
+
sequence: current.sequence,
|
|
1003
|
+
event: {
|
|
1004
|
+
type: "update",
|
|
1005
|
+
update: wireUpdate,
|
|
1006
|
+
snapshot: current.snapshot,
|
|
1007
|
+
},
|
|
1008
|
+
});
|
|
1009
|
+
}
|
|
1010
|
+
})()
|
|
1011
|
+
: Promise.resolve();
|
|
1012
|
+
try {
|
|
1013
|
+
for (;;) {
|
|
1014
|
+
const next = await durableWatch.next();
|
|
1015
|
+
if (next.done)
|
|
1016
|
+
break;
|
|
1017
|
+
const changed = next.value;
|
|
1018
|
+
const durable = changed.take();
|
|
1019
|
+
if (isErr(durable))
|
|
1020
|
+
throw durable.error;
|
|
1021
|
+
if (!durable.value)
|
|
1022
|
+
break;
|
|
1023
|
+
if (durable.value.revision <= revision)
|
|
1024
|
+
continue;
|
|
1025
|
+
revision = durable.value.revision;
|
|
1026
|
+
if (!this.#matchesOperationRoute(durable.value, operation, ctx, value.caller))
|
|
1027
|
+
break;
|
|
1028
|
+
try {
|
|
1029
|
+
const cache = this.auth.authorizationProviderCache;
|
|
1030
|
+
if (!cache)
|
|
1031
|
+
throw new Error("authorization unavailable");
|
|
1032
|
+
await cache.resolveContext(value.caller.contextDigest);
|
|
1033
|
+
}
|
|
1034
|
+
catch {
|
|
1035
|
+
break;
|
|
1036
|
+
}
|
|
1037
|
+
await publishSnapshot(reply, durable.value.snapshot);
|
|
1038
|
+
if (durable.value.snapshot.state === "completed" ||
|
|
1039
|
+
durable.value.snapshot.state === "failed" ||
|
|
1040
|
+
durable.value.snapshot.state === "cancelled")
|
|
1041
|
+
break;
|
|
1042
|
+
}
|
|
1043
|
+
}
|
|
1044
|
+
finally {
|
|
1045
|
+
updateSub?.unsubscribe();
|
|
1046
|
+
await updates;
|
|
1047
|
+
}
|
|
1048
|
+
})().catch((error) => {
|
|
1049
|
+
if (!this.#nats.isClosed()) {
|
|
1050
|
+
this.#log.warn({ error, operation: String(operation) }, "Operation watch stopped");
|
|
1051
|
+
}
|
|
1052
|
+
});
|
|
1053
|
+
}
|
|
1054
|
+
continue;
|
|
1055
|
+
}
|
|
1056
|
+
if (control.action === "get") {
|
|
1057
|
+
msg.respond(JSON.stringify({ kind: "snapshot", snapshot }));
|
|
1058
|
+
continue;
|
|
1059
|
+
}
|
|
1060
|
+
if (control.action === "cancel") {
|
|
1061
|
+
if (runtime.terminal) {
|
|
1062
|
+
respondControlError(msg, this.#operationAlreadyTerminalError(runtime));
|
|
1063
|
+
continue;
|
|
1064
|
+
}
|
|
1065
|
+
try {
|
|
1066
|
+
let current = runtime;
|
|
1067
|
+
for (let retry = 0;; retry++) {
|
|
1068
|
+
current.cancelRequestedAt = new Date().toISOString();
|
|
1069
|
+
current.sequence += 1;
|
|
1070
|
+
current.snapshot = buildRuntimeOperationSnapshot(current, "cancelled", { completedAt: current.cancelRequestedAt });
|
|
1071
|
+
current.terminal = true;
|
|
1072
|
+
try {
|
|
1073
|
+
await this.saveOperationRecord(current);
|
|
1074
|
+
break;
|
|
1075
|
+
}
|
|
1076
|
+
catch (cause) {
|
|
1077
|
+
this.#operations.delete(current.id);
|
|
1078
|
+
if (retry >= 3 || !isOperationRevisionConflict(cause)) {
|
|
1079
|
+
throw cause;
|
|
1080
|
+
}
|
|
1081
|
+
const reloaded = await this.#resolveOperation(current.id);
|
|
1082
|
+
if (!reloaded || reloaded.terminal)
|
|
1083
|
+
throw cause;
|
|
1084
|
+
current = reloaded;
|
|
1085
|
+
}
|
|
1086
|
+
}
|
|
1087
|
+
current.cancellation.abort("operation cancelled");
|
|
1088
|
+
msg.respond(JSON.stringify({ kind: "snapshot", snapshot: current.snapshot }));
|
|
1089
|
+
}
|
|
1090
|
+
catch (cause) {
|
|
1091
|
+
respondControlError(msg, new UnexpectedError({ cause }));
|
|
1092
|
+
}
|
|
1093
|
+
continue;
|
|
1094
|
+
}
|
|
1095
|
+
if (control.action === "signal") {
|
|
1096
|
+
if (!runtime) {
|
|
1097
|
+
respondControlError(msg, new UnexpectedError({
|
|
1098
|
+
cause: new Error("operation is not running in this process"),
|
|
1099
|
+
}));
|
|
1100
|
+
continue;
|
|
1101
|
+
}
|
|
1102
|
+
try {
|
|
1103
|
+
const accepted = await this.#acceptSignal(runtime, ctx, control, msg.headers?.get("request-id") ?? ulid());
|
|
1104
|
+
const acceptedValue = accepted.take();
|
|
1105
|
+
if (isErr(acceptedValue)) {
|
|
1106
|
+
respondControlError(msg, acceptedValue.error);
|
|
1107
|
+
continue;
|
|
1108
|
+
}
|
|
1109
|
+
msg.respond(JSON.stringify(acceptedValue));
|
|
1110
|
+
}
|
|
1111
|
+
catch (cause) {
|
|
1112
|
+
respondControlError(msg, cause instanceof Error ? cause : new Error(String(cause)));
|
|
1113
|
+
continue;
|
|
1114
|
+
}
|
|
1115
|
+
continue;
|
|
1116
|
+
}
|
|
1117
|
+
respondControlError(msg, new UnexpectedError({
|
|
1118
|
+
cause: new Error(`Unknown operation control action '${control.action}' for '${operation}'`),
|
|
1119
|
+
}));
|
|
1120
|
+
}
|
|
1121
|
+
})();
|
|
1122
|
+
}
|
|
1123
|
+
mountRuntime(method, fn) {
|
|
1124
|
+
return super.mount(method, fn);
|
|
1125
|
+
}
|
|
1126
|
+
static create(name, nats, auth, opts) {
|
|
1127
|
+
const runtime = new TrellisServiceRuntime(name, nats, auth, opts);
|
|
1128
|
+
return runtime;
|
|
1129
|
+
}
|
|
1130
|
+
operationHandle(operation) {
|
|
1131
|
+
const ctx = this.api["operations"]?.[operation];
|
|
1132
|
+
if (!ctx) {
|
|
1133
|
+
throw new Error(`Unknown operation '${operation.toString()}'. Did you forget to include its API module?`);
|
|
1134
|
+
}
|
|
1135
|
+
return {
|
|
1136
|
+
control: (operationId) => {
|
|
1137
|
+
this.#ensureOperationControlLoop(String(operation), ctx);
|
|
1138
|
+
return this.#controlOperation(String(operation), ctx, operationId);
|
|
1139
|
+
},
|
|
1140
|
+
handle: async (handler) => {
|
|
1141
|
+
const startSubject = ctx.subject;
|
|
1142
|
+
const now = () => new Date().toISOString();
|
|
1143
|
+
const publishFrame = async (reply, frame) => {
|
|
1144
|
+
await this.#nats.publish(reply, JSON.stringify(frame));
|
|
1145
|
+
};
|
|
1146
|
+
const makeOperation = (runtime, fence, context) => {
|
|
1147
|
+
return {
|
|
1148
|
+
id: runtime.id,
|
|
1149
|
+
started: () => this.#applyControlledOperationUpdate(runtime, fence, ctx, "running", {
|
|
1150
|
+
event: { type: "started" },
|
|
1151
|
+
}),
|
|
1152
|
+
progress: (value) => this.#applyControlledOperationUpdate(runtime, fence, ctx, "running", {
|
|
1153
|
+
patch: { progress: value },
|
|
1154
|
+
event: { type: "progress", progress: value },
|
|
1155
|
+
}),
|
|
1156
|
+
emitUpdate: (value) => AsyncResult.from(this.#emitOperationUpdate(runtime, fence, ctx, value)),
|
|
1157
|
+
complete: (value) => this.#applyControlledOperationUpdate(runtime, fence, ctx, "completed", {
|
|
1158
|
+
patch: { output: value, completedAt: now() },
|
|
1159
|
+
event: { type: "completed" },
|
|
1160
|
+
}),
|
|
1161
|
+
fail: (error) => AsyncResult.from((async () => {
|
|
1162
|
+
const annotatedError = annotateHandlerBoundaryError(error, {
|
|
1163
|
+
operation: String(operation),
|
|
1164
|
+
requestId: context.requestId,
|
|
1165
|
+
service: this.name,
|
|
1166
|
+
contractId: this.contractId,
|
|
1167
|
+
contractDigest: this.contractDigest,
|
|
1168
|
+
traceId: context.traceId,
|
|
1169
|
+
});
|
|
1170
|
+
return await this.#applyControlledOperationUpdate(runtime, fence, ctx, "failed", {
|
|
1171
|
+
patch: {
|
|
1172
|
+
error: annotatedError.toSerializable(),
|
|
1173
|
+
completedAt: now(),
|
|
1174
|
+
},
|
|
1175
|
+
event: { type: "failed" },
|
|
1176
|
+
});
|
|
1177
|
+
})()),
|
|
1178
|
+
cancel: () => this.#applyControlledOperationUpdate(runtime, fence, ctx, "cancelled", {
|
|
1179
|
+
patch: { completedAt: now() },
|
|
1180
|
+
event: { type: "cancelled" },
|
|
1181
|
+
}),
|
|
1182
|
+
attach: (job) => AsyncResult.from((async () => {
|
|
1183
|
+
const waited = await job.wait();
|
|
1184
|
+
const waitedValue = waited.take();
|
|
1185
|
+
if (isErr(waitedValue)) {
|
|
1186
|
+
return err(new UnexpectedError({ cause: waitedValue.error }));
|
|
1187
|
+
}
|
|
1188
|
+
const finalRuntime = await this.loadOperationRecord(runtime.id);
|
|
1189
|
+
if (!finalRuntime || !this.#ownsOperation(finalRuntime, fence) ||
|
|
1190
|
+
!isTerminalRuntimeOperationSnapshot(finalRuntime.snapshot)) {
|
|
1191
|
+
return err(new UnexpectedError({
|
|
1192
|
+
cause: new Error("attached job completed without terminal operation state"),
|
|
1193
|
+
}));
|
|
1194
|
+
}
|
|
1195
|
+
return ok(finalRuntime.snapshot);
|
|
1196
|
+
})()),
|
|
1197
|
+
signals: () => this.#signals(runtime.id),
|
|
1198
|
+
nextSignal: (name) => this.#nextSignal(runtime.id, name),
|
|
1199
|
+
acknowledgeSignal: (sequence) => this.#acknowledgeSignal(runtime, fence, sequence),
|
|
1200
|
+
defer: () => ({ kind: "deferred" }),
|
|
1201
|
+
};
|
|
1202
|
+
};
|
|
1203
|
+
const executeHandler = async (runtime, fence, caller, transferSession, operationContext = {}, resuming = false) => {
|
|
1204
|
+
const admitted = await this.loadOperationRecord(runtime.id);
|
|
1205
|
+
if (!admitted || !this.#ownsOperation(admitted, fence) ||
|
|
1206
|
+
admitted.snapshot.state === "completed" ||
|
|
1207
|
+
admitted.snapshot.state === "failed" ||
|
|
1208
|
+
admitted.snapshot.state === "cancelled" ||
|
|
1209
|
+
admitted.cancelRequestedAt ||
|
|
1210
|
+
!this.#matchesOperationRoute(admitted, String(operation), ctx, caller)) {
|
|
1211
|
+
runtime.cancellation.abort("operation ownership lost");
|
|
1212
|
+
this.#releaseOperationFence(runtime.id, fence);
|
|
1213
|
+
return;
|
|
1214
|
+
}
|
|
1215
|
+
runtime.revision = admitted.revision;
|
|
1216
|
+
runtime.leaseExpiresAt = admitted.leaseExpiresAt;
|
|
1217
|
+
runtime.snapshot = admitted.snapshot;
|
|
1218
|
+
const op = makeOperation(runtime, fence, operationContext);
|
|
1219
|
+
try {
|
|
1220
|
+
const handlerResult = await handler(transferSession
|
|
1221
|
+
? {
|
|
1222
|
+
input: runtime.input,
|
|
1223
|
+
op,
|
|
1224
|
+
caller,
|
|
1225
|
+
signal: runtime.cancellation.signal,
|
|
1226
|
+
resuming,
|
|
1227
|
+
...(runtime.snapshot.progress !== undefined
|
|
1228
|
+
? { progress: runtime.snapshot.progress }
|
|
1229
|
+
: {}),
|
|
1230
|
+
transfer: transferSession.transfer,
|
|
1231
|
+
}
|
|
1232
|
+
: {
|
|
1233
|
+
input: runtime.input,
|
|
1234
|
+
op,
|
|
1235
|
+
caller,
|
|
1236
|
+
signal: runtime.cancellation.signal,
|
|
1237
|
+
resuming,
|
|
1238
|
+
...(runtime.snapshot.progress !== undefined
|
|
1239
|
+
? { progress: runtime.snapshot.progress }
|
|
1240
|
+
: {}),
|
|
1241
|
+
});
|
|
1242
|
+
const handlerOutcome = isResultLike(handlerResult)
|
|
1243
|
+
? handlerResult.take()
|
|
1244
|
+
: handlerResult;
|
|
1245
|
+
if (isErr(handlerOutcome)) {
|
|
1246
|
+
const error = annotateHandlerBoundaryError(handlerOutcome.error, {
|
|
1247
|
+
operation: String(operation),
|
|
1248
|
+
requestId: operationContext.requestId,
|
|
1249
|
+
service: this.name,
|
|
1250
|
+
contractId: this.contractId,
|
|
1251
|
+
contractDigest: this.contractDigest,
|
|
1252
|
+
traceId: operationContext.traceId,
|
|
1253
|
+
});
|
|
1254
|
+
recordOperationServiceError(error, {
|
|
1255
|
+
operation: String(operation),
|
|
1256
|
+
phase: "handler_result",
|
|
1257
|
+
});
|
|
1258
|
+
await op.fail(error);
|
|
1259
|
+
return;
|
|
1260
|
+
}
|
|
1261
|
+
if (isOperationDeferred(handlerOutcome))
|
|
1262
|
+
return;
|
|
1263
|
+
if (isTerminalRuntimeOperationSnapshot(handlerOutcome)) {
|
|
1264
|
+
return;
|
|
1265
|
+
}
|
|
1266
|
+
if (!runtime.terminal)
|
|
1267
|
+
await op.complete(handlerOutcome);
|
|
1268
|
+
}
|
|
1269
|
+
catch (cause) {
|
|
1270
|
+
if (runtime.cancellation.signal.aborted)
|
|
1271
|
+
return;
|
|
1272
|
+
const error = annotateHandlerBoundaryError(cause, {
|
|
1273
|
+
operation: String(operation),
|
|
1274
|
+
requestId: operationContext.requestId,
|
|
1275
|
+
service: this.name,
|
|
1276
|
+
contractId: this.contractId,
|
|
1277
|
+
contractDigest: this.contractDigest,
|
|
1278
|
+
traceId: operationContext.traceId,
|
|
1279
|
+
});
|
|
1280
|
+
recordOperationServiceError(error, {
|
|
1281
|
+
operation: String(operation),
|
|
1282
|
+
phase: "handler_throw",
|
|
1283
|
+
});
|
|
1284
|
+
try {
|
|
1285
|
+
await op.fail(error).take();
|
|
1286
|
+
}
|
|
1287
|
+
catch (failure) {
|
|
1288
|
+
recordOperationServiceError(failure, {
|
|
1289
|
+
operation: String(operation),
|
|
1290
|
+
phase: "failure_persist",
|
|
1291
|
+
});
|
|
1292
|
+
}
|
|
1293
|
+
}
|
|
1294
|
+
};
|
|
1295
|
+
const startLeaseHeartbeat = (runtime, fence) => {
|
|
1296
|
+
const leaseHeartbeat = setInterval(() => {
|
|
1297
|
+
if (runtime.terminal || runtime.cancellation.signal.aborted) {
|
|
1298
|
+
clearInterval(leaseHeartbeat);
|
|
1299
|
+
return;
|
|
1300
|
+
}
|
|
1301
|
+
void this.#queueOperationFrame(runtime, async () => {
|
|
1302
|
+
for (let retry = 0;; retry++) {
|
|
1303
|
+
const durable = await this.loadOperationRecord(runtime.id);
|
|
1304
|
+
if (!durable || durable.cancelRequestedAt ||
|
|
1305
|
+
durable.snapshot.state === "cancelled" ||
|
|
1306
|
+
!this.#ownsOperation(durable, fence))
|
|
1307
|
+
throw new Error("operation ownership lost");
|
|
1308
|
+
runtime.revision = durable.revision;
|
|
1309
|
+
runtime.snapshot = durable.snapshot;
|
|
1310
|
+
runtime.sequence = durable.sequence;
|
|
1311
|
+
runtime.signalSequence = durable.signalSequence;
|
|
1312
|
+
runtime.signals = durable.signals;
|
|
1313
|
+
runtime.leaseExpiresAt = new Date(Date.now() + 30_000)
|
|
1314
|
+
.toISOString();
|
|
1315
|
+
try {
|
|
1316
|
+
await this.saveOperationRecord(runtime);
|
|
1317
|
+
return;
|
|
1318
|
+
}
|
|
1319
|
+
catch (cause) {
|
|
1320
|
+
if (retry >= 3 || !isOperationRevisionConflict(cause)) {
|
|
1321
|
+
throw cause;
|
|
1322
|
+
}
|
|
1323
|
+
}
|
|
1324
|
+
}
|
|
1325
|
+
}).catch(() => {
|
|
1326
|
+
runtime.cancellation.abort("operation ownership lost");
|
|
1327
|
+
if (this.#operations.get(runtime.id) === runtime) {
|
|
1328
|
+
this.#operations.delete(runtime.id);
|
|
1329
|
+
}
|
|
1330
|
+
this.#releaseOperationFence(runtime.id, fence);
|
|
1331
|
+
clearInterval(leaseHeartbeat);
|
|
1332
|
+
});
|
|
1333
|
+
}, 10_000);
|
|
1334
|
+
};
|
|
1335
|
+
const watchCancellation = (runtime, fence) => {
|
|
1336
|
+
let reading = false;
|
|
1337
|
+
const cancellationWatch = setInterval(() => {
|
|
1338
|
+
if (reading || runtime.terminal || runtime.cancellation.signal.aborted) {
|
|
1339
|
+
if (runtime.terminal || runtime.cancellation.signal.aborted) {
|
|
1340
|
+
clearInterval(cancellationWatch);
|
|
1341
|
+
}
|
|
1342
|
+
return;
|
|
1343
|
+
}
|
|
1344
|
+
reading = true;
|
|
1345
|
+
void this.loadOperationRecord(runtime.id).then((durable) => {
|
|
1346
|
+
if (!durable || !this.#ownsOperation(durable, fence)) {
|
|
1347
|
+
runtime.cancellation.abort("operation cancelled or ownership lost");
|
|
1348
|
+
clearInterval(cancellationWatch);
|
|
1349
|
+
}
|
|
1350
|
+
}).catch(() => {
|
|
1351
|
+
runtime.cancellation.abort("operation state unavailable");
|
|
1352
|
+
clearInterval(cancellationWatch);
|
|
1353
|
+
}).finally(() => {
|
|
1354
|
+
reading = false;
|
|
1355
|
+
});
|
|
1356
|
+
}, 100);
|
|
1357
|
+
};
|
|
1358
|
+
const authenticate = (msg, parseInput = true) => this.#authenticateOperationMessage(msg, ctx, parseInput);
|
|
1359
|
+
this.#log.info({ operation: String(operation) }, `Mounting ${String(operation)} operation handler`);
|
|
1360
|
+
this.#ensureOperationControlLoop(String(operation), ctx);
|
|
1361
|
+
const recover = async (durable) => {
|
|
1362
|
+
if (!this.#matchesOperationRoute(durable, String(operation), ctx)) {
|
|
1363
|
+
return;
|
|
1364
|
+
}
|
|
1365
|
+
const runtime = await this.#acquireOperation(durable);
|
|
1366
|
+
if (!runtime?.reclaimed)
|
|
1367
|
+
return;
|
|
1368
|
+
runtime.reclaimed = false;
|
|
1369
|
+
if (runtime.transferGrant) {
|
|
1370
|
+
const fence = this.#operationFence(runtime);
|
|
1371
|
+
const committed = Reflect.get(runtime.transferGrant, "committed");
|
|
1372
|
+
if (Value.Check(FileInfoSchema, committed)) {
|
|
1373
|
+
startLeaseHeartbeat(runtime, fence);
|
|
1374
|
+
watchCancellation(runtime, fence);
|
|
1375
|
+
void executeHandler(runtime, fence, runtime.caller, {
|
|
1376
|
+
grant: runtime.transferGrant,
|
|
1377
|
+
transfer: {
|
|
1378
|
+
updates: async function* () { },
|
|
1379
|
+
completed: () => AsyncResult.from(Promise.resolve(ok(committed))),
|
|
1380
|
+
},
|
|
1381
|
+
}, {}, true);
|
|
1382
|
+
return;
|
|
1383
|
+
}
|
|
1384
|
+
if (!ctx.transfer || !this.#transferSupport)
|
|
1385
|
+
return;
|
|
1386
|
+
const key = asStringPointerValue(String(operation), runtime.input, ctx.transfer.key, "key").take();
|
|
1387
|
+
const contentType = asOptionalStringPointerValue(runtime.input, ctx.transfer.contentType).take();
|
|
1388
|
+
const metadata = asOptionalStringRecordPointerValue(runtime.input, ctx.transfer.metadata).take();
|
|
1389
|
+
if (isErr(key) || isErr(contentType) || isErr(metadata))
|
|
1390
|
+
return;
|
|
1391
|
+
const reopened = await this.#transferSupport
|
|
1392
|
+
.openOperationTransfer({
|
|
1393
|
+
sessionKey: runtime.callerSessionKey,
|
|
1394
|
+
permission: ctx.permissions?.invoke,
|
|
1395
|
+
requiredCapabilities: ctx.callerCapabilities ?? [],
|
|
1396
|
+
store: ctx.transfer.store,
|
|
1397
|
+
key,
|
|
1398
|
+
expiresInMs: ctx.transfer.expiresInMs ?? 60_000,
|
|
1399
|
+
...(ctx.transfer.maxBytes !== undefined
|
|
1400
|
+
? { maxBytes: ctx.transfer.maxBytes }
|
|
1401
|
+
: {}),
|
|
1402
|
+
...(contentType !== undefined ? { contentType } : {}),
|
|
1403
|
+
...(metadata !== undefined ? { metadata } : {}),
|
|
1404
|
+
onComplete: async (info) => {
|
|
1405
|
+
await this.#queueOperationFrame(runtime, async () => {
|
|
1406
|
+
if (!runtime.transferGrant)
|
|
1407
|
+
return;
|
|
1408
|
+
const durable = await this.loadOperationRecord(runtime.id);
|
|
1409
|
+
if (!durable || !this.#ownsOperation(durable, fence) ||
|
|
1410
|
+
durable.cancelRequestedAt ||
|
|
1411
|
+
durable.snapshot.state === "completed" ||
|
|
1412
|
+
durable.snapshot.state === "failed" ||
|
|
1413
|
+
durable.snapshot.state === "cancelled") {
|
|
1414
|
+
runtime.cancellation.abort("operation ownership lost");
|
|
1415
|
+
return;
|
|
1416
|
+
}
|
|
1417
|
+
runtime.revision = durable.revision;
|
|
1418
|
+
Reflect.set(runtime.transferGrant, "committed", info);
|
|
1419
|
+
await this.saveOperationRecord(runtime);
|
|
1420
|
+
});
|
|
1421
|
+
},
|
|
1422
|
+
}).take();
|
|
1423
|
+
if (isErr(reopened))
|
|
1424
|
+
return;
|
|
1425
|
+
runtime.transferGrant = reopened.grant;
|
|
1426
|
+
const current = await this.loadOperationRecord(runtime.id);
|
|
1427
|
+
if (!current || !this.#ownsOperation(current, fence)) {
|
|
1428
|
+
runtime.cancellation.abort("operation ownership lost");
|
|
1429
|
+
return;
|
|
1430
|
+
}
|
|
1431
|
+
runtime.revision = current.revision;
|
|
1432
|
+
await this.saveOperationRecord(runtime);
|
|
1433
|
+
startLeaseHeartbeat(runtime, fence);
|
|
1434
|
+
void (async () => {
|
|
1435
|
+
for await (const progress of reopened.transfer.updates()) {
|
|
1436
|
+
await this.#applyOperationUpdate(runtime, fence, "running", {
|
|
1437
|
+
patch: { transfer: progress },
|
|
1438
|
+
event: { type: "transfer", transfer: progress },
|
|
1439
|
+
});
|
|
1440
|
+
}
|
|
1441
|
+
})();
|
|
1442
|
+
watchCancellation(runtime, fence);
|
|
1443
|
+
void executeHandler(runtime, fence, runtime.caller, reopened, {}, true);
|
|
1444
|
+
return;
|
|
1445
|
+
}
|
|
1446
|
+
const fence = this.#operationFence(runtime);
|
|
1447
|
+
startLeaseHeartbeat(runtime, fence);
|
|
1448
|
+
watchCancellation(runtime, fence);
|
|
1449
|
+
void executeHandler(runtime, fence, runtime.caller, undefined, {}, true);
|
|
1450
|
+
};
|
|
1451
|
+
const recoverExpired = async () => {
|
|
1452
|
+
for (const durable of await this.listNonterminalOperationRecords()) {
|
|
1453
|
+
if (Date.parse(durable.leaseExpiresAt) <= Date.now()) {
|
|
1454
|
+
await recover(durable);
|
|
1455
|
+
}
|
|
1456
|
+
}
|
|
1457
|
+
};
|
|
1458
|
+
await recoverExpired();
|
|
1459
|
+
const recoveryScan = setInterval(() => {
|
|
1460
|
+
if (this.#nats.isClosed()) {
|
|
1461
|
+
clearInterval(recoveryScan);
|
|
1462
|
+
return;
|
|
1463
|
+
}
|
|
1464
|
+
void recoverExpired().catch((error) => {
|
|
1465
|
+
if (!this.#nats.isClosed()) {
|
|
1466
|
+
this.#log.warn({ error, operation: String(operation) }, "Operation recovery scan failed");
|
|
1467
|
+
}
|
|
1468
|
+
});
|
|
1469
|
+
}, 1_000);
|
|
1470
|
+
const startSub = this.#nats.subscribe(startSubject, {
|
|
1471
|
+
queue: routeQueueGroup(startSubject),
|
|
1472
|
+
});
|
|
1473
|
+
void (async () => {
|
|
1474
|
+
for await (const msg of startSub) {
|
|
1475
|
+
const validated = await authenticate(msg, true);
|
|
1476
|
+
const value = validated.take();
|
|
1477
|
+
if (isErr(value)) {
|
|
1478
|
+
recordOperationServiceError(value.error, {
|
|
1479
|
+
operation: String(operation),
|
|
1480
|
+
phase: "start",
|
|
1481
|
+
});
|
|
1482
|
+
this.respondWithError(msg, value.error);
|
|
1483
|
+
continue;
|
|
1484
|
+
}
|
|
1485
|
+
let transferSession;
|
|
1486
|
+
let transferFence;
|
|
1487
|
+
const operationId = value.invocationId;
|
|
1488
|
+
const apiId = `${ctx.permissions?.invoke.apiId ?? ""}@${ctx.permissions?.invoke.apiVersion ?? ""}`;
|
|
1489
|
+
const invocationDigest = (await digestJson({
|
|
1490
|
+
apiId,
|
|
1491
|
+
operation: String(operation),
|
|
1492
|
+
creatorPrincipalId: value.caller.principalId,
|
|
1493
|
+
creatorParticipantId: value.caller.participantId,
|
|
1494
|
+
input: value.input,
|
|
1495
|
+
})).digest;
|
|
1496
|
+
let reclaimed;
|
|
1497
|
+
const existing = await this.#resolveOperation(operationId);
|
|
1498
|
+
if (existing) {
|
|
1499
|
+
if (!this.#matchesOperationRoute(existing, String(operation), ctx, value.caller)) {
|
|
1500
|
+
this.respondWithError(msg, this.#operationNotFoundError(operationId));
|
|
1501
|
+
continue;
|
|
1502
|
+
}
|
|
1503
|
+
if (existing.invocationDigest !== invocationDigest) {
|
|
1504
|
+
this.respondWithError(msg, new ValidationError({
|
|
1505
|
+
errors: [{
|
|
1506
|
+
path: "/invocationId",
|
|
1507
|
+
message: "Invocation id was already accepted with different input",
|
|
1508
|
+
}],
|
|
1509
|
+
}));
|
|
1510
|
+
continue;
|
|
1511
|
+
}
|
|
1512
|
+
if (existing.reclaimed && !ctx.transfer) {
|
|
1513
|
+
reclaimed = existing;
|
|
1514
|
+
}
|
|
1515
|
+
else {
|
|
1516
|
+
msg.respond(JSON.stringify({
|
|
1517
|
+
kind: "accepted",
|
|
1518
|
+
ref: {
|
|
1519
|
+
id: existing.id,
|
|
1520
|
+
service: this.name,
|
|
1521
|
+
operation: String(operation),
|
|
1522
|
+
},
|
|
1523
|
+
snapshot: existing.snapshot,
|
|
1524
|
+
...(existing.transferGrant
|
|
1525
|
+
? { transfer: existing.transferGrant }
|
|
1526
|
+
: {}),
|
|
1527
|
+
}));
|
|
1528
|
+
continue;
|
|
1529
|
+
}
|
|
1530
|
+
}
|
|
1531
|
+
if (ctx.transfer) {
|
|
1532
|
+
if (!this.#transferSupport) {
|
|
1533
|
+
const error = new UnexpectedError({
|
|
1534
|
+
cause: new Error(`Operation '${String(operation)}' declared transfer support but no runtime transfer support is configured`),
|
|
1535
|
+
});
|
|
1536
|
+
recordOperationServiceError(error, {
|
|
1537
|
+
operation: String(operation),
|
|
1538
|
+
phase: "start",
|
|
1539
|
+
});
|
|
1540
|
+
this.respondWithError(msg, error);
|
|
1541
|
+
continue;
|
|
1542
|
+
}
|
|
1543
|
+
const key = ctx.transfer.key
|
|
1544
|
+
? asStringPointerValue(String(operation), value.input, ctx.transfer.key, "key").take()
|
|
1545
|
+
: operationId;
|
|
1546
|
+
if (isErr(key)) {
|
|
1547
|
+
recordOperationServiceError(key.error, {
|
|
1548
|
+
operation: String(operation),
|
|
1549
|
+
phase: "start",
|
|
1550
|
+
});
|
|
1551
|
+
this.respondWithError(msg, key.error);
|
|
1552
|
+
continue;
|
|
1553
|
+
}
|
|
1554
|
+
const contentType = asOptionalStringPointerValue(value.input, ctx.transfer.contentType).take();
|
|
1555
|
+
if (isErr(contentType)) {
|
|
1556
|
+
recordOperationServiceError(contentType.error, {
|
|
1557
|
+
operation: String(operation),
|
|
1558
|
+
phase: "start",
|
|
1559
|
+
});
|
|
1560
|
+
this.respondWithError(msg, contentType.error);
|
|
1561
|
+
continue;
|
|
1562
|
+
}
|
|
1563
|
+
const metadata = asOptionalStringRecordPointerValue(value.input, ctx.transfer.metadata).take();
|
|
1564
|
+
if (isErr(metadata)) {
|
|
1565
|
+
recordOperationServiceError(metadata.error, {
|
|
1566
|
+
operation: String(operation),
|
|
1567
|
+
phase: "start",
|
|
1568
|
+
});
|
|
1569
|
+
this.respondWithError(msg, metadata.error);
|
|
1570
|
+
continue;
|
|
1571
|
+
}
|
|
1572
|
+
const openedTransferValue = await this.#transferSupport
|
|
1573
|
+
.openOperationTransfer({
|
|
1574
|
+
sessionKey: value.sessionKey,
|
|
1575
|
+
permission: ctx.permissions?.invoke,
|
|
1576
|
+
requiredCapabilities: ctx.callerCapabilities ?? [],
|
|
1577
|
+
store: ctx.transfer.store,
|
|
1578
|
+
key,
|
|
1579
|
+
expiresInMs: ctx.transfer.expiresInMs ?? 60_000,
|
|
1580
|
+
...(ctx.transfer.maxBytes !== undefined
|
|
1581
|
+
? { maxBytes: ctx.transfer.maxBytes }
|
|
1582
|
+
: {}),
|
|
1583
|
+
...(contentType !== undefined ? { contentType } : {}),
|
|
1584
|
+
...(metadata !== undefined ? { metadata } : {}),
|
|
1585
|
+
onComplete: async (info) => {
|
|
1586
|
+
await this.#queueOperationFrame(runtime, async () => {
|
|
1587
|
+
if (!runtime.transferGrant || !transferFence)
|
|
1588
|
+
return;
|
|
1589
|
+
const durable = await this.loadOperationRecord(runtime.id);
|
|
1590
|
+
if (!durable ||
|
|
1591
|
+
!this.#ownsOperation(durable, transferFence) ||
|
|
1592
|
+
durable.cancelRequestedAt ||
|
|
1593
|
+
durable.snapshot.state === "completed" ||
|
|
1594
|
+
durable.snapshot.state === "failed" ||
|
|
1595
|
+
durable.snapshot.state === "cancelled") {
|
|
1596
|
+
runtime.cancellation.abort("operation ownership lost");
|
|
1597
|
+
return;
|
|
1598
|
+
}
|
|
1599
|
+
runtime.revision = durable.revision;
|
|
1600
|
+
Reflect.set(runtime.transferGrant, "committed", info);
|
|
1601
|
+
await this.saveOperationRecord(runtime);
|
|
1602
|
+
});
|
|
1603
|
+
},
|
|
1604
|
+
}).take();
|
|
1605
|
+
if (isErr(openedTransferValue)) {
|
|
1606
|
+
recordOperationServiceError(openedTransferValue.error, {
|
|
1607
|
+
operation: String(operation),
|
|
1608
|
+
phase: "start",
|
|
1609
|
+
});
|
|
1610
|
+
this.respondWithError(msg, openedTransferValue.error);
|
|
1611
|
+
continue;
|
|
1612
|
+
}
|
|
1613
|
+
transferSession = openedTransferValue;
|
|
1614
|
+
}
|
|
1615
|
+
const createdAt = now();
|
|
1616
|
+
const runtime = reclaimed ?? {
|
|
1617
|
+
id: operationId,
|
|
1618
|
+
service: this.name,
|
|
1619
|
+
operation: String(operation),
|
|
1620
|
+
callerSessionKey: value.sessionKey,
|
|
1621
|
+
invocationDigest,
|
|
1622
|
+
caller: value.caller,
|
|
1623
|
+
creatorPrincipalId: value.caller.principalId,
|
|
1624
|
+
creatorParticipantId: value.caller.participantId,
|
|
1625
|
+
apiId,
|
|
1626
|
+
input: value.input,
|
|
1627
|
+
revision: 1,
|
|
1628
|
+
ownerInstanceId: this.#operationOwnerId,
|
|
1629
|
+
ownerConnectionId: this.#operationConnectionId,
|
|
1630
|
+
ownerEpoch: 1,
|
|
1631
|
+
leaseExpiresAt: new Date(Date.now() + 30_000).toISOString(),
|
|
1632
|
+
...(transferSession
|
|
1633
|
+
? { transferGrant: transferSession.grant }
|
|
1634
|
+
: {}),
|
|
1635
|
+
snapshot: {
|
|
1636
|
+
id: operationId,
|
|
1637
|
+
service: this.name,
|
|
1638
|
+
operation: String(operation),
|
|
1639
|
+
revision: 1,
|
|
1640
|
+
state: "pending",
|
|
1641
|
+
createdAt,
|
|
1642
|
+
updatedAt: createdAt,
|
|
1643
|
+
},
|
|
1644
|
+
sequence: 0,
|
|
1645
|
+
signalSequence: 0,
|
|
1646
|
+
signals: [],
|
|
1647
|
+
terminal: false,
|
|
1648
|
+
watchers: new Map(),
|
|
1649
|
+
frameQueue: Promise.resolve(),
|
|
1650
|
+
signalWaiters: new Set(),
|
|
1651
|
+
cancellation: new AbortController(),
|
|
1652
|
+
};
|
|
1653
|
+
transferFence = this.#operationFence(runtime);
|
|
1654
|
+
this.#activeOperationFences.set(runtime.id, transferFence);
|
|
1655
|
+
if (!reclaimed) {
|
|
1656
|
+
this.#operations.set(operationId, runtime);
|
|
1657
|
+
try {
|
|
1658
|
+
await this.saveOperationRecord(runtime);
|
|
1659
|
+
}
|
|
1660
|
+
catch (cause) {
|
|
1661
|
+
this.#operations.delete(operationId);
|
|
1662
|
+
const accepted = await this.#resolveOperation(operationId);
|
|
1663
|
+
if (!accepted) {
|
|
1664
|
+
this.respondWithError(msg, cause instanceof Error
|
|
1665
|
+
? new ValidationError({
|
|
1666
|
+
errors: [{ path: "/", message: cause.message }],
|
|
1667
|
+
})
|
|
1668
|
+
: new UnexpectedError({ cause }));
|
|
1669
|
+
continue;
|
|
1670
|
+
}
|
|
1671
|
+
if (!this.#matchesOperationRoute(accepted, String(operation), ctx, value.caller) ||
|
|
1672
|
+
accepted.invocationDigest !== invocationDigest) {
|
|
1673
|
+
this.respondWithError(msg, new ValidationError({
|
|
1674
|
+
errors: [{
|
|
1675
|
+
path: "/invocationId",
|
|
1676
|
+
message: "Invocation id was already accepted with different input",
|
|
1677
|
+
}],
|
|
1678
|
+
}));
|
|
1679
|
+
continue;
|
|
1680
|
+
}
|
|
1681
|
+
msg.respond(JSON.stringify({
|
|
1682
|
+
kind: "accepted",
|
|
1683
|
+
ref: {
|
|
1684
|
+
id: accepted.id,
|
|
1685
|
+
service: this.name,
|
|
1686
|
+
operation: String(operation),
|
|
1687
|
+
},
|
|
1688
|
+
snapshot: accepted.snapshot,
|
|
1689
|
+
...(accepted.transferGrant
|
|
1690
|
+
? { transfer: accepted.transferGrant }
|
|
1691
|
+
: {}),
|
|
1692
|
+
}));
|
|
1693
|
+
continue;
|
|
1694
|
+
}
|
|
1695
|
+
}
|
|
1696
|
+
if (transferSession) {
|
|
1697
|
+
void (async () => {
|
|
1698
|
+
for await (const progress of transferSession.transfer.updates()) {
|
|
1699
|
+
await this.#applyOperationUpdate(runtime, transferFence, "running", {
|
|
1700
|
+
patch: { transfer: progress },
|
|
1701
|
+
event: { type: "transfer", transfer: progress },
|
|
1702
|
+
});
|
|
1703
|
+
}
|
|
1704
|
+
})();
|
|
1705
|
+
}
|
|
1706
|
+
startLeaseHeartbeat(runtime, transferFence);
|
|
1707
|
+
watchCancellation(runtime, transferFence);
|
|
1708
|
+
const accepted = {
|
|
1709
|
+
kind: "accepted",
|
|
1710
|
+
ref: {
|
|
1711
|
+
id: operationId,
|
|
1712
|
+
service: this.name,
|
|
1713
|
+
operation: String(operation),
|
|
1714
|
+
},
|
|
1715
|
+
snapshot: runtime.snapshot,
|
|
1716
|
+
...(transferSession ? { transfer: transferSession.grant } : {}),
|
|
1717
|
+
};
|
|
1718
|
+
msg.respond(JSON.stringify(accepted));
|
|
1719
|
+
void executeHandler(runtime, transferFence, value.caller, transferSession, {
|
|
1720
|
+
requestId: msg.headers?.get("request-id"),
|
|
1721
|
+
traceId: traceIdFromTraceparent(msg.headers?.get("traceparent")),
|
|
1722
|
+
});
|
|
1723
|
+
}
|
|
1724
|
+
})();
|
|
1725
|
+
return Promise.resolve();
|
|
1726
|
+
},
|
|
1727
|
+
};
|
|
1728
|
+
}
|
|
1729
|
+
async stop() {
|
|
1730
|
+
this.#stopPromise ??= (async () => {
|
|
1731
|
+
if (this.#nats.isClosed()) {
|
|
1732
|
+
return;
|
|
1733
|
+
}
|
|
1734
|
+
try {
|
|
1735
|
+
await this.#nats.drain();
|
|
1736
|
+
}
|
|
1737
|
+
catch (cause) {
|
|
1738
|
+
if (!(cause instanceof Error) ||
|
|
1739
|
+
cause.name !== "DrainingConnectionError") {
|
|
1740
|
+
throw cause;
|
|
1741
|
+
}
|
|
1742
|
+
await this.#nats.closed().catch(() => undefined);
|
|
1743
|
+
}
|
|
1744
|
+
})();
|
|
1745
|
+
await this.#stopPromise;
|
|
1746
|
+
}
|
|
1747
|
+
}
|