@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,1896 @@
|
|
|
1
|
+
import { jetstream } from "@nats-io/jetstream";
|
|
2
|
+
import { headers as natsHeaders, } from "@nats-io/nats-core";
|
|
3
|
+
import { installConnectionAvailability, } from "../../connection.js";
|
|
4
|
+
import { TypedKV } from "../../kv.js";
|
|
5
|
+
import { TypedStore, } from "../../store.js";
|
|
6
|
+
import { TrellisServiceRuntime, } from "./core.js";
|
|
7
|
+
import { base64urlEncode, createAuth, } from "../../auth.js";
|
|
8
|
+
import { AuthorizationContextCache, AuthorizationContextRefreshError, AuthorizationProviderCache, startAuthorizationContextRefresh, } from "../../auth/authorization_context.js";
|
|
9
|
+
import { TrellisHttpError } from "../../auth/http_error.js";
|
|
10
|
+
import { bindApiRoutes, getParticipantRuntime, participantAvailability, participantEvidence, refreshApiRoutes, } from "../../participant_runtime/participant.js";
|
|
11
|
+
import { lowerCamelSurfaceName, } from "../../participant_runtime/surface_names.js";
|
|
12
|
+
import { AsyncResult, isErr, Result, } from "@oatscenter/result";
|
|
13
|
+
import { Value } from "typebox/value";
|
|
14
|
+
import { ServiceHealthRuntime, } from "./health.js";
|
|
15
|
+
import { publishHealthHeartbeatSample } from "../../health_transport.js";
|
|
16
|
+
import { annotateHandlerBoundaryError, createTrellisInternal, } from "../../session.js";
|
|
17
|
+
import { ServiceTransfer } from "./transfer.js";
|
|
18
|
+
import { logger as noopLogger } from "../../globals.js";
|
|
19
|
+
import { createProviderRuntime, PROVIDER_CALLER, } from "../../provider.js";
|
|
20
|
+
import { DEFAULT_RUNTIME_MAX_RECONNECT_ATTEMPTS, DEFAULT_SERVICE_RUNTIME_WAIT_ON_FIRST_CONNECT, selectRuntimeTransportServers, } from "../../runtime_transport.js";
|
|
21
|
+
import { serviceRuntimeLogger } from "./logger.js";
|
|
22
|
+
import { TransportError, UnexpectedError, ValidationError, } from "../../errors/index.js";
|
|
23
|
+
import { ActiveJob as PublicActiveJob, decodeJobUpdateEnvelope, JobNotEnqueuedError, JobRef, JobWorkerHostAdapter, RetryJobError, } from "../../jobs.js";
|
|
24
|
+
import { parseSchema } from "../../codec.js";
|
|
25
|
+
import { isJsonValue } from "../../participant_runtime/json.js";
|
|
26
|
+
import { ulid } from "ulid";
|
|
27
|
+
import { JobManager as InternalJobManager, JobProcessError as InternalJobProcessError, prepareJobSubmission, } from "./internal_jobs/job-manager.js";
|
|
28
|
+
import { startNatsWorkerHostFromBinding } from "./internal_jobs/runtime-worker.js";
|
|
29
|
+
import { createNatsJobKeyCoordinator, normalizeJobKeyPolicy, } from "./internal_jobs/key-coordinator.js";
|
|
30
|
+
import { JobEventSchema, PreparedJobSubmissionSchema, } from "./internal_jobs/types.js";
|
|
31
|
+
import { observeNatsTrellisConnection, } from "../../connection.js";
|
|
32
|
+
import { recordTrellisDuration } from "../../telemetry/mod.js";
|
|
33
|
+
import { defaultSqlOutboxTables, OutboxDispatcher, preparedTrellisEventToOutboxRecord, SqlOutboxRepository, } from "../outbox_inbox.js";
|
|
34
|
+
import { closeFailedServiceBootstrapConnection, fetchServiceBootstrapInfo, loadDefaultServiceRuntimeDeps, } from "./bootstrap.js";
|
|
35
|
+
function normalizeResourceJobsBinding(binding) {
|
|
36
|
+
const queues = {};
|
|
37
|
+
for (const [name, queue] of Object.entries(binding.queues)) {
|
|
38
|
+
const baseQueue = baseJobsQueueBinding(queue);
|
|
39
|
+
if (!queue.keyConcurrency) {
|
|
40
|
+
queues[name] = {
|
|
41
|
+
...baseQueue,
|
|
42
|
+
...(queue.queue ? { queue: normalizeQueuePolicy(queue.queue) } : {}),
|
|
43
|
+
};
|
|
44
|
+
continue;
|
|
45
|
+
}
|
|
46
|
+
const policy = normalizeJobKeyPolicy({
|
|
47
|
+
keyConcurrency: queue.keyConcurrency,
|
|
48
|
+
queue: queue.queue,
|
|
49
|
+
});
|
|
50
|
+
queues[name] = {
|
|
51
|
+
...baseQueue,
|
|
52
|
+
keyConcurrency: {
|
|
53
|
+
key: policy.key,
|
|
54
|
+
maxActive: policy.maxActive,
|
|
55
|
+
heartbeatIntervalMs: policy.heartbeatIntervalMs,
|
|
56
|
+
heartbeatTtlMs: policy.heartbeatTtlMs,
|
|
57
|
+
stalePolicy: policy.stalePolicy,
|
|
58
|
+
},
|
|
59
|
+
queue: policy.queue,
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
return {
|
|
63
|
+
serviceName: binding.serviceName,
|
|
64
|
+
namespace: binding.namespace,
|
|
65
|
+
queues,
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
function baseJobsQueueBinding(queue) {
|
|
69
|
+
return {
|
|
70
|
+
queueType: queue.queueType,
|
|
71
|
+
publishPrefix: queue.publishPrefix,
|
|
72
|
+
workSubject: queue.workSubject,
|
|
73
|
+
consumerName: queue.consumerName,
|
|
74
|
+
payload: queue.payload,
|
|
75
|
+
...(queue.update ? { update: queue.update } : {}),
|
|
76
|
+
...(queue.updatesPrefix ? { updatesPrefix: queue.updatesPrefix } : {}),
|
|
77
|
+
...(queue.result ? { result: queue.result } : {}),
|
|
78
|
+
maxDeliver: queue.maxDeliver ?? 5,
|
|
79
|
+
backoffMs: queue.backoffMs ?? [5_000, 30_000, 120_000, 600_000],
|
|
80
|
+
ackWaitMs: queue.ackWaitMs ?? 5_000,
|
|
81
|
+
...(queue.defaultDeadlineMs !== undefined
|
|
82
|
+
? { defaultDeadlineMs: queue.defaultDeadlineMs }
|
|
83
|
+
: {}),
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
function normalizeQueuePolicy(queue) {
|
|
87
|
+
return {
|
|
88
|
+
maxQueuedPerKey: queue.maxQueuedPerKey ?? 0,
|
|
89
|
+
whenFull: queue.whenFull ?? "reject",
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
function resolveServiceLogger(log) {
|
|
93
|
+
return log === false ? noopLogger : log ?? serviceRuntimeLogger;
|
|
94
|
+
}
|
|
95
|
+
function surfaceGroupName(key) {
|
|
96
|
+
return lowerCamelIdent(key.split(".")[0] ?? key);
|
|
97
|
+
}
|
|
98
|
+
function surfaceLeafName(key) {
|
|
99
|
+
const parts = key.split(".");
|
|
100
|
+
parts.shift();
|
|
101
|
+
return lowerCamelIdent(parts.length === 0 ? key : parts.join("."));
|
|
102
|
+
}
|
|
103
|
+
function lowerCamelIdent(value) {
|
|
104
|
+
return lowerCamelSurfaceName(value) || "_";
|
|
105
|
+
}
|
|
106
|
+
function addSurfaceLeaf(surface, key, leaf) {
|
|
107
|
+
const group = surfaceGroupName(key);
|
|
108
|
+
surface[group] ??= {};
|
|
109
|
+
surface[group][surfaceLeafName(key)] = leaf;
|
|
110
|
+
}
|
|
111
|
+
const storeHandleConstructorToken = Symbol("StoreHandle.constructorToken");
|
|
112
|
+
const trellisServiceConstructorToken = Symbol("TrellisService.constructorToken");
|
|
113
|
+
export class StoreHandle {
|
|
114
|
+
}
|
|
115
|
+
class InternalStoreHandle extends StoreHandle {
|
|
116
|
+
binding;
|
|
117
|
+
#nc;
|
|
118
|
+
constructor(nc, binding, token) {
|
|
119
|
+
super();
|
|
120
|
+
if (token !== storeHandleConstructorToken) {
|
|
121
|
+
throw new TypeError("StoreHandle instances are created by TrellisService");
|
|
122
|
+
}
|
|
123
|
+
this.#nc = nc;
|
|
124
|
+
this.binding = binding;
|
|
125
|
+
}
|
|
126
|
+
open() {
|
|
127
|
+
return TypedStore.open(this.#nc, this.binding.name, {
|
|
128
|
+
ttlMs: this.binding.ttlMs,
|
|
129
|
+
maxObjectBytes: this.binding.maxObjectBytes,
|
|
130
|
+
maxTotalBytes: this.binding.maxTotalBytes,
|
|
131
|
+
bindOnly: true,
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
/**
|
|
135
|
+
* Waits for a staged object to appear in the bound store and returns its entry.
|
|
136
|
+
*/
|
|
137
|
+
waitFor(key, options = {}) {
|
|
138
|
+
return this.open().andThen((store) => store.waitFor(key, options));
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
async function openServiceKvBindings(args) {
|
|
142
|
+
for (const alias of Object.keys(args.bindings)) {
|
|
143
|
+
if (!args.contractKv[alias]) {
|
|
144
|
+
throw new Error(`KV binding '${alias}' is missing contract schema metadata`);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
const entries = await Promise.all(Object.entries(args.contractKv).map(async ([alias, metadata]) => {
|
|
148
|
+
const binding = args.bindings[alias];
|
|
149
|
+
if (!binding) {
|
|
150
|
+
if (!metadata.required) {
|
|
151
|
+
return [alias, undefined];
|
|
152
|
+
}
|
|
153
|
+
throw new Error(`Required KV binding '${alias}' is unavailable`);
|
|
154
|
+
}
|
|
155
|
+
const store = await TypedKV.open(args.nc, binding.bucket, metadata.schema, {
|
|
156
|
+
history: binding.history,
|
|
157
|
+
ttl: binding.ttlMs,
|
|
158
|
+
maxValueBytes: binding.maxValueBytes,
|
|
159
|
+
bindOnly: true,
|
|
160
|
+
}).orThrow();
|
|
161
|
+
return [alias, store];
|
|
162
|
+
}));
|
|
163
|
+
return Object.fromEntries(entries);
|
|
164
|
+
}
|
|
165
|
+
const MANAGED_JOB_WORKERS = Symbol("trellis.managedJobWorkers");
|
|
166
|
+
function createServiceEventPublishFacade(outbound) {
|
|
167
|
+
const surface = {};
|
|
168
|
+
for (const event of Object.keys(outbound.api.events ?? {})) {
|
|
169
|
+
addSurfaceLeaf(surface, event, {
|
|
170
|
+
prepare: (payload) => outbound.prepare(event, payload),
|
|
171
|
+
publish: (payload) => outbound.publish(event, payload),
|
|
172
|
+
});
|
|
173
|
+
}
|
|
174
|
+
return surface;
|
|
175
|
+
}
|
|
176
|
+
/**
|
|
177
|
+
* @internal Shared by Trellis-owned service bootstrap paths.
|
|
178
|
+
*/
|
|
179
|
+
export async function createConnectedService(args) {
|
|
180
|
+
const resolvedLog = resolveServiceLogger(args.runtime.log);
|
|
181
|
+
const connection = observeNatsTrellisConnection({
|
|
182
|
+
kind: "service",
|
|
183
|
+
nc: args.nc,
|
|
184
|
+
availability: args.availability,
|
|
185
|
+
onTransportEvent: (event) => args.authorizationProviderCache?.observeTransportEvent(event),
|
|
186
|
+
log: false,
|
|
187
|
+
lifecycleLog: {
|
|
188
|
+
log: resolvedLog,
|
|
189
|
+
context: { service: args.name },
|
|
190
|
+
},
|
|
191
|
+
});
|
|
192
|
+
if (args.authorizationProviderCache) {
|
|
193
|
+
connection.subscribe((status) => args.authorizationProviderCache?.observeConnectionPhase(status.phase));
|
|
194
|
+
}
|
|
195
|
+
const currentApi = (args.runtime.trellisApi ?? args.runtime.api);
|
|
196
|
+
const storeNames = Object.keys(args.bindings.store);
|
|
197
|
+
const runtimeApi = {
|
|
198
|
+
...currentApi,
|
|
199
|
+
rpc: currentApi.rpc,
|
|
200
|
+
operations: storeNames.length === 1
|
|
201
|
+
? Object.fromEntries(Object.entries(currentApi.operations).map(([name, operation]) => [
|
|
202
|
+
name,
|
|
203
|
+
operation.transfer?.store === undefined
|
|
204
|
+
? {
|
|
205
|
+
...operation,
|
|
206
|
+
...(operation.transfer
|
|
207
|
+
? {
|
|
208
|
+
transfer: { ...operation.transfer, store: storeNames[0] },
|
|
209
|
+
}
|
|
210
|
+
: {}),
|
|
211
|
+
}
|
|
212
|
+
: operation,
|
|
213
|
+
]))
|
|
214
|
+
: currentApi.operations,
|
|
215
|
+
};
|
|
216
|
+
const runtime = TrellisServiceRuntime.create(args.name, args.nc, {
|
|
217
|
+
sessionKey: args.auth.sessionKey,
|
|
218
|
+
sign: args.auth.sign,
|
|
219
|
+
contextDigest: args.contextDigest,
|
|
220
|
+
authorizationProviderCache: args.auth.authorizationProviderCache,
|
|
221
|
+
}, {
|
|
222
|
+
log: resolvedLog,
|
|
223
|
+
timeout: args.runtime.timeout,
|
|
224
|
+
stream: args.runtime.stream,
|
|
225
|
+
noResponderRetry: args.runtime.noResponderRetry,
|
|
226
|
+
api: runtimeApi,
|
|
227
|
+
contractId: args.contractId,
|
|
228
|
+
contractDigest: args.contractDigest,
|
|
229
|
+
connection,
|
|
230
|
+
transferSupport: {
|
|
231
|
+
openOperationTransfer: (transferArgs) => getTransfer().createOperationUpload(transferArgs),
|
|
232
|
+
},
|
|
233
|
+
operationDeploymentId: args.healthIdentity?.deploymentId,
|
|
234
|
+
operationConnectionId: args.operationConnectionId,
|
|
235
|
+
feedOwnerId: args.healthIdentity?.instanceId ?? ulid(),
|
|
236
|
+
});
|
|
237
|
+
const outbound = createTrellisInternal(args.name, args.nc, {
|
|
238
|
+
sessionKey: args.auth.sessionKey,
|
|
239
|
+
sign: args.auth.sign,
|
|
240
|
+
contextDigest: args.contextDigest,
|
|
241
|
+
authorizationProviderCache: args.auth.authorizationProviderCache,
|
|
242
|
+
}, {
|
|
243
|
+
log: resolvedLog,
|
|
244
|
+
timeout: args.runtime.timeout,
|
|
245
|
+
stream: args.runtime.stream,
|
|
246
|
+
noResponderRetry: args.runtime.noResponderRetry,
|
|
247
|
+
api: runtimeApi,
|
|
248
|
+
contractId: args.contractId,
|
|
249
|
+
contractDigest: args.contractDigest,
|
|
250
|
+
inboxPrefix: args.inboxPrefix,
|
|
251
|
+
eventConsumers: {
|
|
252
|
+
metadata: args.contractEventConsumers,
|
|
253
|
+
bindings: args.bindings.eventConsumers,
|
|
254
|
+
},
|
|
255
|
+
apiBindings: args.apiBindings,
|
|
256
|
+
connection,
|
|
257
|
+
});
|
|
258
|
+
const resources = {};
|
|
259
|
+
const getTransfer = () => {
|
|
260
|
+
if (!resources.transfer) {
|
|
261
|
+
throw new Error("service transfer helper accessed before initialization");
|
|
262
|
+
}
|
|
263
|
+
return resources.transfer;
|
|
264
|
+
};
|
|
265
|
+
const getHandlerResources = () => {
|
|
266
|
+
if (!resources.handlerResources) {
|
|
267
|
+
throw new Error("service resource handles accessed before initialization");
|
|
268
|
+
}
|
|
269
|
+
return resources.handlerResources;
|
|
270
|
+
};
|
|
271
|
+
const handlerTrellis = {
|
|
272
|
+
rpc: outbound.rpc,
|
|
273
|
+
event: createServiceEventPublishFacade(outbound),
|
|
274
|
+
feed: outbound.feed,
|
|
275
|
+
operation: outbound.operation,
|
|
276
|
+
request: outbound.request.bind(outbound),
|
|
277
|
+
prepare: (event, data) => outbound.prepare(event, data),
|
|
278
|
+
publish: (event, data) => outbound.publish(event, data),
|
|
279
|
+
publishPrepared: (event) => outbound.publishPrepared(event),
|
|
280
|
+
stopEventListeners: () => outbound.stopEventListeners(),
|
|
281
|
+
get kv() {
|
|
282
|
+
return getHandlerResources().kv;
|
|
283
|
+
},
|
|
284
|
+
get store() {
|
|
285
|
+
return getHandlerResources().store;
|
|
286
|
+
},
|
|
287
|
+
get jobs() {
|
|
288
|
+
return getHandlerResources().jobs;
|
|
289
|
+
},
|
|
290
|
+
};
|
|
291
|
+
const health = new ServiceHealthRuntime({
|
|
292
|
+
serviceName: args.name,
|
|
293
|
+
instanceId: args.healthIdentity?.instanceId,
|
|
294
|
+
contractId: args.contractId ?? "unknown",
|
|
295
|
+
contractDigest: args.participantDigest ?? "unknown",
|
|
296
|
+
publishIntervalMs: args.runtime.health?.publishIntervalMs ?? 30_000,
|
|
297
|
+
});
|
|
298
|
+
health.add("nats", () => ({
|
|
299
|
+
status: args.nc.isClosed() ? "failed" : "ok",
|
|
300
|
+
...(args.nc.isClosed() ? { summary: "NATS connection closed" } : {}),
|
|
301
|
+
}));
|
|
302
|
+
const heartbeatEnabled = args.healthIdentity !== undefined;
|
|
303
|
+
let healthPublishTimer;
|
|
304
|
+
let publishingHeartbeat = false;
|
|
305
|
+
const publishHealthHeartbeat = async () => {
|
|
306
|
+
if (!args.healthIdentity || publishingHeartbeat) {
|
|
307
|
+
return;
|
|
308
|
+
}
|
|
309
|
+
publishingHeartbeat = true;
|
|
310
|
+
try {
|
|
311
|
+
await publishHealthHeartbeatSample({
|
|
312
|
+
nc: args.nc,
|
|
313
|
+
identity: {
|
|
314
|
+
sessionKey: args.auth.sessionKey,
|
|
315
|
+
participantKind: "service",
|
|
316
|
+
contractId: health.contractId,
|
|
317
|
+
contractDigest: health.contractDigest,
|
|
318
|
+
deploymentId: args.healthIdentity.deploymentId,
|
|
319
|
+
instanceId: health.instanceId,
|
|
320
|
+
},
|
|
321
|
+
sample: await health.sample(),
|
|
322
|
+
});
|
|
323
|
+
}
|
|
324
|
+
catch (error) {
|
|
325
|
+
resolvedLog.warn({ error }, "Failed to build or publish health heartbeat");
|
|
326
|
+
}
|
|
327
|
+
finally {
|
|
328
|
+
publishingHeartbeat = false;
|
|
329
|
+
}
|
|
330
|
+
};
|
|
331
|
+
const stopHealthPublishing = () => {
|
|
332
|
+
if (healthPublishTimer !== undefined) {
|
|
333
|
+
clearInterval(healthPublishTimer);
|
|
334
|
+
healthPublishTimer = undefined;
|
|
335
|
+
}
|
|
336
|
+
return Promise.resolve();
|
|
337
|
+
};
|
|
338
|
+
const kv = await openServiceKvBindings({
|
|
339
|
+
nc: args.nc,
|
|
340
|
+
bindings: args.bindings.kv ?? {},
|
|
341
|
+
contractKv: args.contractKv,
|
|
342
|
+
});
|
|
343
|
+
const operationTransfer = new ServiceTransfer({
|
|
344
|
+
name: args.name,
|
|
345
|
+
nc: args.nc,
|
|
346
|
+
auth: args.auth,
|
|
347
|
+
stores: Object.fromEntries(Object.entries(args.bindings.store ?? {}).map(([alias, binding]) => [
|
|
348
|
+
alias,
|
|
349
|
+
new InternalStoreHandle(args.nc, binding, storeHandleConstructorToken),
|
|
350
|
+
])),
|
|
351
|
+
});
|
|
352
|
+
const service = Reflect.construct(TrellisServiceSession, [
|
|
353
|
+
args.name,
|
|
354
|
+
args.auth,
|
|
355
|
+
args.nc,
|
|
356
|
+
runtime,
|
|
357
|
+
outbound.event,
|
|
358
|
+
outbound,
|
|
359
|
+
handlerTrellis,
|
|
360
|
+
kv,
|
|
361
|
+
args.contractJobs,
|
|
362
|
+
args.bindings,
|
|
363
|
+
operationTransfer,
|
|
364
|
+
health,
|
|
365
|
+
stopHealthPublishing,
|
|
366
|
+
connection,
|
|
367
|
+
trellisServiceConstructorToken,
|
|
368
|
+
]);
|
|
369
|
+
resources.handlerResources = {
|
|
370
|
+
kv: service.kv,
|
|
371
|
+
store: service.store,
|
|
372
|
+
jobs: service.jobs,
|
|
373
|
+
};
|
|
374
|
+
resources.transfer = operationTransfer;
|
|
375
|
+
if (heartbeatEnabled) {
|
|
376
|
+
await publishHealthHeartbeat();
|
|
377
|
+
healthPublishTimer = setInterval(() => {
|
|
378
|
+
void publishHealthHeartbeat();
|
|
379
|
+
}, health.publishIntervalMs);
|
|
380
|
+
void args.nc.closed().then(stopHealthPublishing, stopHealthPublishing);
|
|
381
|
+
}
|
|
382
|
+
return service;
|
|
383
|
+
}
|
|
384
|
+
function toUnexpectedError(cause) {
|
|
385
|
+
return cause instanceof UnexpectedError
|
|
386
|
+
? cause
|
|
387
|
+
: new UnexpectedError({ cause });
|
|
388
|
+
}
|
|
389
|
+
function resolveSqlOutboxTables(tables) {
|
|
390
|
+
return {
|
|
391
|
+
outbox: tables?.outbox ?? defaultSqlOutboxTables.outbox,
|
|
392
|
+
inbox: tables?.inbox ?? defaultSqlOutboxTables.inbox,
|
|
393
|
+
};
|
|
394
|
+
}
|
|
395
|
+
function createSqlOutboxBaseExecutor(options) {
|
|
396
|
+
return options.executor;
|
|
397
|
+
}
|
|
398
|
+
function createSqlOutboxTransactionRunner(options) {
|
|
399
|
+
return options.transaction;
|
|
400
|
+
}
|
|
401
|
+
function createSqlOutboxEventEnqueueFacade(args) {
|
|
402
|
+
const facade = {};
|
|
403
|
+
const source = args.event;
|
|
404
|
+
for (const [groupName, leaves] of Object.entries(source)) {
|
|
405
|
+
const group = {};
|
|
406
|
+
for (const [leafName, leaf] of Object.entries(leaves)) {
|
|
407
|
+
group[leafName] = {
|
|
408
|
+
enqueue: (payload) => AsyncResult.from((async () => {
|
|
409
|
+
const prepared = leaf.prepare(payload).take();
|
|
410
|
+
if (isErr(prepared))
|
|
411
|
+
return Result.err(prepared.error);
|
|
412
|
+
const record = preparedTrellisEventToOutboxRecord(prepared);
|
|
413
|
+
try {
|
|
414
|
+
const message = await args.repository.enqueue(record);
|
|
415
|
+
args.onEnqueued();
|
|
416
|
+
return Result.ok(message);
|
|
417
|
+
}
|
|
418
|
+
catch (cause) {
|
|
419
|
+
return Result.err(toUnexpectedError(cause));
|
|
420
|
+
}
|
|
421
|
+
})()),
|
|
422
|
+
};
|
|
423
|
+
}
|
|
424
|
+
Object.defineProperty(facade, groupName, {
|
|
425
|
+
value: group,
|
|
426
|
+
enumerable: true,
|
|
427
|
+
configurable: true,
|
|
428
|
+
});
|
|
429
|
+
}
|
|
430
|
+
return facade;
|
|
431
|
+
}
|
|
432
|
+
function createSqlOutboxJobEnqueueFacade(args) {
|
|
433
|
+
const facade = {};
|
|
434
|
+
for (const queueType of Object.keys(args.contractJobs)) {
|
|
435
|
+
const jobsBinding = args.jobsBinding;
|
|
436
|
+
const queueBinding = jobsBinding?.queues[queueType];
|
|
437
|
+
const queue = queueType;
|
|
438
|
+
facade[queue] = {
|
|
439
|
+
create: (payload) => {
|
|
440
|
+
if (!queueBinding) {
|
|
441
|
+
return AsyncResult.err(new ValidationError({
|
|
442
|
+
errors: [{
|
|
443
|
+
path: "/",
|
|
444
|
+
message: `Jobs binding unavailable for queue '${queue}'`,
|
|
445
|
+
}],
|
|
446
|
+
}));
|
|
447
|
+
}
|
|
448
|
+
const submissionId = ulid();
|
|
449
|
+
const jobId = ulid();
|
|
450
|
+
const submission = prepareJobSubmission({
|
|
451
|
+
submissionId,
|
|
452
|
+
mode: "create",
|
|
453
|
+
service: jobsBinding.serviceName,
|
|
454
|
+
queue,
|
|
455
|
+
jobId,
|
|
456
|
+
payload,
|
|
457
|
+
createdAt: new Date().toISOString(),
|
|
458
|
+
});
|
|
459
|
+
const record = {
|
|
460
|
+
id: submissionId,
|
|
461
|
+
kind: "job.create",
|
|
462
|
+
name: queue,
|
|
463
|
+
subject: `${queueBinding.publishPrefix}.${jobId}.created`,
|
|
464
|
+
payload: JSON.stringify(submission),
|
|
465
|
+
headers: {
|
|
466
|
+
"request-id": submission.context.requestId,
|
|
467
|
+
"traceparent": submission.context.traceparent,
|
|
468
|
+
...(submission.context.tracestate
|
|
469
|
+
? { "tracestate": submission.context.tracestate }
|
|
470
|
+
: {}),
|
|
471
|
+
},
|
|
472
|
+
};
|
|
473
|
+
return AsyncResult.from((async () => {
|
|
474
|
+
try {
|
|
475
|
+
await args.repository.enqueue(record);
|
|
476
|
+
args.onEnqueued();
|
|
477
|
+
return Result.ok({
|
|
478
|
+
submissionId,
|
|
479
|
+
jobId,
|
|
480
|
+
queue,
|
|
481
|
+
mode: "create",
|
|
482
|
+
});
|
|
483
|
+
}
|
|
484
|
+
catch (cause) {
|
|
485
|
+
return Result.err(toUnexpectedError(cause));
|
|
486
|
+
}
|
|
487
|
+
})());
|
|
488
|
+
},
|
|
489
|
+
submit: (payload) => {
|
|
490
|
+
if (!queueBinding) {
|
|
491
|
+
return AsyncResult.err(new ValidationError({
|
|
492
|
+
errors: [{
|
|
493
|
+
path: "/",
|
|
494
|
+
message: `Jobs binding unavailable for queue '${queue}'`,
|
|
495
|
+
}],
|
|
496
|
+
}));
|
|
497
|
+
}
|
|
498
|
+
const submissionId = ulid();
|
|
499
|
+
const jobId = ulid();
|
|
500
|
+
const submission = prepareJobSubmission({
|
|
501
|
+
submissionId,
|
|
502
|
+
mode: "submit",
|
|
503
|
+
service: jobsBinding.serviceName,
|
|
504
|
+
queue,
|
|
505
|
+
jobId,
|
|
506
|
+
payload,
|
|
507
|
+
createdAt: new Date().toISOString(),
|
|
508
|
+
});
|
|
509
|
+
const record = {
|
|
510
|
+
id: submissionId,
|
|
511
|
+
kind: "job.submit",
|
|
512
|
+
name: queue,
|
|
513
|
+
subject: `${queueBinding.publishPrefix}.${jobId}.created`,
|
|
514
|
+
payload: JSON.stringify(submission),
|
|
515
|
+
headers: {
|
|
516
|
+
"request-id": submission.context.requestId,
|
|
517
|
+
"traceparent": submission.context.traceparent,
|
|
518
|
+
...(submission.context.tracestate
|
|
519
|
+
? { "tracestate": submission.context.tracestate }
|
|
520
|
+
: {}),
|
|
521
|
+
},
|
|
522
|
+
};
|
|
523
|
+
return AsyncResult.from((async () => {
|
|
524
|
+
try {
|
|
525
|
+
await args.repository.enqueue(record);
|
|
526
|
+
args.onEnqueued();
|
|
527
|
+
return Result.ok({
|
|
528
|
+
submissionId,
|
|
529
|
+
jobId,
|
|
530
|
+
queue,
|
|
531
|
+
mode: "submit",
|
|
532
|
+
});
|
|
533
|
+
}
|
|
534
|
+
catch (cause) {
|
|
535
|
+
return Result.err(toUnexpectedError(cause));
|
|
536
|
+
}
|
|
537
|
+
})());
|
|
538
|
+
},
|
|
539
|
+
};
|
|
540
|
+
}
|
|
541
|
+
return facade;
|
|
542
|
+
}
|
|
543
|
+
function serializeJobHandlerError(error) {
|
|
544
|
+
try {
|
|
545
|
+
return JSON.stringify(error.toSerializable());
|
|
546
|
+
}
|
|
547
|
+
catch {
|
|
548
|
+
return error.message;
|
|
549
|
+
}
|
|
550
|
+
}
|
|
551
|
+
function okVoid() {
|
|
552
|
+
return Result.ok(undefined);
|
|
553
|
+
}
|
|
554
|
+
function wrapVoidTask(task) {
|
|
555
|
+
return AsyncResult.from((async () => {
|
|
556
|
+
try {
|
|
557
|
+
await task();
|
|
558
|
+
return okVoid();
|
|
559
|
+
}
|
|
560
|
+
catch (cause) {
|
|
561
|
+
return Result.err(toUnexpectedError(cause));
|
|
562
|
+
}
|
|
563
|
+
})());
|
|
564
|
+
}
|
|
565
|
+
function isTerminalJobState(state) {
|
|
566
|
+
return state === "completed" || state === "failed" || state === "cancelled" ||
|
|
567
|
+
state === "expired" || state === "skipped" || state === "stale" ||
|
|
568
|
+
state === "dead" || state === "dismissed";
|
|
569
|
+
}
|
|
570
|
+
function isTerminalJobSnapshot(snapshot) {
|
|
571
|
+
return isTerminalJobState(snapshot.state);
|
|
572
|
+
}
|
|
573
|
+
function operationOutputsEqual(left, right) {
|
|
574
|
+
if (Object.is(left, right))
|
|
575
|
+
return true;
|
|
576
|
+
if (typeof left !== typeof right || left === null || right === null) {
|
|
577
|
+
return false;
|
|
578
|
+
}
|
|
579
|
+
if (Array.isArray(left) || Array.isArray(right)) {
|
|
580
|
+
return Array.isArray(left) && Array.isArray(right) &&
|
|
581
|
+
left.length === right.length &&
|
|
582
|
+
left.every((value, index) => operationOutputsEqual(value, right[index]));
|
|
583
|
+
}
|
|
584
|
+
if (typeof left === "object") {
|
|
585
|
+
if (Object.getPrototypeOf(left) !== Object.prototype)
|
|
586
|
+
return false;
|
|
587
|
+
if (Object.getPrototypeOf(right) !== Object.prototype)
|
|
588
|
+
return false;
|
|
589
|
+
const leftRecord = left;
|
|
590
|
+
const rightRecord = right;
|
|
591
|
+
const leftKeys = Object.keys(leftRecord);
|
|
592
|
+
const rightKeys = Object.keys(rightRecord);
|
|
593
|
+
return leftKeys.length === rightKeys.length &&
|
|
594
|
+
leftKeys.every((key) => Object.hasOwn(rightRecord, key) &&
|
|
595
|
+
operationOutputsEqual(leftRecord[key], rightRecord[key]));
|
|
596
|
+
}
|
|
597
|
+
return false;
|
|
598
|
+
}
|
|
599
|
+
function parseJobLifecycleEvent(data) {
|
|
600
|
+
try {
|
|
601
|
+
const decoded = JSON.parse(new TextDecoder().decode(data));
|
|
602
|
+
if (!Value.Check(JobEventSchema, decoded)) {
|
|
603
|
+
return undefined;
|
|
604
|
+
}
|
|
605
|
+
return decoded;
|
|
606
|
+
}
|
|
607
|
+
catch {
|
|
608
|
+
return undefined;
|
|
609
|
+
}
|
|
610
|
+
}
|
|
611
|
+
function jobLifecycleKey(service, jobType, jobId) {
|
|
612
|
+
return `${service}.${jobType}.${jobId}`;
|
|
613
|
+
}
|
|
614
|
+
function subjectMatchesLifecycleEvent(subject, queueBinding, event) {
|
|
615
|
+
const prefix = `${queueBinding.publishPrefix}.`;
|
|
616
|
+
if (!subject.startsWith(prefix))
|
|
617
|
+
return false;
|
|
618
|
+
const suffix = subject.slice(prefix.length).split(".");
|
|
619
|
+
return suffix.length === 2 && suffix[0] === event.jobId &&
|
|
620
|
+
suffix[1] === event.eventType;
|
|
621
|
+
}
|
|
622
|
+
function snapshotFromLifecycleEvent(current, event) {
|
|
623
|
+
if (event.service !== current.service || event.jobType !== current.type ||
|
|
624
|
+
event.jobId !== current.id) {
|
|
625
|
+
return current;
|
|
626
|
+
}
|
|
627
|
+
if (isTerminalJobState(current.state)) {
|
|
628
|
+
return current;
|
|
629
|
+
}
|
|
630
|
+
const base = {
|
|
631
|
+
...current,
|
|
632
|
+
state: event.state,
|
|
633
|
+
updatedAt: event.timestamp,
|
|
634
|
+
tries: event.tries,
|
|
635
|
+
...(event.maxTries !== undefined ? { maxTries: event.maxTries } : {}),
|
|
636
|
+
...(event.deadline !== undefined ? { deadline: event.deadline } : {}),
|
|
637
|
+
...(event.trigger !== undefined ? { trigger: event.trigger } : {}),
|
|
638
|
+
...(event.lineage !== undefined ? { lineage: event.lineage } : {}),
|
|
639
|
+
};
|
|
640
|
+
switch (event.eventType) {
|
|
641
|
+
case "created":
|
|
642
|
+
case "retried":
|
|
643
|
+
return event.payload === undefined ? base : {
|
|
644
|
+
...base,
|
|
645
|
+
payload: event.payload,
|
|
646
|
+
};
|
|
647
|
+
case "started":
|
|
648
|
+
return { ...base, startedAt: event.timestamp };
|
|
649
|
+
case "progress":
|
|
650
|
+
return event.progress === undefined ? base : {
|
|
651
|
+
...base,
|
|
652
|
+
progress: event.progress,
|
|
653
|
+
};
|
|
654
|
+
case "logged":
|
|
655
|
+
return event.logs === undefined ? base : {
|
|
656
|
+
...base,
|
|
657
|
+
logs: [...(current.logs ?? []), ...event.logs],
|
|
658
|
+
};
|
|
659
|
+
case "waiting": {
|
|
660
|
+
if (event.waitEdge === undefined)
|
|
661
|
+
return base;
|
|
662
|
+
const waitingOn = (current.waitingOn ?? []).filter((edge) => edge.id !== event.waitEdge?.id);
|
|
663
|
+
return { ...base, waitingOn: [...waitingOn, event.waitEdge] };
|
|
664
|
+
}
|
|
665
|
+
case "resumed":
|
|
666
|
+
return event.waitEdge === undefined ? base : {
|
|
667
|
+
...base,
|
|
668
|
+
waitingOn: (current.waitingOn ?? []).filter((edge) => edge.id !== event.waitEdge?.id),
|
|
669
|
+
};
|
|
670
|
+
case "completed":
|
|
671
|
+
return {
|
|
672
|
+
...base,
|
|
673
|
+
completedAt: event.timestamp,
|
|
674
|
+
...(event.result !== undefined ? { result: event.result } : {}),
|
|
675
|
+
};
|
|
676
|
+
case "failed":
|
|
677
|
+
case "cancelled":
|
|
678
|
+
case "expired":
|
|
679
|
+
case "skipped":
|
|
680
|
+
case "stale":
|
|
681
|
+
case "staleCompletionIgnored":
|
|
682
|
+
case "dead":
|
|
683
|
+
case "dismissed":
|
|
684
|
+
return event.error === undefined ? base : {
|
|
685
|
+
...base,
|
|
686
|
+
lastError: event.error,
|
|
687
|
+
};
|
|
688
|
+
}
|
|
689
|
+
return base;
|
|
690
|
+
}
|
|
691
|
+
function headersFromJobContext(context) {
|
|
692
|
+
const headers = natsHeaders();
|
|
693
|
+
headers.set("request-id", context.requestId);
|
|
694
|
+
headers.set("traceparent", context.traceparent);
|
|
695
|
+
if (context.tracestate) {
|
|
696
|
+
headers.set("tracestate", context.tracestate);
|
|
697
|
+
}
|
|
698
|
+
return headers;
|
|
699
|
+
}
|
|
700
|
+
function createJobLifecycleTracker(nc) {
|
|
701
|
+
const snapshots = new Map();
|
|
702
|
+
const attempts = new Map();
|
|
703
|
+
const waiters = new Map();
|
|
704
|
+
const subscriptions = new Map();
|
|
705
|
+
let stopped = false;
|
|
706
|
+
const notify = (key, snapshot) => {
|
|
707
|
+
if (!isTerminalJobSnapshot(snapshot))
|
|
708
|
+
return;
|
|
709
|
+
const pending = waiters.get(key) ?? [];
|
|
710
|
+
waiters.delete(key);
|
|
711
|
+
for (const waiter of pending)
|
|
712
|
+
waiter.resolve(snapshot);
|
|
713
|
+
};
|
|
714
|
+
const apply = (event) => {
|
|
715
|
+
const key = jobLifecycleKey(event.service, event.jobType, event.jobId);
|
|
716
|
+
attempts.set(key, Math.max(attempts.get(key) ?? 0, event.tries));
|
|
717
|
+
const current = snapshots.get(key);
|
|
718
|
+
if (!current)
|
|
719
|
+
return undefined;
|
|
720
|
+
const next = snapshotFromLifecycleEvent(current, event);
|
|
721
|
+
snapshots.set(key, next);
|
|
722
|
+
notify(key, next);
|
|
723
|
+
return next;
|
|
724
|
+
};
|
|
725
|
+
return {
|
|
726
|
+
watch(queueBinding) {
|
|
727
|
+
if (subscriptions.has(queueBinding.publishPrefix))
|
|
728
|
+
return;
|
|
729
|
+
const subscription = nc.subscribe(`${queueBinding.publishPrefix}.*.*`);
|
|
730
|
+
subscriptions.set(queueBinding.publishPrefix, subscription);
|
|
731
|
+
void (async () => {
|
|
732
|
+
for await (const msg of subscription) {
|
|
733
|
+
const event = parseJobLifecycleEvent(msg.data);
|
|
734
|
+
if (!event || !subjectMatchesLifecycleEvent(msg.subject, queueBinding, event)) {
|
|
735
|
+
continue;
|
|
736
|
+
}
|
|
737
|
+
apply(event);
|
|
738
|
+
}
|
|
739
|
+
})();
|
|
740
|
+
},
|
|
741
|
+
seed(snapshot) {
|
|
742
|
+
const key = jobLifecycleKey(snapshot.service, snapshot.type, snapshot.id);
|
|
743
|
+
attempts.set(key, Math.max(attempts.get(key) ?? 0, snapshot.tries));
|
|
744
|
+
const current = snapshots.get(key);
|
|
745
|
+
if (current && isTerminalJobState(current.state))
|
|
746
|
+
return;
|
|
747
|
+
snapshots.set(key, snapshot);
|
|
748
|
+
notify(key, snapshot);
|
|
749
|
+
},
|
|
750
|
+
get(args) {
|
|
751
|
+
const current = snapshots.get(jobLifecycleKey(args.service, args.jobType, args.id));
|
|
752
|
+
return current;
|
|
753
|
+
},
|
|
754
|
+
attempt(args) {
|
|
755
|
+
return attempts.get(jobLifecycleKey(args.service, args.jobType, args.id));
|
|
756
|
+
},
|
|
757
|
+
apply,
|
|
758
|
+
wait(snapshot) {
|
|
759
|
+
this.seed(snapshot);
|
|
760
|
+
const key = jobLifecycleKey(snapshot.service, snapshot.type, snapshot.id);
|
|
761
|
+
const current = snapshots.get(key);
|
|
762
|
+
if (current && isTerminalJobSnapshot(current)) {
|
|
763
|
+
return Promise.resolve(current);
|
|
764
|
+
}
|
|
765
|
+
if (stopped) {
|
|
766
|
+
return Promise.reject(toUnexpectedError(new Error("job lifecycle tracker stopped")));
|
|
767
|
+
}
|
|
768
|
+
return new Promise((resolve, reject) => {
|
|
769
|
+
const pending = waiters.get(key) ?? [];
|
|
770
|
+
pending.push({
|
|
771
|
+
resolve: resolve,
|
|
772
|
+
reject,
|
|
773
|
+
});
|
|
774
|
+
waiters.set(key, pending);
|
|
775
|
+
});
|
|
776
|
+
},
|
|
777
|
+
stop() {
|
|
778
|
+
stopped = true;
|
|
779
|
+
for (const subscription of subscriptions.values()) {
|
|
780
|
+
subscription.unsubscribe();
|
|
781
|
+
}
|
|
782
|
+
subscriptions.clear();
|
|
783
|
+
const error = toUnexpectedError(new Error("job lifecycle tracker stopped"));
|
|
784
|
+
for (const pending of waiters.values()) {
|
|
785
|
+
for (const waiter of pending)
|
|
786
|
+
waiter.reject(error);
|
|
787
|
+
}
|
|
788
|
+
waiters.clear();
|
|
789
|
+
attempts.clear();
|
|
790
|
+
},
|
|
791
|
+
};
|
|
792
|
+
}
|
|
793
|
+
function createJobRef(args) {
|
|
794
|
+
args.lifecycle.seed(args.seed);
|
|
795
|
+
return new JobRef({
|
|
796
|
+
id: args.seed.id,
|
|
797
|
+
service: args.seed.service,
|
|
798
|
+
jobType: args.queueType,
|
|
799
|
+
}, {
|
|
800
|
+
get: () => AsyncResult.ok(args.lifecycle.get({
|
|
801
|
+
service: args.seed.service,
|
|
802
|
+
jobType: args.queueType,
|
|
803
|
+
id: args.seed.id,
|
|
804
|
+
}) ?? args.seed),
|
|
805
|
+
wait: () => AsyncResult.from((async () => {
|
|
806
|
+
try {
|
|
807
|
+
return Result.ok(await args.lifecycle.wait(args.seed));
|
|
808
|
+
}
|
|
809
|
+
catch (cause) {
|
|
810
|
+
return Result.err(toUnexpectedError(cause));
|
|
811
|
+
}
|
|
812
|
+
})()),
|
|
813
|
+
cancel: () => AsyncResult.from(Promise.resolve().then(() => {
|
|
814
|
+
const current = args.lifecycle.get({
|
|
815
|
+
service: args.seed.service,
|
|
816
|
+
jobType: args.queueType,
|
|
817
|
+
id: args.seed.id,
|
|
818
|
+
}) ?? args.seed;
|
|
819
|
+
if (isTerminalJobState(current.state)) {
|
|
820
|
+
return Result.ok(current);
|
|
821
|
+
}
|
|
822
|
+
const event = {
|
|
823
|
+
jobId: args.seed.id,
|
|
824
|
+
service: current.service,
|
|
825
|
+
jobType: args.queueType,
|
|
826
|
+
eventType: "cancelled",
|
|
827
|
+
state: "cancelled",
|
|
828
|
+
previousState: current.state,
|
|
829
|
+
context: current.context,
|
|
830
|
+
tries: current.tries,
|
|
831
|
+
error: "cancelled",
|
|
832
|
+
timestamp: new Date().toISOString(),
|
|
833
|
+
};
|
|
834
|
+
try {
|
|
835
|
+
args.nc.publish(`${args.queueBinding.publishPrefix}.${args.seed.id}.cancelled`, new TextEncoder().encode(JSON.stringify(event)), { headers: headersFromJobContext(event.context) });
|
|
836
|
+
}
|
|
837
|
+
catch (cause) {
|
|
838
|
+
return Result.err(toUnexpectedError(cause));
|
|
839
|
+
}
|
|
840
|
+
return Result.ok(args.lifecycle.apply(event) ?? current);
|
|
841
|
+
})),
|
|
842
|
+
updates: args.updates,
|
|
843
|
+
});
|
|
844
|
+
}
|
|
845
|
+
function subscribeToJobUpdates(args) {
|
|
846
|
+
return AsyncResult.from(Promise.resolve().then(() => {
|
|
847
|
+
if (!args.queue.update || !args.queue.updatesPrefix) {
|
|
848
|
+
return Result.err(toUnexpectedError(new Error("Job updates are not configured for this queue")));
|
|
849
|
+
}
|
|
850
|
+
if (!/^[^.>*\s]+$/u.test(args.jobId)) {
|
|
851
|
+
return Result.err(new ValidationError({
|
|
852
|
+
errors: [{
|
|
853
|
+
path: "/jobId",
|
|
854
|
+
message: "Job id must be one NATS token",
|
|
855
|
+
}],
|
|
856
|
+
}));
|
|
857
|
+
}
|
|
858
|
+
const subscription = args.nc.subscribe(`${args.queue.updatesPrefix}.${args.jobId}`);
|
|
859
|
+
args.active.add(subscription);
|
|
860
|
+
const close = () => {
|
|
861
|
+
subscription.unsubscribe();
|
|
862
|
+
args.active.delete(subscription);
|
|
863
|
+
};
|
|
864
|
+
const abort = () => close();
|
|
865
|
+
args.options?.signal?.addEventListener("abort", abort, { once: true });
|
|
866
|
+
if (args.options?.signal?.aborted)
|
|
867
|
+
close();
|
|
868
|
+
const updates = {
|
|
869
|
+
unsubscribe: close,
|
|
870
|
+
async *[Symbol.asyncIterator]() {
|
|
871
|
+
let attempt = 0;
|
|
872
|
+
let sequence = 0;
|
|
873
|
+
try {
|
|
874
|
+
for await (const message of subscription) {
|
|
875
|
+
const envelope = decodeJobUpdateEnvelope(message.data);
|
|
876
|
+
if (!envelope || envelope.jobId !== args.jobId)
|
|
877
|
+
continue;
|
|
878
|
+
if (!isJsonValue(envelope.update))
|
|
879
|
+
continue;
|
|
880
|
+
const parsed = parseSchema(args.updateSchema, envelope.update)
|
|
881
|
+
.take();
|
|
882
|
+
if (isErr(parsed))
|
|
883
|
+
continue;
|
|
884
|
+
const lifecycleAttempt = args.lifecycle.attempt({
|
|
885
|
+
service: args.service,
|
|
886
|
+
jobType: args.queue.queueType,
|
|
887
|
+
id: args.jobId,
|
|
888
|
+
}) ?? 0;
|
|
889
|
+
if (lifecycleAttempt > attempt) {
|
|
890
|
+
attempt = lifecycleAttempt;
|
|
891
|
+
sequence = 0;
|
|
892
|
+
}
|
|
893
|
+
if (envelope.attempt < attempt)
|
|
894
|
+
continue;
|
|
895
|
+
if (envelope.attempt > attempt) {
|
|
896
|
+
attempt = envelope.attempt;
|
|
897
|
+
sequence = 0;
|
|
898
|
+
}
|
|
899
|
+
if (envelope.sequence <= sequence)
|
|
900
|
+
continue;
|
|
901
|
+
sequence = envelope.sequence;
|
|
902
|
+
yield parsed;
|
|
903
|
+
}
|
|
904
|
+
}
|
|
905
|
+
finally {
|
|
906
|
+
close();
|
|
907
|
+
args.options?.signal?.removeEventListener("abort", abort);
|
|
908
|
+
}
|
|
909
|
+
},
|
|
910
|
+
};
|
|
911
|
+
return Result.ok(updates);
|
|
912
|
+
}));
|
|
913
|
+
}
|
|
914
|
+
function createNoopJobWorkerHost() {
|
|
915
|
+
return new JobWorkerHostAdapter({
|
|
916
|
+
stop: () => AsyncResult.ok(undefined),
|
|
917
|
+
join: () => AsyncResult.ok(undefined),
|
|
918
|
+
});
|
|
919
|
+
}
|
|
920
|
+
function createJobsFacade(args) {
|
|
921
|
+
const handlers = new Map();
|
|
922
|
+
const jobsFacade = {};
|
|
923
|
+
const lifecycle = createJobLifecycleTracker(args.nc);
|
|
924
|
+
const updateSubscriptions = new Set();
|
|
925
|
+
const keyCoordinator = createNatsJobKeyCoordinator(args.nc);
|
|
926
|
+
const jobsBinding = args.jobsBinding
|
|
927
|
+
? normalizeResourceJobsBinding(args.jobsBinding)
|
|
928
|
+
: undefined;
|
|
929
|
+
const manager = new InternalJobManager({
|
|
930
|
+
nc: jetstream(args.nc),
|
|
931
|
+
jobs: jobsBinding,
|
|
932
|
+
keyCoordinator,
|
|
933
|
+
});
|
|
934
|
+
let activeHost;
|
|
935
|
+
let startupPromise;
|
|
936
|
+
let stopPromise;
|
|
937
|
+
for (const queueType of Object.keys(args.contractJobs ?? {})) {
|
|
938
|
+
const queueBinding = jobsBinding?.queues[queueType];
|
|
939
|
+
const updateSchema = args.contractJobs[queueType]?.updateSchema;
|
|
940
|
+
if (queueBinding)
|
|
941
|
+
lifecycle.watch(queueBinding);
|
|
942
|
+
const updates = (jobId, options) => {
|
|
943
|
+
if (!queueBinding || !updateSchema) {
|
|
944
|
+
return AsyncResult.err(toUnexpectedError(new Error(`Job updates are unavailable for queue '${queueType}'`)));
|
|
945
|
+
}
|
|
946
|
+
return subscribeToJobUpdates({
|
|
947
|
+
nc: args.nc,
|
|
948
|
+
active: updateSubscriptions,
|
|
949
|
+
lifecycle,
|
|
950
|
+
service: args.serviceName,
|
|
951
|
+
queue: queueBinding,
|
|
952
|
+
updateSchema,
|
|
953
|
+
jobId,
|
|
954
|
+
options,
|
|
955
|
+
});
|
|
956
|
+
};
|
|
957
|
+
jobsFacade[queueType] = {
|
|
958
|
+
updates,
|
|
959
|
+
create: (payload) => AsyncResult.from((async () => {
|
|
960
|
+
try {
|
|
961
|
+
if (!jobsBinding) {
|
|
962
|
+
return Result.err(toUnexpectedError(new Error("Jobs bindings are unavailable")));
|
|
963
|
+
}
|
|
964
|
+
const queueBinding = jobsBinding.queues[queueType];
|
|
965
|
+
if (!queueBinding) {
|
|
966
|
+
return Result.err(toUnexpectedError(new Error(`Jobs binding for queue '${queueType}' is unavailable`)));
|
|
967
|
+
}
|
|
968
|
+
const created = await manager.create(queueType, payload);
|
|
969
|
+
return Result.ok(createJobRef({
|
|
970
|
+
nc: args.nc,
|
|
971
|
+
queueType,
|
|
972
|
+
jobsBinding,
|
|
973
|
+
queueBinding,
|
|
974
|
+
seed: created,
|
|
975
|
+
lifecycle,
|
|
976
|
+
updates: (options) => updates(created.id, options),
|
|
977
|
+
}));
|
|
978
|
+
}
|
|
979
|
+
catch (cause) {
|
|
980
|
+
if (cause instanceof JobNotEnqueuedError) {
|
|
981
|
+
return Result.err(cause);
|
|
982
|
+
}
|
|
983
|
+
return Result.err(toUnexpectedError(cause));
|
|
984
|
+
}
|
|
985
|
+
})()),
|
|
986
|
+
submit: (payload) => AsyncResult.from((async () => {
|
|
987
|
+
try {
|
|
988
|
+
if (!jobsBinding) {
|
|
989
|
+
return Result.err(toUnexpectedError(new Error("Jobs bindings are unavailable")));
|
|
990
|
+
}
|
|
991
|
+
const queueBinding = jobsBinding.queues[queueType];
|
|
992
|
+
if (!queueBinding) {
|
|
993
|
+
return Result.err(toUnexpectedError(new Error(`Jobs binding for queue '${queueType}' is unavailable`)));
|
|
994
|
+
}
|
|
995
|
+
const outcome = await manager.submit(queueType, payload);
|
|
996
|
+
if (outcome.kind === "accepted") {
|
|
997
|
+
return Result.ok({
|
|
998
|
+
kind: "accepted",
|
|
999
|
+
key: outcome.key,
|
|
1000
|
+
ref: createJobRef({
|
|
1001
|
+
nc: args.nc,
|
|
1002
|
+
queueType,
|
|
1003
|
+
jobsBinding,
|
|
1004
|
+
queueBinding,
|
|
1005
|
+
seed: outcome.job,
|
|
1006
|
+
lifecycle,
|
|
1007
|
+
updates: (options) => updates(outcome.job.id, options),
|
|
1008
|
+
}),
|
|
1009
|
+
});
|
|
1010
|
+
}
|
|
1011
|
+
if (outcome.kind === "replaced") {
|
|
1012
|
+
return Result.ok({
|
|
1013
|
+
kind: "replaced",
|
|
1014
|
+
key: outcome.key,
|
|
1015
|
+
replaced: outcome.replaced,
|
|
1016
|
+
ref: createJobRef({
|
|
1017
|
+
nc: args.nc,
|
|
1018
|
+
queueType,
|
|
1019
|
+
jobsBinding,
|
|
1020
|
+
queueBinding,
|
|
1021
|
+
seed: outcome.job,
|
|
1022
|
+
lifecycle,
|
|
1023
|
+
updates: (options) => updates(outcome.job.id, options),
|
|
1024
|
+
}),
|
|
1025
|
+
});
|
|
1026
|
+
}
|
|
1027
|
+
return Result.ok(outcome);
|
|
1028
|
+
}
|
|
1029
|
+
catch (cause) {
|
|
1030
|
+
return Result.err(toUnexpectedError(cause));
|
|
1031
|
+
}
|
|
1032
|
+
})()),
|
|
1033
|
+
handle: (handler, options) => {
|
|
1034
|
+
if (handlers.has(queueType)) {
|
|
1035
|
+
throw new Error(`Job handler for queue '${queueType}' is already registered`);
|
|
1036
|
+
}
|
|
1037
|
+
if (activeHost || startupPromise) {
|
|
1038
|
+
throw new Error(`Job handler for queue '${queueType}' cannot be registered after worker startup has begun`);
|
|
1039
|
+
}
|
|
1040
|
+
const concurrency = options?.concurrency ?? 1;
|
|
1041
|
+
if (!Number.isInteger(concurrency) || concurrency < 1) {
|
|
1042
|
+
throw new Error(`Job handler for queue '${queueType}' has invalid concurrency ${concurrency}; expected a positive integer`);
|
|
1043
|
+
}
|
|
1044
|
+
handlers.set(queueType, {
|
|
1045
|
+
concurrency,
|
|
1046
|
+
handler: async (job) => await handler({
|
|
1047
|
+
job,
|
|
1048
|
+
client: args.client,
|
|
1049
|
+
}),
|
|
1050
|
+
});
|
|
1051
|
+
},
|
|
1052
|
+
};
|
|
1053
|
+
}
|
|
1054
|
+
const managedWorkers = {
|
|
1055
|
+
start: () => {
|
|
1056
|
+
if (activeHost) {
|
|
1057
|
+
return AsyncResult.ok(activeHost);
|
|
1058
|
+
}
|
|
1059
|
+
if (startupPromise) {
|
|
1060
|
+
return AsyncResult.from(startupPromise);
|
|
1061
|
+
}
|
|
1062
|
+
startupPromise = (async () => {
|
|
1063
|
+
const selectedQueues = [...handlers.keys()];
|
|
1064
|
+
if (selectedQueues.length === 0) {
|
|
1065
|
+
const host = createNoopJobWorkerHost();
|
|
1066
|
+
activeHost = host;
|
|
1067
|
+
return Result.ok(host);
|
|
1068
|
+
}
|
|
1069
|
+
if (!jobsBinding || !args.workStream) {
|
|
1070
|
+
return Result.err(toUnexpectedError(new Error("Jobs infrastructure bindings are unavailable for this service")));
|
|
1071
|
+
}
|
|
1072
|
+
const workStream = args.workStream;
|
|
1073
|
+
const hosts = [];
|
|
1074
|
+
try {
|
|
1075
|
+
for (const queueType of selectedQueues) {
|
|
1076
|
+
const queueBinding = jobsBinding.queues[queueType];
|
|
1077
|
+
if (!queueBinding) {
|
|
1078
|
+
throw new Error(`Unknown jobs queue '${queueType}'`);
|
|
1079
|
+
}
|
|
1080
|
+
const updateSchema = args.contractJobs[queueType]?.updateSchema;
|
|
1081
|
+
const workerUpdates = (jobId, options) => {
|
|
1082
|
+
if (!updateSchema) {
|
|
1083
|
+
return AsyncResult.err(toUnexpectedError(new Error(`Job updates are unavailable for queue '${queueType}'`)));
|
|
1084
|
+
}
|
|
1085
|
+
return subscribeToJobUpdates({
|
|
1086
|
+
nc: args.nc,
|
|
1087
|
+
active: updateSubscriptions,
|
|
1088
|
+
lifecycle,
|
|
1089
|
+
service: args.serviceName,
|
|
1090
|
+
queue: queueBinding,
|
|
1091
|
+
updateSchema,
|
|
1092
|
+
jobId,
|
|
1093
|
+
options,
|
|
1094
|
+
});
|
|
1095
|
+
};
|
|
1096
|
+
const registration = handlers.get(queueType);
|
|
1097
|
+
if (!registration) {
|
|
1098
|
+
throw new Error(`No job handler registered for queue '${queueType}'`);
|
|
1099
|
+
}
|
|
1100
|
+
const host = await startNatsWorkerHostFromBinding({
|
|
1101
|
+
jobs: jobsBinding,
|
|
1102
|
+
workStream,
|
|
1103
|
+
}, {
|
|
1104
|
+
nats: args.nc,
|
|
1105
|
+
instanceId: `${args.serviceName}-worker`,
|
|
1106
|
+
queueTypes: [queueType],
|
|
1107
|
+
queueConcurrency: {
|
|
1108
|
+
[queueType]: registration.concurrency,
|
|
1109
|
+
},
|
|
1110
|
+
manager,
|
|
1111
|
+
heartbeatPublisher: args.nc,
|
|
1112
|
+
getProjectedJob: (job) => Promise.resolve(lifecycle.get({
|
|
1113
|
+
service: job.service,
|
|
1114
|
+
jobType: job.type,
|
|
1115
|
+
id: job.id,
|
|
1116
|
+
})),
|
|
1117
|
+
handler: async (job) => {
|
|
1118
|
+
let updateSequence = 0;
|
|
1119
|
+
let attemptActive = true;
|
|
1120
|
+
const publicJob = new PublicActiveJob(createJobRef({
|
|
1121
|
+
nc: args.nc,
|
|
1122
|
+
queueType,
|
|
1123
|
+
jobsBinding,
|
|
1124
|
+
queueBinding,
|
|
1125
|
+
seed: job.job(),
|
|
1126
|
+
lifecycle,
|
|
1127
|
+
updates: (options) => workerUpdates(job.job().id, options),
|
|
1128
|
+
}), job.job().payload, job.context(), () => job.isCancelled(), {
|
|
1129
|
+
heartbeat: () => wrapVoidTask(() => job.heartbeat()),
|
|
1130
|
+
progress: (value) => wrapVoidTask(() => job.updateProgress(value)),
|
|
1131
|
+
log: (entry) => wrapVoidTask(() => job.log(entry.level, entry.message)),
|
|
1132
|
+
emitUpdate: (value) => AsyncResult.from(Promise.resolve().then(() => {
|
|
1133
|
+
if (!attemptActive) {
|
|
1134
|
+
return Result.err(toUnexpectedError(new Error("Job attempt is no longer active")));
|
|
1135
|
+
}
|
|
1136
|
+
if (!queueBinding.update ||
|
|
1137
|
+
!queueBinding.updatesPrefix ||
|
|
1138
|
+
!updateSchema) {
|
|
1139
|
+
return Result.err(toUnexpectedError(new Error(`Job updates are unavailable for queue '${queueType}'`)));
|
|
1140
|
+
}
|
|
1141
|
+
if (!isJsonValue(value)) {
|
|
1142
|
+
return Result.err(new ValidationError({
|
|
1143
|
+
errors: [{
|
|
1144
|
+
path: "/",
|
|
1145
|
+
message: "Job update must be JSON-serializable",
|
|
1146
|
+
}],
|
|
1147
|
+
}));
|
|
1148
|
+
}
|
|
1149
|
+
const parsed = parseSchema(updateSchema, value)
|
|
1150
|
+
.take();
|
|
1151
|
+
if (isErr(parsed))
|
|
1152
|
+
return parsed;
|
|
1153
|
+
updateSequence += 1;
|
|
1154
|
+
try {
|
|
1155
|
+
args.nc.publish(`${queueBinding.updatesPrefix}.${job.job().id}`, new TextEncoder().encode(JSON.stringify({
|
|
1156
|
+
jobId: job.job().id,
|
|
1157
|
+
attempt: job.job().tries + 1,
|
|
1158
|
+
sequence: updateSequence,
|
|
1159
|
+
timestamp: new Date().toISOString(),
|
|
1160
|
+
update: parsed,
|
|
1161
|
+
})));
|
|
1162
|
+
return Result.ok(undefined);
|
|
1163
|
+
}
|
|
1164
|
+
catch (cause) {
|
|
1165
|
+
return Result.err(toUnexpectedError(cause));
|
|
1166
|
+
}
|
|
1167
|
+
})),
|
|
1168
|
+
waitFor: (target, fn) => job.waitFor(target, fn),
|
|
1169
|
+
redeliveryCount: job.redeliveryCount(),
|
|
1170
|
+
signal: job.signal,
|
|
1171
|
+
});
|
|
1172
|
+
const jobErrorContext = {
|
|
1173
|
+
jobType: queueType,
|
|
1174
|
+
requestId: job.context().requestId,
|
|
1175
|
+
service: args.serviceName,
|
|
1176
|
+
contractId: args.contractId,
|
|
1177
|
+
contractDigest: args.contractDigest,
|
|
1178
|
+
traceId: job.context().traceId,
|
|
1179
|
+
};
|
|
1180
|
+
let handled;
|
|
1181
|
+
try {
|
|
1182
|
+
handled = (await registration.handler(publicJob)).take();
|
|
1183
|
+
}
|
|
1184
|
+
catch (cause) {
|
|
1185
|
+
const annotatedError = annotateHandlerBoundaryError(cause, jobErrorContext);
|
|
1186
|
+
const signal = annotatedError instanceof RetryJobError
|
|
1187
|
+
? InternalJobProcessError.retryable
|
|
1188
|
+
: InternalJobProcessError.failed;
|
|
1189
|
+
throw signal(serializeJobHandlerError(annotatedError));
|
|
1190
|
+
}
|
|
1191
|
+
finally {
|
|
1192
|
+
attemptActive = false;
|
|
1193
|
+
}
|
|
1194
|
+
if (isErr(handled)) {
|
|
1195
|
+
const annotatedError = annotateHandlerBoundaryError(handled.error, jobErrorContext);
|
|
1196
|
+
const signal = annotatedError instanceof RetryJobError
|
|
1197
|
+
? InternalJobProcessError.retryable
|
|
1198
|
+
: InternalJobProcessError.failed;
|
|
1199
|
+
throw signal(serializeJobHandlerError(annotatedError));
|
|
1200
|
+
}
|
|
1201
|
+
return handled;
|
|
1202
|
+
},
|
|
1203
|
+
});
|
|
1204
|
+
hosts.push(host);
|
|
1205
|
+
}
|
|
1206
|
+
}
|
|
1207
|
+
catch (cause) {
|
|
1208
|
+
const stopResults = await Promise.allSettled(hosts.map((host) => host.stop()));
|
|
1209
|
+
const stopErrors = stopResults
|
|
1210
|
+
.filter((result) => result.status === "rejected")
|
|
1211
|
+
.map((result) => result.reason);
|
|
1212
|
+
if (stopErrors.length > 0) {
|
|
1213
|
+
return Result.err(toUnexpectedError(new AggregateError([cause, ...stopErrors])));
|
|
1214
|
+
}
|
|
1215
|
+
return Result.err(toUnexpectedError(cause));
|
|
1216
|
+
}
|
|
1217
|
+
activeHost = new JobWorkerHostAdapter({
|
|
1218
|
+
stop: () => wrapVoidTask(async () => {
|
|
1219
|
+
for (const host of hosts) {
|
|
1220
|
+
await host.stop();
|
|
1221
|
+
}
|
|
1222
|
+
}),
|
|
1223
|
+
join: () => AsyncResult.ok(undefined),
|
|
1224
|
+
});
|
|
1225
|
+
return Result.ok(activeHost);
|
|
1226
|
+
})().finally(() => {
|
|
1227
|
+
startupPromise = undefined;
|
|
1228
|
+
});
|
|
1229
|
+
return AsyncResult.from(startupPromise);
|
|
1230
|
+
},
|
|
1231
|
+
stop: () => {
|
|
1232
|
+
if (stopPromise) {
|
|
1233
|
+
return AsyncResult.from(stopPromise);
|
|
1234
|
+
}
|
|
1235
|
+
stopPromise = (async () => {
|
|
1236
|
+
const startup = startupPromise;
|
|
1237
|
+
if (startup) {
|
|
1238
|
+
const started = await startup;
|
|
1239
|
+
if (isErr(started)) {
|
|
1240
|
+
return Result.ok(undefined);
|
|
1241
|
+
}
|
|
1242
|
+
}
|
|
1243
|
+
if (!activeHost) {
|
|
1244
|
+
for (const subscription of updateSubscriptions) {
|
|
1245
|
+
subscription.unsubscribe();
|
|
1246
|
+
}
|
|
1247
|
+
updateSubscriptions.clear();
|
|
1248
|
+
lifecycle.stop();
|
|
1249
|
+
return Result.ok(undefined);
|
|
1250
|
+
}
|
|
1251
|
+
const host = activeHost;
|
|
1252
|
+
try {
|
|
1253
|
+
return await host.stop();
|
|
1254
|
+
}
|
|
1255
|
+
finally {
|
|
1256
|
+
for (const subscription of updateSubscriptions) {
|
|
1257
|
+
subscription.unsubscribe();
|
|
1258
|
+
}
|
|
1259
|
+
updateSubscriptions.clear();
|
|
1260
|
+
lifecycle.stop();
|
|
1261
|
+
if (activeHost === host) {
|
|
1262
|
+
activeHost = undefined;
|
|
1263
|
+
}
|
|
1264
|
+
stopPromise = undefined;
|
|
1265
|
+
}
|
|
1266
|
+
})();
|
|
1267
|
+
return AsyncResult.from(stopPromise);
|
|
1268
|
+
},
|
|
1269
|
+
};
|
|
1270
|
+
Object.defineProperty(jobsFacade, MANAGED_JOB_WORKERS, {
|
|
1271
|
+
value: managedWorkers,
|
|
1272
|
+
enumerable: false,
|
|
1273
|
+
});
|
|
1274
|
+
return jobsFacade;
|
|
1275
|
+
}
|
|
1276
|
+
/**
|
|
1277
|
+
* Connects a service with caller-supplied runtime dependencies for tests and
|
|
1278
|
+
* Trellis-owned internals. This helper is intentionally not re-exported from
|
|
1279
|
+
* public package subpaths.
|
|
1280
|
+
*
|
|
1281
|
+
* @internal
|
|
1282
|
+
*/
|
|
1283
|
+
export function connectTrellisServiceWithRuntimeDeps(args, deps) {
|
|
1284
|
+
return AsyncResult.from((async () => {
|
|
1285
|
+
const totalStartedAt = performance.now();
|
|
1286
|
+
try {
|
|
1287
|
+
const runtimeDeps = {
|
|
1288
|
+
...(await loadDefaultServiceRuntimeDeps()),
|
|
1289
|
+
...deps,
|
|
1290
|
+
};
|
|
1291
|
+
const serviceName = args.name ?? args.participant.identity;
|
|
1292
|
+
if (args.telemetry !== false && args.telemetry?.enabled !== false) {
|
|
1293
|
+
runtimeDeps.initTelemetry?.(serviceName);
|
|
1294
|
+
}
|
|
1295
|
+
const identityAuth = await createAuth({
|
|
1296
|
+
sessionKeySeed: args.seed,
|
|
1297
|
+
});
|
|
1298
|
+
const sessionAuth = await createAuth({
|
|
1299
|
+
sessionKeySeed: base64urlEncode(crypto.getRandomValues(new Uint8Array(32))),
|
|
1300
|
+
});
|
|
1301
|
+
const bootstrapLog = resolveServiceLogger(args.runtime?.log);
|
|
1302
|
+
const bootstrapStartedAt = performance.now();
|
|
1303
|
+
const bootstrap = await fetchServiceBootstrapInfo({
|
|
1304
|
+
trellisUrl: args.trellisUrl,
|
|
1305
|
+
serviceName,
|
|
1306
|
+
name: args.name,
|
|
1307
|
+
contractId: args.participant.identity,
|
|
1308
|
+
contractDigest: participantEvidence(args.participant).packageDigest,
|
|
1309
|
+
contract: args.participant,
|
|
1310
|
+
identityAuth,
|
|
1311
|
+
sessionAuth,
|
|
1312
|
+
log: bootstrapLog,
|
|
1313
|
+
});
|
|
1314
|
+
recordTrellisDuration("trellis.connect.duration", performance.now() - bootstrapStartedAt, {
|
|
1315
|
+
phase: "bootstrap",
|
|
1316
|
+
participantKind: "service",
|
|
1317
|
+
outcome: "ok",
|
|
1318
|
+
});
|
|
1319
|
+
const authorizationContexts = new AuthorizationContextCache(args.trellisUrl);
|
|
1320
|
+
authorizationContexts.setServerClockOffsetMs(bootstrap.serverClockOffsetMs);
|
|
1321
|
+
await authorizationContexts.install(bootstrap.connectInfo.authorizationContext, {
|
|
1322
|
+
bootstrapJwt: bootstrap.connectInfo.jwt,
|
|
1323
|
+
bootstrapJwtExpiresAt: bootstrap.connectInfo.jwtExpiresAt,
|
|
1324
|
+
});
|
|
1325
|
+
const verifiedContext = authorizationContexts.current();
|
|
1326
|
+
if (verifiedContext.context.participantId !== args.participant.identity) {
|
|
1327
|
+
throw new Error("service authorization context belongs to another participant");
|
|
1328
|
+
}
|
|
1329
|
+
if (!verifiedContext.context.deploymentId ||
|
|
1330
|
+
!verifiedContext.context.instanceId) {
|
|
1331
|
+
throw new Error("service authorization context is missing its deployment assignment");
|
|
1332
|
+
}
|
|
1333
|
+
const { authenticator, inboxPrefix } = await sessionAuth
|
|
1334
|
+
.natsConnectOptions({
|
|
1335
|
+
sessionId: bootstrap.connectInfo.connectionId,
|
|
1336
|
+
contextDigest: () => authorizationContexts.transportCurrent().contextDigest,
|
|
1337
|
+
jwt: () => authorizationContexts.transportRoutingJwt(),
|
|
1338
|
+
authorizationUsable: () => authorizationProviderCache?.transportUsable() ?? true,
|
|
1339
|
+
});
|
|
1340
|
+
let nc;
|
|
1341
|
+
let authorizationProviderCache;
|
|
1342
|
+
let stopContextRefresh;
|
|
1343
|
+
try {
|
|
1344
|
+
const natsStartedAt = performance.now();
|
|
1345
|
+
nc = await runtimeDeps.connect({
|
|
1346
|
+
servers: selectRuntimeTransportServers(bootstrap.connectInfo.transports),
|
|
1347
|
+
maxReconnectAttempts: DEFAULT_RUNTIME_MAX_RECONNECT_ATTEMPTS,
|
|
1348
|
+
ignoreAuthErrorAbort: true,
|
|
1349
|
+
waitOnFirstConnect: DEFAULT_SERVICE_RUNTIME_WAIT_ON_FIRST_CONNECT,
|
|
1350
|
+
inboxPrefix,
|
|
1351
|
+
authenticator,
|
|
1352
|
+
});
|
|
1353
|
+
const connectedNats = nc;
|
|
1354
|
+
authorizationProviderCache = await AuthorizationProviderCache.attach(connectedNats, authorizationContexts.bundle().authorizationRegistry, inboxPrefix, authorizationContexts);
|
|
1355
|
+
authorizationProviderCache.start();
|
|
1356
|
+
await authorizationProviderCache.waitReady();
|
|
1357
|
+
await authorizationProviderCache.retainOwnContext();
|
|
1358
|
+
void connectedNats.closed().then(() => {
|
|
1359
|
+
stopContextRefresh?.();
|
|
1360
|
+
authorizationProviderCache?.stop();
|
|
1361
|
+
}, () => {
|
|
1362
|
+
stopContextRefresh?.();
|
|
1363
|
+
authorizationProviderCache?.stop();
|
|
1364
|
+
});
|
|
1365
|
+
recordTrellisDuration("trellis.connect.duration", performance.now() - natsStartedAt, {
|
|
1366
|
+
phase: "nats_connect",
|
|
1367
|
+
participantKind: "service",
|
|
1368
|
+
outcome: "ok",
|
|
1369
|
+
});
|
|
1370
|
+
}
|
|
1371
|
+
catch (cause) {
|
|
1372
|
+
authorizationProviderCache?.stop();
|
|
1373
|
+
stopContextRefresh?.();
|
|
1374
|
+
if (nc && !nc.isClosed())
|
|
1375
|
+
await nc.close();
|
|
1376
|
+
throw new TransportError({
|
|
1377
|
+
code: "trellis.runtime.connect_failed",
|
|
1378
|
+
message: "Trellis could not open the service runtime connection.",
|
|
1379
|
+
hint: "Retry the connection. If it keeps failing, check Trellis transport availability.",
|
|
1380
|
+
cause,
|
|
1381
|
+
context: {
|
|
1382
|
+
trellisUrl: args.trellisUrl,
|
|
1383
|
+
contractId: args.participant.identity,
|
|
1384
|
+
contractDigest: bootstrap.connectInfo.participantDigest,
|
|
1385
|
+
},
|
|
1386
|
+
});
|
|
1387
|
+
}
|
|
1388
|
+
if (!nc || !authorizationProviderCache) {
|
|
1389
|
+
throw new Error("Trellis service runtime connection was not established");
|
|
1390
|
+
}
|
|
1391
|
+
const serviceAuth = {
|
|
1392
|
+
...sessionAuth,
|
|
1393
|
+
authorizationProviderCache,
|
|
1394
|
+
};
|
|
1395
|
+
try {
|
|
1396
|
+
const contractRuntime = getParticipantRuntime(args.participant);
|
|
1397
|
+
const runtime = {
|
|
1398
|
+
...(args.runtime ?? {}),
|
|
1399
|
+
api: bindApiRoutes(contractRuntime.ownedApi, bootstrap.binding.apiBindings),
|
|
1400
|
+
trellisApi: bindApiRoutes(contractRuntime.api, bootstrap.binding.apiBindings),
|
|
1401
|
+
operationDeploymentId: verifiedContext.context.deploymentId,
|
|
1402
|
+
operationConnectionId: verifiedContext.context.connectionId,
|
|
1403
|
+
};
|
|
1404
|
+
const service = await createConnectedService({
|
|
1405
|
+
name: serviceName,
|
|
1406
|
+
auth: serviceAuth,
|
|
1407
|
+
nc,
|
|
1408
|
+
inboxPrefix,
|
|
1409
|
+
contextDigest: () => authorizationContexts.current().contextDigest,
|
|
1410
|
+
operationConnectionId: verifiedContext.context.connectionId,
|
|
1411
|
+
contractId: args.participant.identity,
|
|
1412
|
+
contractDigest: bootstrap.connectInfo.participantDigest,
|
|
1413
|
+
participantDigest: bootstrap.connectInfo.participantDigest,
|
|
1414
|
+
contractJobs: contractRuntime.jobs,
|
|
1415
|
+
contractKv: contractRuntime.kv,
|
|
1416
|
+
contractEventConsumers: contractRuntime.eventConsumers,
|
|
1417
|
+
apiBindings: bootstrap.binding.apiBindings,
|
|
1418
|
+
runtime,
|
|
1419
|
+
bindings: bootstrap.binding.resources,
|
|
1420
|
+
availability: participantAvailability(args.participant, bootstrap.binding.apiBindings, bootstrap.binding.resources),
|
|
1421
|
+
healthIdentity: {
|
|
1422
|
+
instanceId: verifiedContext.context.instanceId,
|
|
1423
|
+
deploymentId: verifiedContext.context.deploymentId,
|
|
1424
|
+
},
|
|
1425
|
+
authorizationProviderCache,
|
|
1426
|
+
});
|
|
1427
|
+
let installedAvailability = participantAvailability(args.participant, bootstrap.binding.apiBindings, bootstrap.binding.resources);
|
|
1428
|
+
authorizationProviderCache.onOwnInvalidated(() => installConnectionAvailability(service.connection, participantAvailability(args.participant, {}, {}, [])));
|
|
1429
|
+
authorizationProviderCache.onOwnResumed(() => {
|
|
1430
|
+
installConnectionAvailability(service.connection, installedAvailability);
|
|
1431
|
+
});
|
|
1432
|
+
stopContextRefresh = startAuthorizationContextRefresh({
|
|
1433
|
+
trellisUrl: args.trellisUrl,
|
|
1434
|
+
sessionId: bootstrap.connectInfo.connectionId,
|
|
1435
|
+
auth: sessionAuth,
|
|
1436
|
+
cache: authorizationContexts,
|
|
1437
|
+
refresh: async (shouldInstall) => {
|
|
1438
|
+
try {
|
|
1439
|
+
const next = await fetchServiceBootstrapInfo({
|
|
1440
|
+
trellisUrl: args.trellisUrl,
|
|
1441
|
+
serviceName,
|
|
1442
|
+
name: args.name,
|
|
1443
|
+
contractId: args.participant.identity,
|
|
1444
|
+
contractDigest: bootstrap.connectInfo.participantDigest,
|
|
1445
|
+
contract: args.participant,
|
|
1446
|
+
identityAuth,
|
|
1447
|
+
sessionAuth,
|
|
1448
|
+
log: bootstrapLog,
|
|
1449
|
+
connectionId: bootstrap.connectInfo.connectionId,
|
|
1450
|
+
});
|
|
1451
|
+
authorizationContexts.setServerClockOffsetMs(next.serverClockOffsetMs);
|
|
1452
|
+
const context = await authorizationContexts.prepare(next.connectInfo.authorizationContext, {
|
|
1453
|
+
bootstrapJwt: next.connectInfo.jwt,
|
|
1454
|
+
bootstrapJwtExpiresAt: next.connectInfo.jwtExpiresAt,
|
|
1455
|
+
}, next.serverNow, shouldInstall, {
|
|
1456
|
+
connectionId: next.connectInfo.connectionId,
|
|
1457
|
+
loginSessionId: null,
|
|
1458
|
+
participantId: next.connectInfo.participantId,
|
|
1459
|
+
inboxPrefix,
|
|
1460
|
+
transports: next.connectInfo.transports,
|
|
1461
|
+
}, () => () => {
|
|
1462
|
+
installedAvailability = participantAvailability(args.participant, next.binding.apiBindings, next.binding.resources);
|
|
1463
|
+
installConnectionAvailability(service.connection, installedAvailability);
|
|
1464
|
+
refreshApiRoutes(runtime.api, next.binding.apiBindings);
|
|
1465
|
+
refreshApiRoutes(runtime.trellisApi, next.binding.apiBindings);
|
|
1466
|
+
});
|
|
1467
|
+
return context;
|
|
1468
|
+
}
|
|
1469
|
+
catch (error) {
|
|
1470
|
+
if (error instanceof TrellisHttpError) {
|
|
1471
|
+
throw new AuthorizationContextRefreshError(error.status, error.code);
|
|
1472
|
+
}
|
|
1473
|
+
throw error;
|
|
1474
|
+
}
|
|
1475
|
+
},
|
|
1476
|
+
onRefresh: async (context) => {
|
|
1477
|
+
nc.setServers(selectRuntimeTransportServers(authorizationContexts.transportRuntimeBinding().transports));
|
|
1478
|
+
if (service.connection.status.phase === "connected") {
|
|
1479
|
+
await nc.reconnect();
|
|
1480
|
+
}
|
|
1481
|
+
await authorizationProviderCache.waitReady({ timeoutMs: 30_000 });
|
|
1482
|
+
const generation = authorizationProviderCache
|
|
1483
|
+
.connectionGeneration();
|
|
1484
|
+
await authorizationProviderCache.retainOwnCandidate(context.contextDigest, generation);
|
|
1485
|
+
authorizationProviderCache.promoteOwnCandidate(context.contextDigest, generation);
|
|
1486
|
+
},
|
|
1487
|
+
onTerminalFailure: async () => {
|
|
1488
|
+
if (!nc.isClosed())
|
|
1489
|
+
await nc.close();
|
|
1490
|
+
},
|
|
1491
|
+
});
|
|
1492
|
+
recordTrellisDuration("trellis.connect.duration", performance.now() - totalStartedAt, {
|
|
1493
|
+
phase: "total",
|
|
1494
|
+
participantKind: "service",
|
|
1495
|
+
outcome: "ok",
|
|
1496
|
+
});
|
|
1497
|
+
return Result.ok(service);
|
|
1498
|
+
}
|
|
1499
|
+
catch (cause) {
|
|
1500
|
+
await closeFailedServiceBootstrapConnection(nc);
|
|
1501
|
+
throw cause;
|
|
1502
|
+
}
|
|
1503
|
+
}
|
|
1504
|
+
catch (cause) {
|
|
1505
|
+
return Result.err(cause instanceof TransportError ? cause : toUnexpectedError(cause));
|
|
1506
|
+
}
|
|
1507
|
+
})());
|
|
1508
|
+
}
|
|
1509
|
+
/** Connected session implementation backing the public service type. */
|
|
1510
|
+
export class TrellisServiceSession {
|
|
1511
|
+
name;
|
|
1512
|
+
auth;
|
|
1513
|
+
#runtime;
|
|
1514
|
+
#nc;
|
|
1515
|
+
#handlerTrellis;
|
|
1516
|
+
/** Event lifecycle surface for service startup listeners and publishers. */
|
|
1517
|
+
event;
|
|
1518
|
+
kv;
|
|
1519
|
+
store;
|
|
1520
|
+
jobs;
|
|
1521
|
+
health;
|
|
1522
|
+
handle;
|
|
1523
|
+
/** Framework-neutral lifecycle handle for the service runtime connection. */
|
|
1524
|
+
connection;
|
|
1525
|
+
#operationTransfer;
|
|
1526
|
+
#stopHealthPublishing;
|
|
1527
|
+
#managedJobWorkers;
|
|
1528
|
+
#contractJobs;
|
|
1529
|
+
#jobsBinding;
|
|
1530
|
+
#ownedOutboxDispatchers = new Set();
|
|
1531
|
+
#waitPromise;
|
|
1532
|
+
#stopPromise;
|
|
1533
|
+
constructor(name, auth, nc, runtime, event, providerCaller, handlerTrellis, kv, contractJobs, bindings, operationTransfer, health, stopHealthPublishing, connection, token) {
|
|
1534
|
+
if (token !== trellisServiceConstructorToken) {
|
|
1535
|
+
throw new TypeError("TrellisService instances are created by connect()");
|
|
1536
|
+
}
|
|
1537
|
+
const storeBindings = bindings.store ?? {};
|
|
1538
|
+
this.name = name;
|
|
1539
|
+
this.auth = auth;
|
|
1540
|
+
this.#nc = nc;
|
|
1541
|
+
this.#runtime = runtime;
|
|
1542
|
+
this.#handlerTrellis = handlerTrellis;
|
|
1543
|
+
Object.defineProperty(this, PROVIDER_CALLER, {
|
|
1544
|
+
value: providerCaller,
|
|
1545
|
+
});
|
|
1546
|
+
this.event = event;
|
|
1547
|
+
this.kv = kv;
|
|
1548
|
+
this.store = Object.fromEntries(Object.entries(storeBindings).map(([alias, binding]) => [
|
|
1549
|
+
alias,
|
|
1550
|
+
new InternalStoreHandle(nc, binding, storeHandleConstructorToken),
|
|
1551
|
+
]));
|
|
1552
|
+
this.#operationTransfer = operationTransfer;
|
|
1553
|
+
const jobs = createJobsFacade({
|
|
1554
|
+
serviceName: name,
|
|
1555
|
+
contractId: health.contractId,
|
|
1556
|
+
contractDigest: health.contractDigest,
|
|
1557
|
+
nc,
|
|
1558
|
+
contractJobs,
|
|
1559
|
+
client: handlerTrellis,
|
|
1560
|
+
jobsBinding: bindings.jobs,
|
|
1561
|
+
workStream: bindings.jobs?.workStream,
|
|
1562
|
+
});
|
|
1563
|
+
this.jobs = jobs;
|
|
1564
|
+
this.#managedJobWorkers = jobs[MANAGED_JOB_WORKERS];
|
|
1565
|
+
this.#contractJobs = contractJobs;
|
|
1566
|
+
this.#jobsBinding = bindings.jobs;
|
|
1567
|
+
this.health = health;
|
|
1568
|
+
this.handle = this.#createHandleFacade();
|
|
1569
|
+
this.connection = connection;
|
|
1570
|
+
this.#stopHealthPublishing = stopHealthPublishing;
|
|
1571
|
+
}
|
|
1572
|
+
/**
|
|
1573
|
+
* Creates an explicit SQL outbox helper for service-owned transactions.
|
|
1574
|
+
* Services should create this at startup and close over it in handlers.
|
|
1575
|
+
*/
|
|
1576
|
+
createSqlOutbox(options) {
|
|
1577
|
+
const binding = this.#createSqlOutboxBinding(options);
|
|
1578
|
+
return this.#createSqlOutbox(binding);
|
|
1579
|
+
}
|
|
1580
|
+
/** Publishes a prepared event through the service runtime connection. */
|
|
1581
|
+
publishPrepared(event) {
|
|
1582
|
+
return this.#handlerTrellis.publishPrepared(event);
|
|
1583
|
+
}
|
|
1584
|
+
#createHandleFacade() {
|
|
1585
|
+
const rpc = {};
|
|
1586
|
+
for (const method of Object.keys(this.#runtime.api.rpc ?? {})) {
|
|
1587
|
+
addSurfaceLeaf(rpc, method, (handler) => this.#runtime.mountRuntime(method, async ({ input, context }) => await Promise.resolve(handler({
|
|
1588
|
+
input,
|
|
1589
|
+
context,
|
|
1590
|
+
client: this.#handlerTrellis,
|
|
1591
|
+
}))));
|
|
1592
|
+
}
|
|
1593
|
+
const feed = {};
|
|
1594
|
+
for (const feedName of Object.keys(this.#runtime.api.feeds ?? {})) {
|
|
1595
|
+
addSurfaceLeaf(feed, feedName, (handler) => this.#runtime.feedHandle(feedName).handle((context) => handler({
|
|
1596
|
+
...context,
|
|
1597
|
+
client: this.#handlerTrellis,
|
|
1598
|
+
})));
|
|
1599
|
+
}
|
|
1600
|
+
const operation = {};
|
|
1601
|
+
for (const operationName of Object.keys(this.#runtime.api.operations ?? {})) {
|
|
1602
|
+
const registration = this.#operation(operationName);
|
|
1603
|
+
const leaf = ((handler) => registration.handle((context) => handler({
|
|
1604
|
+
...context,
|
|
1605
|
+
client: this.#handlerTrellis,
|
|
1606
|
+
})));
|
|
1607
|
+
addSurfaceLeaf(operation, operationName, leaf);
|
|
1608
|
+
}
|
|
1609
|
+
return { rpc, feed, operation };
|
|
1610
|
+
}
|
|
1611
|
+
#createSqlOutboxBinding(options) {
|
|
1612
|
+
const tables = resolveSqlOutboxTables(options.tables);
|
|
1613
|
+
const executor = createSqlOutboxBaseExecutor(options);
|
|
1614
|
+
const repository = new SqlOutboxRepository(executor, options.dialect, tables);
|
|
1615
|
+
const dispatchRuntime = {
|
|
1616
|
+
publishPreparedEvent: (event) => this.#handlerTrellis.publishPrepared(event),
|
|
1617
|
+
dispatchJobSubmission: (message) => AsyncResult.from((async () => {
|
|
1618
|
+
try {
|
|
1619
|
+
if (!this.#jobsBinding) {
|
|
1620
|
+
return Result.ok({
|
|
1621
|
+
kind: "invalid",
|
|
1622
|
+
error: "Jobs bindings are unavailable",
|
|
1623
|
+
});
|
|
1624
|
+
}
|
|
1625
|
+
const jobsBinding = normalizeResourceJobsBinding(this.#jobsBinding);
|
|
1626
|
+
const queueBinding = jobsBinding.queues[message.name];
|
|
1627
|
+
if (!queueBinding) {
|
|
1628
|
+
return Result.ok({
|
|
1629
|
+
kind: "invalid",
|
|
1630
|
+
error: `Jobs binding for queue '${message.name}' is unavailable`,
|
|
1631
|
+
});
|
|
1632
|
+
}
|
|
1633
|
+
let submission;
|
|
1634
|
+
try {
|
|
1635
|
+
submission = Value.Parse(PreparedJobSubmissionSchema, JSON.parse(message.payload));
|
|
1636
|
+
}
|
|
1637
|
+
catch (cause) {
|
|
1638
|
+
return Result.ok({
|
|
1639
|
+
kind: "invalid",
|
|
1640
|
+
error: cause instanceof Error ? cause.message : String(cause),
|
|
1641
|
+
});
|
|
1642
|
+
}
|
|
1643
|
+
const expectedMode = message.kind === "job.create"
|
|
1644
|
+
? "create"
|
|
1645
|
+
: "submit";
|
|
1646
|
+
const expectedSubject = `${queueBinding.publishPrefix}.${submission.jobId}.created`;
|
|
1647
|
+
if (submission.submissionId !== message.id ||
|
|
1648
|
+
submission.mode !== expectedMode ||
|
|
1649
|
+
submission.service !== jobsBinding.serviceName ||
|
|
1650
|
+
submission.queue !== message.name ||
|
|
1651
|
+
message.subject !== expectedSubject ||
|
|
1652
|
+
message.headers["request-id"] !==
|
|
1653
|
+
submission.context.requestId ||
|
|
1654
|
+
message.headers.traceparent !== submission.context.traceparent ||
|
|
1655
|
+
message.headers.tracestate !== submission.context.tracestate) {
|
|
1656
|
+
return Result.ok({
|
|
1657
|
+
kind: "invalid",
|
|
1658
|
+
error: "Outbox job submission metadata is inconsistent",
|
|
1659
|
+
});
|
|
1660
|
+
}
|
|
1661
|
+
const manager = new InternalJobManager({
|
|
1662
|
+
nc: jetstream(this.#nc),
|
|
1663
|
+
jobs: jobsBinding,
|
|
1664
|
+
keyCoordinator: createNatsJobKeyCoordinator(this.#nc),
|
|
1665
|
+
});
|
|
1666
|
+
if (submission.mode === "create") {
|
|
1667
|
+
const job = await manager.createPrepared(submission);
|
|
1668
|
+
return Result.ok({ kind: "accepted", jobId: job.id });
|
|
1669
|
+
}
|
|
1670
|
+
const outcome = await manager.submitPrepared(submission);
|
|
1671
|
+
if (outcome.kind === "accepted") {
|
|
1672
|
+
return Result.ok({
|
|
1673
|
+
kind: outcome.kind,
|
|
1674
|
+
jobId: outcome.job.id,
|
|
1675
|
+
...(outcome.key ? { key: outcome.key } : {}),
|
|
1676
|
+
});
|
|
1677
|
+
}
|
|
1678
|
+
if (outcome.kind === "replaced") {
|
|
1679
|
+
return Result.ok({
|
|
1680
|
+
kind: outcome.kind,
|
|
1681
|
+
jobId: outcome.job.id,
|
|
1682
|
+
key: outcome.key,
|
|
1683
|
+
replaced: outcome.replaced,
|
|
1684
|
+
});
|
|
1685
|
+
}
|
|
1686
|
+
return Result.ok(outcome);
|
|
1687
|
+
}
|
|
1688
|
+
catch (cause) {
|
|
1689
|
+
if (cause instanceof JobNotEnqueuedError) {
|
|
1690
|
+
return Result.ok({
|
|
1691
|
+
kind: "rejected",
|
|
1692
|
+
reason: cause.reason,
|
|
1693
|
+
key: cause.key,
|
|
1694
|
+
active: cause.active,
|
|
1695
|
+
queued: cause.queued,
|
|
1696
|
+
limit: cause.limit,
|
|
1697
|
+
...(cause.existingJobId
|
|
1698
|
+
? { existingJobId: cause.existingJobId }
|
|
1699
|
+
: {}),
|
|
1700
|
+
});
|
|
1701
|
+
}
|
|
1702
|
+
if (cause instanceof ValidationError) {
|
|
1703
|
+
return Result.ok({ kind: "invalid", error: cause.message });
|
|
1704
|
+
}
|
|
1705
|
+
if (cause instanceof UnexpectedError) {
|
|
1706
|
+
return Result.err(cause);
|
|
1707
|
+
}
|
|
1708
|
+
return Result.err(toUnexpectedError(cause));
|
|
1709
|
+
}
|
|
1710
|
+
})()),
|
|
1711
|
+
};
|
|
1712
|
+
const dispatcher = new OutboxDispatcher(repository, dispatchRuntime, options.dispatcher);
|
|
1713
|
+
this.#ownedOutboxDispatchers.add(dispatcher);
|
|
1714
|
+
return {
|
|
1715
|
+
dialect: options.dialect,
|
|
1716
|
+
tables,
|
|
1717
|
+
transaction: createSqlOutboxTransactionRunner(options),
|
|
1718
|
+
dispatcher,
|
|
1719
|
+
repository,
|
|
1720
|
+
};
|
|
1721
|
+
}
|
|
1722
|
+
#createSqlOutbox(binding) {
|
|
1723
|
+
return {
|
|
1724
|
+
jobSubmissionOutcome: (submissionId) => AsyncResult.from((async () => {
|
|
1725
|
+
try {
|
|
1726
|
+
const message = await binding.repository.get(submissionId);
|
|
1727
|
+
if (message?.state !== "dispatched") {
|
|
1728
|
+
return Result.ok(undefined);
|
|
1729
|
+
}
|
|
1730
|
+
const outcome = message.outcome;
|
|
1731
|
+
if (!outcome || typeof outcome !== "object" ||
|
|
1732
|
+
!("kind" in outcome) || typeof outcome.kind !== "string") {
|
|
1733
|
+
return Result.err(toUnexpectedError(new Error(`Outbox job submission '${submissionId}' has no valid outcome`)));
|
|
1734
|
+
}
|
|
1735
|
+
return Result.ok({ ...outcome, kind: outcome.kind });
|
|
1736
|
+
}
|
|
1737
|
+
catch (cause) {
|
|
1738
|
+
return Result.err(toUnexpectedError(cause));
|
|
1739
|
+
}
|
|
1740
|
+
})()),
|
|
1741
|
+
transaction: (work) => AsyncResult.from((() => {
|
|
1742
|
+
let enqueued = 0;
|
|
1743
|
+
const toResultError = (cause) => {
|
|
1744
|
+
if (cause instanceof ValidationError ||
|
|
1745
|
+
cause instanceof UnexpectedError) {
|
|
1746
|
+
return Result.err(cause);
|
|
1747
|
+
}
|
|
1748
|
+
return Result.err(toUnexpectedError(cause));
|
|
1749
|
+
};
|
|
1750
|
+
let transaction;
|
|
1751
|
+
try {
|
|
1752
|
+
transaction = binding.transaction(({ tx, executor }) => {
|
|
1753
|
+
const repository = new SqlOutboxRepository(executor, binding.dialect, binding.tables);
|
|
1754
|
+
const event = createSqlOutboxEventEnqueueFacade({
|
|
1755
|
+
event: this.event,
|
|
1756
|
+
repository,
|
|
1757
|
+
onEnqueued: () => {
|
|
1758
|
+
enqueued += 1;
|
|
1759
|
+
},
|
|
1760
|
+
});
|
|
1761
|
+
const job = createSqlOutboxJobEnqueueFacade({
|
|
1762
|
+
contractJobs: this.#contractJobs,
|
|
1763
|
+
jobsBinding: this.#jobsBinding,
|
|
1764
|
+
repository,
|
|
1765
|
+
onEnqueued: () => {
|
|
1766
|
+
enqueued += 1;
|
|
1767
|
+
},
|
|
1768
|
+
});
|
|
1769
|
+
return work({ tx, event, job });
|
|
1770
|
+
});
|
|
1771
|
+
}
|
|
1772
|
+
catch (cause) {
|
|
1773
|
+
return Promise.resolve(toResultError(cause));
|
|
1774
|
+
}
|
|
1775
|
+
return transaction.then((result) => {
|
|
1776
|
+
if (enqueued > 0)
|
|
1777
|
+
binding.dispatcher.notify();
|
|
1778
|
+
return Result.ok(result);
|
|
1779
|
+
}, toResultError);
|
|
1780
|
+
})()),
|
|
1781
|
+
};
|
|
1782
|
+
}
|
|
1783
|
+
/**
|
|
1784
|
+
* Creates a short-lived receive transfer grant for a caller session.
|
|
1785
|
+
*/
|
|
1786
|
+
createTransfer(args) {
|
|
1787
|
+
return AsyncResult.from(this.#operationTransfer.initiateDownload({
|
|
1788
|
+
store: args.store,
|
|
1789
|
+
key: args.key,
|
|
1790
|
+
sessionKey: args.sessionKey,
|
|
1791
|
+
permission: args.permission,
|
|
1792
|
+
requiredCapabilities: args.requiredCapabilities,
|
|
1793
|
+
inboxPrefix: args.inboxPrefix,
|
|
1794
|
+
expiresInMs: args.expiresInMs ?? 60_000,
|
|
1795
|
+
}));
|
|
1796
|
+
}
|
|
1797
|
+
/**
|
|
1798
|
+
* Completes an operation from Trellis-owned runtime code that resolves
|
|
1799
|
+
* an operation from a separate RPC handler.
|
|
1800
|
+
*
|
|
1801
|
+
* @internal
|
|
1802
|
+
*/
|
|
1803
|
+
completeOperation(operationId, output) {
|
|
1804
|
+
return AsyncResult.from((async () => {
|
|
1805
|
+
const completed = await this.#runtime.operations.complete(operationId, output).take();
|
|
1806
|
+
if (!isErr(completed))
|
|
1807
|
+
return Result.ok(completed);
|
|
1808
|
+
const current = await this.#runtime.operations.get(operationId).take();
|
|
1809
|
+
if (!isErr(current) && current.state === "completed") {
|
|
1810
|
+
if (!operationOutputsEqual(current.output, output)) {
|
|
1811
|
+
return Result.err(new UnexpectedError({
|
|
1812
|
+
cause: new Error("operation already completed with different output"),
|
|
1813
|
+
}));
|
|
1814
|
+
}
|
|
1815
|
+
return Result.ok(current);
|
|
1816
|
+
}
|
|
1817
|
+
return Result.err(completed.error);
|
|
1818
|
+
})());
|
|
1819
|
+
}
|
|
1820
|
+
static connect(args) {
|
|
1821
|
+
return AsyncResult.from((async () => {
|
|
1822
|
+
const connected = await connectTrellisServiceWithRuntimeDeps(args, {});
|
|
1823
|
+
if (isErr(connected)) {
|
|
1824
|
+
return connected;
|
|
1825
|
+
}
|
|
1826
|
+
const service = connected.unwrapOrElse(() => {
|
|
1827
|
+
throw new Error("Connected service result narrowed incorrectly");
|
|
1828
|
+
});
|
|
1829
|
+
return Result.ok(createProviderRuntime(service, args.participant));
|
|
1830
|
+
})());
|
|
1831
|
+
}
|
|
1832
|
+
async wait() {
|
|
1833
|
+
this.#waitPromise ??= (async () => {
|
|
1834
|
+
try {
|
|
1835
|
+
await this.#managedJobWorkers.start().orThrow();
|
|
1836
|
+
const closed = await this.#nc.closed();
|
|
1837
|
+
if (closed instanceof Error) {
|
|
1838
|
+
throw closed;
|
|
1839
|
+
}
|
|
1840
|
+
}
|
|
1841
|
+
finally {
|
|
1842
|
+
await this.stop();
|
|
1843
|
+
}
|
|
1844
|
+
})();
|
|
1845
|
+
await this.#waitPromise;
|
|
1846
|
+
}
|
|
1847
|
+
async stop() {
|
|
1848
|
+
this.#stopPromise ??= (async () => {
|
|
1849
|
+
this.connection.stopObserving();
|
|
1850
|
+
this.#handlerTrellis.stopEventListeners();
|
|
1851
|
+
for (const dispatcher of this.#ownedOutboxDispatchers) {
|
|
1852
|
+
dispatcher.stop();
|
|
1853
|
+
}
|
|
1854
|
+
this.#ownedOutboxDispatchers.clear();
|
|
1855
|
+
try {
|
|
1856
|
+
await this.#stopHealthPublishing();
|
|
1857
|
+
}
|
|
1858
|
+
finally {
|
|
1859
|
+
try {
|
|
1860
|
+
await this.#managedJobWorkers.stop().orThrow();
|
|
1861
|
+
}
|
|
1862
|
+
finally {
|
|
1863
|
+
try {
|
|
1864
|
+
await this.#operationTransfer.stop();
|
|
1865
|
+
}
|
|
1866
|
+
finally {
|
|
1867
|
+
try {
|
|
1868
|
+
await this.#runtime.stop();
|
|
1869
|
+
}
|
|
1870
|
+
finally {
|
|
1871
|
+
await this.connection.close();
|
|
1872
|
+
}
|
|
1873
|
+
}
|
|
1874
|
+
}
|
|
1875
|
+
}
|
|
1876
|
+
})();
|
|
1877
|
+
await this.#stopPromise;
|
|
1878
|
+
}
|
|
1879
|
+
#operation(operation) {
|
|
1880
|
+
const registration = this.#runtime.operationHandle(operation);
|
|
1881
|
+
return {
|
|
1882
|
+
handle: (handler) => registration.handle((context) => handler({
|
|
1883
|
+
...context,
|
|
1884
|
+
client: this.#handlerTrellis,
|
|
1885
|
+
})),
|
|
1886
|
+
};
|
|
1887
|
+
}
|
|
1888
|
+
}
|
|
1889
|
+
/** Public factory for connecting a descriptor-defined Trellis service. */
|
|
1890
|
+
export class TrellisService {
|
|
1891
|
+
constructor() { }
|
|
1892
|
+
/** Connects a service and returns its provider-only runtime facade. */
|
|
1893
|
+
static connect(args) {
|
|
1894
|
+
return TrellisServiceSession.connect(args);
|
|
1895
|
+
}
|
|
1896
|
+
}
|