@yarkivaev/scada 2.3.115 → 2.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +158 -0
- package/charts/scada/templates/processes.yaml +22 -0
- package/charts/scada/values.schema.json +12 -0
- package/db/migrations/V0009__collector_task_state.sql +6 -0
- package/package.json +14 -5
- package/src/application/deploy/collectorProcesses.js +3 -2
- package/src/application/deploy/readDocument.js +4 -1
- package/src/application/plantServer.js +1 -1
- package/src/application/runtime/additionalAdapters.js +139 -0
- package/src/application/runtime/assignment.js +30 -0
- package/src/application/runtime/budgetedSink.js +17 -0
- package/src/application/runtime/collectorSignals.js +14 -0
- package/src/application/runtime/compositeLifecycle.js +49 -0
- package/src/application/runtime/createRegistry.js +20 -0
- package/src/application/runtime/deadline.js +8 -0
- package/src/application/runtime/index.js +10 -0
- package/src/application/runtime/manifest.js +38 -0
- package/src/application/runtime/pipelineAdapters.js +60 -0
- package/src/application/runtime/resolveSecrets.js +12 -0
- package/src/application/runtime/resourceClaims.js +21 -0
- package/src/application/runtime/runCollector.js +50 -0
- package/src/application/runtime/runtime.js +86 -0
- package/src/application/runtime/runtimeHealth.js +29 -0
- package/src/application/runtime/runtimeServices.js +15 -0
- package/src/application/runtime/stateDocument.js +22 -0
- package/src/application/runtime/streamPorts.js +42 -0
- package/src/application/runtime/streamTask.js +105 -0
- package/src/application/runtime/taskBudget.js +31 -0
- package/src/application/runtime/taskContext.js +18 -0
- package/src/application/runtime/taskQueue.js +21 -0
- package/src/application/runtime/taskSlot.js +46 -0
- package/src/application/runtime/taskState.js +73 -0
- package/src/application/siteCollection.js +14 -0
- package/src/application/siteServer.js +7 -6
- package/src/application/siteTelemetry.js +1 -1
- package/src/application/supervisorSink.js +9 -8
- package/src/application/sync/siteSyncBind.js +1 -1
- package/src/application/sync/siteSyncTargets.js +1 -1
- package/src/bin/collector.js +7 -0
- package/src/bin/modbus-to-mqtt.js +11 -9
- package/src/infrastructure/deploy/executeRelease.js +45 -3
- package/src/infrastructure/ingest/activity/activityTracking.js +15 -21
- package/src/infrastructure/ingest/activity/activityTransformer.js +16 -15
- package/src/infrastructure/ingest/durableConsume.js +2 -169
- package/src/infrastructure/ingest/ingestBatch.js +13 -0
- package/src/infrastructure/ingest/modbus/mx210Tcp.js +4 -4
- package/src/infrastructure/ingest/modbus/silentStreams.js +39 -16
- package/src/infrastructure/ingest/mqtt/metricsTransformer.js +4 -5
- package/src/infrastructure/ingest/mqtt/modbusMqtt.js +17 -19
- package/src/infrastructure/ingest/mqtt/modbusPublish.js +4 -0
- package/src/infrastructure/ingest/mqtt/mqttMetrics.js +17 -21
- package/src/infrastructure/ingest/mqtt/registerPublish.js +3 -3
- package/src/infrastructure/ingest/pipelines/alertPipeline.js +7 -6
- package/src/infrastructure/ingest/pipelines/decisionPipeline.js +7 -6
- package/src/infrastructure/ingest/pipelines/segmentPipeline.js +8 -17
- package/src/infrastructure/ingest/pipelines/stompOptions.js +7 -0
- package/src/infrastructure/ingest/silentOpenWatch.js +5 -42
- package/src/infrastructure/ingest/telemetry/amqpMetricsIngest.js +16 -28
- package/src/infrastructure/ingest/telemetry/amqpMqttRelay.js +17 -22
- package/src/infrastructure/ingest/telemetry/foldingSink.js +5 -2
- package/src/infrastructure/messaging/stomp/segmentRetags.js +1 -1
- package/src/infrastructure/messaging/stomp/userDecisions.js +1 -1
- package/src/infrastructure/opcua/certificates.js +11 -0
- package/src/infrastructure/opcua/config.js +38 -0
- package/src/infrastructure/opcua/delivery.js +38 -0
- package/src/infrastructure/opcua/endpoint.js +90 -0
- package/src/infrastructure/opcua/index.js +10 -0
- package/src/infrastructure/opcua/session.js +70 -0
- package/src/infrastructure/opcua/source.js +38 -0
- package/src/infrastructure/opcua/subscriptionHealth.js +28 -0
- package/src/infrastructure/operators/operatorsSync.js +5 -29
- package/src/infrastructure/persistence/clickhouse/streamHub.js +21 -15
- package/src/infrastructure/persistence/metricsSensor.js +9 -6
- package/src/infrastructure/persistence/runtime/taskStatePg.js +24 -0
- package/src/infrastructure/streams/README.md +291 -0
- package/src/infrastructure/streams/abortable.js +14 -0
- package/src/infrastructure/streams/amqpSink/publication.js +46 -0
- package/src/infrastructure/streams/amqpSink/session.js +96 -0
- package/src/infrastructure/streams/amqpSink.js +47 -0
- package/src/infrastructure/streams/amqpSource/session.js +127 -0
- package/src/infrastructure/streams/amqpSource.js +97 -0
- package/src/infrastructure/streams/brokerClose.js +19 -0
- package/src/infrastructure/streams/budgetAccept.js +6 -0
- package/src/infrastructure/streams/clickhouseSink.js +48 -0
- package/src/infrastructure/streams/deadline.js +1 -0
- package/src/infrastructure/streams/follow.js +41 -0
- package/src/infrastructure/streams/index.js +15 -0
- package/src/infrastructure/streams/lokiSource.js +88 -0
- package/src/infrastructure/streams/modbusConnection.js +173 -0
- package/src/infrastructure/streams/modbusRtuBusSource.js +124 -0
- package/src/infrastructure/streams/modbusSource.js +185 -0
- package/src/infrastructure/streams/mqttSink/session.js +51 -0
- package/src/infrastructure/streams/mqttSink.js +54 -0
- package/src/infrastructure/streams/mqttSource.js +66 -0
- package/src/infrastructure/streams/pollingSource.js +1 -0
- package/src/infrastructure/streams/positive.js +5 -0
- package/src/infrastructure/streams/postgresSink.js +124 -0
- package/src/infrastructure/streams/serialReceive.js +80 -0
- package/src/infrastructure/streams/serialSource.js +87 -0
- package/src/infrastructure/streams/sqliteSink.js +55 -0
- package/src/infrastructure/streams/stompBody.js +46 -0
- package/src/infrastructure/streams/stompSend.js +89 -0
- package/src/infrastructure/streams/stompSource.js +201 -0
- package/src/infrastructure/sync/operationSyncIngest.js +4 -16
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { createReadStream } from 'node:fs';
|
|
2
|
+
import assignment, { identity } from './assignment.js';
|
|
3
|
+
|
|
4
|
+
async function read(stream) {
|
|
5
|
+
const chunks = [];
|
|
6
|
+
let bytes = 0;
|
|
7
|
+
for await (const chunk of stream) {
|
|
8
|
+
bytes += Buffer.byteLength(chunk);
|
|
9
|
+
if (bytes > 1048576) { throw new Error('Manifest capacity exceeded'); }
|
|
10
|
+
chunks.push(Buffer.from(chunk));
|
|
11
|
+
}
|
|
12
|
+
return Buffer.concat(chunks).toString('utf8');
|
|
13
|
+
}
|
|
14
|
+
export async function readManifest(env, argv) {
|
|
15
|
+
const text = env.COLLECTOR_MANIFEST ?? await read(argv[0] === '-' ? process.stdin : createReadStream(argv[0] ?? env.COLLECTOR_MANIFEST_PATH));
|
|
16
|
+
if (Buffer.byteLength(text) > 1048576) { throw new Error('Manifest capacity exceeded'); }
|
|
17
|
+
return JSON.parse(text);
|
|
18
|
+
}
|
|
19
|
+
export async function validateManifest(document, registry) {
|
|
20
|
+
if (!document || !Array.isArray(document.assignments) || !Array.isArray(document.removals ?? [])) {
|
|
21
|
+
throw new TypeError('Manifest requires assignments and optional removals arrays');
|
|
22
|
+
}
|
|
23
|
+
const ids = new Set();
|
|
24
|
+
const resources = new Set();
|
|
25
|
+
for (const input of [...document.assignments, ...(document.removals ?? [])]) {
|
|
26
|
+
identity(input);
|
|
27
|
+
if (ids.has(input.taskId)) { throw new TypeError('Duplicate manifest taskId'); }
|
|
28
|
+
ids.add(input.taskId);
|
|
29
|
+
}
|
|
30
|
+
await Promise.all(document.assignments.map(async (input) => {
|
|
31
|
+
const request = assignment(input, registry);
|
|
32
|
+
await request.adapter.validate(request.spec);
|
|
33
|
+
request.adapter.resources(request.spec).forEach((key) => {
|
|
34
|
+
if (resources.has(key)) { throw new Error('Manifest resource already claimed'); }
|
|
35
|
+
resources.add(key);
|
|
36
|
+
});
|
|
37
|
+
}));
|
|
38
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { clickhouseSink, mqttSink } from '../../infrastructure/streams/index.js';
|
|
2
|
+
import activityTracking from '../../infrastructure/ingest/activity/activityTracking.js';
|
|
3
|
+
import amqpMetricsIngest from '../../infrastructure/ingest/telemetry/amqpMetricsIngest.js';
|
|
4
|
+
import amqpMqttRelay from '../../infrastructure/ingest/telemetry/amqpMqttRelay.js';
|
|
5
|
+
import modbusPublish from '../../infrastructure/ingest/mqtt/modbusPublish.js';
|
|
6
|
+
import mqttMetrics from '../../infrastructure/ingest/mqtt/mqttMetrics.js';
|
|
7
|
+
import { parseModbusDeviceSpec } from '../../infrastructure/ingest/mqtt/modbusDeviceSpec.js';
|
|
8
|
+
import compositeLifecycle from './compositeLifecycle.js';
|
|
9
|
+
import resolveSecrets from './resolveSecrets.js';
|
|
10
|
+
|
|
11
|
+
function configured(spec, context) {
|
|
12
|
+
const options = { maxRecords: spec.limits?.maxRecords, maxBytes: spec.limits?.maxBytes, budget: context.budget };
|
|
13
|
+
const defined = Object.fromEntries(Object.entries(options).filter(([, value]) => { return value !== undefined; }));
|
|
14
|
+
return { ...resolveSecrets(spec, context.secrets),
|
|
15
|
+
config: { ...resolveSecrets(spec.config, context.secrets), ...defined },
|
|
16
|
+
options: { ...resolveSecrets(spec.options, context.secrets), ...defined } };
|
|
17
|
+
}
|
|
18
|
+
function adapter(factory, required, resourceKeys = () => { return []; }) {
|
|
19
|
+
return {
|
|
20
|
+
validate(spec) {
|
|
21
|
+
required.forEach((key) => { if (!spec[key]) { throw new TypeError(`Missing pipeline ${key}`); } });
|
|
22
|
+
},
|
|
23
|
+
resources(spec) { return resourceKeys(spec); },
|
|
24
|
+
create(spec, context) {
|
|
25
|
+
let pipeline;
|
|
26
|
+
let stopped = false;
|
|
27
|
+
return {
|
|
28
|
+
start() {
|
|
29
|
+
context.signal.throwIfAborted();
|
|
30
|
+
if (stopped) { throw new Error('Pipeline stopped'); }
|
|
31
|
+
pipeline ??= factory(configured(spec, context));
|
|
32
|
+
return pipeline.start();
|
|
33
|
+
},
|
|
34
|
+
stop() { stopped = true; return pipeline?.stop(); }
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
function modbusResources(spec) {
|
|
40
|
+
const devices = spec.config.devices?.split(',').map(parseModbusDeviceSpec) ?? [];
|
|
41
|
+
const paths = devices.filter((device) => { return device.kind === 'rtu'; }).map((device) => { return `serial:${device.path}`; });
|
|
42
|
+
const addresses = spec.config.addresses ?? [];
|
|
43
|
+
addresses.filter((address) => { return address.startsWith('rtu://'); }).forEach((address) => {
|
|
44
|
+
paths.push(`serial:${address.slice(6).split(':')[0]}`);
|
|
45
|
+
});
|
|
46
|
+
return [...new Set(paths)];
|
|
47
|
+
}
|
|
48
|
+
function metrics(spec, factory) {
|
|
49
|
+
const sink = clickhouseSink(spec.clickhouseUrl, spec.table);
|
|
50
|
+
return compositeLifecycle({ outputs: [sink], inputs: [factory(sink)] });
|
|
51
|
+
}
|
|
52
|
+
export default function pipelineAdapters() {
|
|
53
|
+
return new Map([
|
|
54
|
+
['modbus', adapter((spec) => { return modbusPublish(spec.url, spec.config); }, ['url', 'config'], modbusResources)],
|
|
55
|
+
['activity', adapter((spec) => { return activityTracking(spec.lokiUrl, spec.clickhouseUrl, spec.query, spec.config); }, ['lokiUrl', 'clickhouseUrl', 'query', 'config'])],
|
|
56
|
+
['amqp-relay', adapter((spec) => { return amqpMqttRelay(spec.url, spec.queue, mqttSink(spec.mqttUrl, spec.options), spec.options); }, ['url', 'queue', 'mqttUrl'])],
|
|
57
|
+
['amqp-metrics', adapter((spec) => { return metrics(spec, (sink) => { return amqpMetricsIngest(spec.url, spec.queue, sink, spec.options); }); }, ['url', 'queue', 'clickhouseUrl', 'table'])],
|
|
58
|
+
['mqtt-metrics', adapter((spec) => { return metrics(spec, (sink) => { return mqttMetrics(spec.url, sink, spec.topic, spec.config); }); }, ['url', 'topic', 'clickhouseUrl', 'table', 'config'])]
|
|
59
|
+
]);
|
|
60
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/** Resolve references only at resource construction; hashes and manifests retain refs. */
|
|
2
|
+
export default function resolveSecrets(value, secrets) {
|
|
3
|
+
if (Array.isArray(value)) { return value.map((item) => { return resolveSecrets(item, secrets); }); }
|
|
4
|
+
if (!value || typeof value !== 'object') { return value; }
|
|
5
|
+
if (Object.hasOwn(value, '$secret')) {
|
|
6
|
+
if (Object.keys(value).length !== 1 || typeof value.$secret !== 'string') { throw new TypeError('Invalid secret reference'); }
|
|
7
|
+
const resolved = secrets?.get(value.$secret);
|
|
8
|
+
if (resolved === undefined || resolved === null) { throw new Error('Required collector secret is unavailable'); }
|
|
9
|
+
return resolved;
|
|
10
|
+
}
|
|
11
|
+
return Object.fromEntries(Object.entries(value).map(([key, item]) => { return [key, resolveSecrets(item, secrets)]; }));
|
|
12
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/** Exclusive resource claims remain held when cleanup cannot be proved. */
|
|
2
|
+
export default function resourceClaims() {
|
|
3
|
+
const claims = new Map();
|
|
4
|
+
return {
|
|
5
|
+
check(taskId, resources) {
|
|
6
|
+
if (!Array.isArray(resources) || resources.some((key) => { return typeof key !== 'string' || !key; })) {
|
|
7
|
+
throw new TypeError('Invalid task resources');
|
|
8
|
+
}
|
|
9
|
+
resources.forEach((key) => {
|
|
10
|
+
if (claims.has(key) && claims.get(key) !== taskId) { throw new Error(`Resource already claimed: ${key}`); }
|
|
11
|
+
});
|
|
12
|
+
},
|
|
13
|
+
acquire(taskId, resources) {
|
|
14
|
+
this.check(taskId, resources);
|
|
15
|
+
resources.forEach((key) => { claims.set(key, taskId); });
|
|
16
|
+
},
|
|
17
|
+
release(taskId) {
|
|
18
|
+
claims.forEach((owner, key) => { if (owner === taskId) { claims.delete(key); } });
|
|
19
|
+
}
|
|
20
|
+
};
|
|
21
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { readManifest, validateManifest } from './manifest.js';
|
|
2
|
+
import collectorSignals from './collectorSignals.js';
|
|
3
|
+
import createRuntime from './runtime.js';
|
|
4
|
+
import runtimeServices from './runtimeServices.js';
|
|
5
|
+
import runtimeHealth from './runtimeHealth.js';
|
|
6
|
+
|
|
7
|
+
async function apply(runtime, document) {
|
|
8
|
+
for (const removal of document.removals ?? []) {
|
|
9
|
+
// Retire explicitly before opening replacement resource owners.
|
|
10
|
+
// eslint-disable-next-line no-await-in-loop
|
|
11
|
+
await runtime.remove(removal);
|
|
12
|
+
}
|
|
13
|
+
for (const input of document.assignments) {
|
|
14
|
+
// Keep startup concurrency bounded; task execution remains concurrent.
|
|
15
|
+
// eslint-disable-next-line no-await-in-loop
|
|
16
|
+
await runtime.apply(input);
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
/** Local executable host. The external agent can instead use createRuntime directly. */
|
|
20
|
+
export default async function runCollector({ registry, env = process.env, argv = process.argv.slice(2), signals = true, manifestReader = readManifest, ...services }) {
|
|
21
|
+
const owned = runtimeServices(env, services);
|
|
22
|
+
const runtime = createRuntime({ registry, ...owned });
|
|
23
|
+
const state = { health: null, reloading: false, stopped: false, detach: null };
|
|
24
|
+
const collector = {
|
|
25
|
+
runtime,
|
|
26
|
+
async reload() {
|
|
27
|
+
if (state.stopped || state.reloading) { throw new Error('Collector stopped or reload in progress'); }
|
|
28
|
+
state.reloading = true;
|
|
29
|
+
try {
|
|
30
|
+
const document = await manifestReader(env, argv, runtime.status());
|
|
31
|
+
await validateManifest(document, registry);
|
|
32
|
+
await apply(runtime, document);
|
|
33
|
+
} finally { Object.assign(state, { reloading: false }); }
|
|
34
|
+
},
|
|
35
|
+
async stop() {
|
|
36
|
+
state.stopped = true;
|
|
37
|
+
state.detach?.();
|
|
38
|
+
try { await runtime.stop(); } finally { await Promise.all([owned.close(), state.health?.stop()]); }
|
|
39
|
+
}
|
|
40
|
+
};
|
|
41
|
+
if (signals) { state.detach = collectorSignals(collector); }
|
|
42
|
+
try {
|
|
43
|
+
await collector.reload();
|
|
44
|
+
if (env.COLLECTOR_HEALTH_PORT) { state.health = await runtimeHealth(runtime, { port: Number(env.COLLECTOR_HEALTH_PORT), host: env.COLLECTOR_HEALTH_HOST ?? '127.0.0.1' }); }
|
|
45
|
+
} catch (error) {
|
|
46
|
+
try { await collector.stop(); } catch (cleanup) { throw new AggregateError([error, cleanup], 'Collector startup cleanup failed'); }
|
|
47
|
+
throw error;
|
|
48
|
+
}
|
|
49
|
+
return collector;
|
|
50
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import assignment, { identity } from './assignment.js';
|
|
2
|
+
import { halt, launch, view } from './taskSlot.js';
|
|
3
|
+
import resourceClaims from './resourceClaims.js';
|
|
4
|
+
import deadline from './deadline.js';
|
|
5
|
+
import taskBudget from './taskBudget.js';
|
|
6
|
+
|
|
7
|
+
function positive(value) {
|
|
8
|
+
if (!Number.isSafeInteger(value) || value <= 0) { throw new TypeError('Runtime limits must be positive integers'); }
|
|
9
|
+
return value;
|
|
10
|
+
}
|
|
11
|
+
function slotFor(ctx, taskId) {
|
|
12
|
+
if (!ctx.slots.has(taskId)) {
|
|
13
|
+
if (ctx.slots.size >= ctx.maxTasks) { throw new Error('Task capacity exceeded'); }
|
|
14
|
+
ctx.slots.set(taskId, { taskId, generation: -1, state: 'idle', removed: false, task: null, chain: Promise.resolve() });
|
|
15
|
+
}
|
|
16
|
+
return ctx.slots.get(taskId);
|
|
17
|
+
}
|
|
18
|
+
function enqueue(ctx, input, action) {
|
|
19
|
+
try {
|
|
20
|
+
identity(input);
|
|
21
|
+
if (ctx.stopping || ctx.fatal) { throw new Error('Runtime stopping or fatal'); }
|
|
22
|
+
if (ctx.pending >= ctx.maxPending) { throw new Error('Control capacity exceeded'); }
|
|
23
|
+
const slot = slotFor(ctx, input.taskId);
|
|
24
|
+
ctx.pending += 1;
|
|
25
|
+
const result = slot.chain.then(() => { return action(slot); }).finally(() => { ctx.pending -= 1; });
|
|
26
|
+
slot.chain = result.catch(() => {
|
|
27
|
+
// Preserve sequencing after a rejected command.
|
|
28
|
+
});
|
|
29
|
+
return result;
|
|
30
|
+
} catch (error) { return Promise.reject(error); }
|
|
31
|
+
}
|
|
32
|
+
function duplicate(slot, request) {
|
|
33
|
+
if (request.generation < slot.generation || (request.generation === slot.generation && slot.removed)) {
|
|
34
|
+
throw new Error('stale assignment');
|
|
35
|
+
}
|
|
36
|
+
if (request.generation !== slot.generation) { return false; }
|
|
37
|
+
if (slot.specHash !== request.specHash) { throw new Error('assignment conflict'); }
|
|
38
|
+
return true;
|
|
39
|
+
}
|
|
40
|
+
async function apply(ctx, input, slot) {
|
|
41
|
+
if (ctx.fatal || ctx.stopping) { throw new Error('Runtime stopping or fatal'); }
|
|
42
|
+
const request = assignment(input, ctx.registry);
|
|
43
|
+
if (duplicate(slot, request)) { return view(slot); }
|
|
44
|
+
await deadline(() => { return request.adapter.validate(request.spec); }, ctx.startTimeoutMs);
|
|
45
|
+
if (ctx.fatal || ctx.stopping) { throw new Error('Runtime stopping or fatal'); }
|
|
46
|
+
const resources = request.adapter.resources(request.spec);
|
|
47
|
+
ctx.claims.check(slot.taskId, resources);
|
|
48
|
+
await halt(slot, ctx);
|
|
49
|
+
if (ctx.fatal || ctx.stopping) { throw new Error('Runtime stopping or fatal'); }
|
|
50
|
+
await launch(slot, request, ctx, resources);
|
|
51
|
+
return view(slot);
|
|
52
|
+
}
|
|
53
|
+
async function remove(ctx, input, slot) {
|
|
54
|
+
if (input.generation < slot.generation) { return view(slot); }
|
|
55
|
+
slot.generation = input.generation;
|
|
56
|
+
slot.removed = true;
|
|
57
|
+
await halt(slot, ctx);
|
|
58
|
+
Object.assign(slot, { state: 'stopped' });
|
|
59
|
+
return view(slot);
|
|
60
|
+
}
|
|
61
|
+
function shutdown(ctx, milliseconds) {
|
|
62
|
+
ctx.stopping = true;
|
|
63
|
+
ctx.slots.forEach((slot) => { slot.controller?.abort(new Error('Runtime stopping')); });
|
|
64
|
+
const tasks = Array.from(ctx.slots.values()).map((slot) => {
|
|
65
|
+
return slot.chain.then(() => { return halt(slot, ctx); });
|
|
66
|
+
});
|
|
67
|
+
return deadline(() => { return Promise.all(tasks); }, milliseconds).catch((error) => {
|
|
68
|
+
ctx.fatal = true;
|
|
69
|
+
throw error;
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
/** Identical local executors; placement and distributed ownership belong outside. */
|
|
73
|
+
export default function createRuntime(options) {
|
|
74
|
+
const ctx = { registry: options.registry, slots: new Map(), claims: resourceClaims(), pending: 0, stopping: false, fatal: false,
|
|
75
|
+
maxTasks: positive(options.maxTasks ?? 1000), maxPending: positive(options.maxPending ?? 100),
|
|
76
|
+
stopTimeoutMs: positive(options.stopTimeoutMs ?? 10000), startTimeoutMs: positive(options.startTimeoutMs ?? 10000),
|
|
77
|
+
budget: taskBudget(options.budgets ?? { maxBytes: 16777216, maxRecords: 16384 }),
|
|
78
|
+
services: { stateStore: options.stateStore, secrets: options.secrets, report: options.report } };
|
|
79
|
+
if (!(ctx.registry instanceof Map)) { throw new TypeError('registry must be a Map'); }
|
|
80
|
+
return {
|
|
81
|
+
apply(input) { return enqueue(ctx, input, (slot) => { return apply(ctx, input, slot); }); },
|
|
82
|
+
remove(input) { return enqueue(ctx, input, (slot) => { return remove(ctx, input, slot); }); },
|
|
83
|
+
status() { return { fatal: ctx.fatal, stopping: ctx.stopping, budget: ctx.budget.status(), tasks: Array.from(ctx.slots.values()).map(view) }; },
|
|
84
|
+
stop({ deadlineMs = ctx.stopTimeoutMs } = {}) { return shutdown(ctx, positive(deadlineMs)); }
|
|
85
|
+
};
|
|
86
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { createServer } from 'node:http';
|
|
2
|
+
import { monitorEventLoopDelay } from 'node:perf_hooks';
|
|
3
|
+
|
|
4
|
+
function response(runtime, histogram, request, result) {
|
|
5
|
+
if (!['/live', '/ready', '/status'].includes(request.url)) { result.writeHead(404).end(); return; }
|
|
6
|
+
const state = runtime.status();
|
|
7
|
+
const ready = !state.fatal && !state.stopping;
|
|
8
|
+
const body = request.url === '/status' ? { ...state, memory: process.memoryUsage(), eventLoopP99Ms: histogram.percentile(99) / 1e6 } : { ready };
|
|
9
|
+
result.writeHead((request.url === '/ready' && !ready) || (request.url === '/live' && state.fatal) ? 503 : 200, { 'content-type': 'application/json', 'cache-control': 'no-store' });
|
|
10
|
+
result.end(JSON.stringify(body));
|
|
11
|
+
}
|
|
12
|
+
/** Read-only health surface. A blocked event loop cannot answer an external probe. */
|
|
13
|
+
export default async function runtimeHealth(runtime, { port, host = '127.0.0.1' }) {
|
|
14
|
+
if (!Number.isInteger(port) || port < 0 || port > 65535) { throw new TypeError('Invalid health port'); }
|
|
15
|
+
const histogram = monitorEventLoopDelay({ resolution: 20 });
|
|
16
|
+
const server = createServer((request, result) => { response(runtime, histogram, request, result); });
|
|
17
|
+
server.requestTimeout = 2000;
|
|
18
|
+
server.headersTimeout = 2000;
|
|
19
|
+
await new Promise((resolve, reject) => { server.once('error', reject); server.listen(port, host, resolve); });
|
|
20
|
+
histogram.enable();
|
|
21
|
+
return {
|
|
22
|
+
port: server.address().port,
|
|
23
|
+
stop() {
|
|
24
|
+
histogram.disable();
|
|
25
|
+
server.closeAllConnections();
|
|
26
|
+
return new Promise((resolve, reject) => { server.close((error) => { if (error) { reject(error); } else { resolve(); } }); });
|
|
27
|
+
}
|
|
28
|
+
};
|
|
29
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import pg from 'pg';
|
|
2
|
+
import taskStatePg from '../../infrastructure/persistence/runtime/taskStatePg.js';
|
|
3
|
+
import deadline from './deadline.js';
|
|
4
|
+
|
|
5
|
+
/** Storage is optional for live-only tasks and mandatory for durable adapters. */
|
|
6
|
+
export default function runtimeServices(env, supplied) {
|
|
7
|
+
const pool = !supplied.stateStore && env.COLLECTOR_STATE_URL ? new pg.Pool({
|
|
8
|
+
connectionString: env.COLLECTOR_STATE_URL, max: 4, connectionTimeoutMillis: 5000,
|
|
9
|
+
query_timeout: 10000, statement_timeout: 10000, idleTimeoutMillis: 30000
|
|
10
|
+
}) : null;
|
|
11
|
+
return { ...supplied, secrets: supplied.secrets ?? { get(name) { return env[name]; } },
|
|
12
|
+
stateStore: supplied.stateStore ?? (pool ? taskStatePg(pool) : undefined),
|
|
13
|
+
close() { return pool ? deadline(() => { return pool.end(); }, 10000) : Promise.resolve(); }
|
|
14
|
+
};
|
|
15
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
function validate(value, seen, depth) {
|
|
2
|
+
if (depth > 128) { throw new TypeError('Task state JSON is too deeply nested'); }
|
|
3
|
+
if (value === null || typeof value !== 'object') { return; }
|
|
4
|
+
if (seen.has(value)) { throw new TypeError('Task state JSON must not contain cycles'); }
|
|
5
|
+
if (ArrayBuffer.isView(value) || value instanceof ArrayBuffer) {
|
|
6
|
+
throw new TypeError('Task state requires JSON data; encode binary as a string explicitly');
|
|
7
|
+
}
|
|
8
|
+
if (!(Array.isArray(value) || value instanceof Date || Object.getPrototypeOf(value) === Object.prototype || Object.getPrototypeOf(value) === null)) {
|
|
9
|
+
throw new TypeError('Task state requires plain JSON data');
|
|
10
|
+
}
|
|
11
|
+
seen.add(value);
|
|
12
|
+
Object.values(value).forEach((item) => { validate(item, seen, depth + 1); });
|
|
13
|
+
seen.delete(value);
|
|
14
|
+
}
|
|
15
|
+
/** Normalize before first delivery so recovered PostgreSQL JSON has identical types. */
|
|
16
|
+
export default function stateDocument(value, maxBytes) {
|
|
17
|
+
validate(value, new Set(), 0);
|
|
18
|
+
const text = JSON.stringify(value);
|
|
19
|
+
if (typeof text !== 'string') { throw new TypeError('Task state requires JSON data'); }
|
|
20
|
+
if (Buffer.byteLength(text) > maxBytes) { throw new Error('Task state capacity exceeded'); }
|
|
21
|
+
return JSON.parse(text);
|
|
22
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { clock, intervalSource } from '@yarkivaev/source-to-sink';
|
|
2
|
+
import { amqpSink, amqpSource, clickhouseSink, lokiSource, modbusRtuSource, modbusSource, mqttSink, mqttSource, postgresSink, serialSource } from '../../infrastructure/streams/index.js';
|
|
3
|
+
import opcuaSource from '../../infrastructure/opcua/source.js';
|
|
4
|
+
import resolveSecrets from './resolveSecrets.js';
|
|
5
|
+
|
|
6
|
+
// Protocol buffers charge bytes; stream acceptance owns the logical record slot.
|
|
7
|
+
function bytesOnly(budget) {
|
|
8
|
+
return { reserve({ bytes }) { return budget.reserve({ bytes, records: 0 }); } };
|
|
9
|
+
}
|
|
10
|
+
function configured(factory) {
|
|
11
|
+
return (spec, collector, context) => { return factory(resolveSecrets(spec, context.secrets), collector, context); };
|
|
12
|
+
}
|
|
13
|
+
export function sources() {
|
|
14
|
+
return new Map([
|
|
15
|
+
['opcua', configured((spec, collector, ctx) => { return opcuaSource(spec.connections, collector, { ...spec.options, budget: bytesOnly(ctx.budget), onError() { ctx.report?.({ state: 'degraded' }); } }); })],
|
|
16
|
+
['serial', configured((spec, collector, ctx) => { return serialSource(spec.devices, collector, { ...spec.options, budget: bytesOnly(ctx.budget), onError() { ctx.report?.({ state: 'degraded' }); } }); })],
|
|
17
|
+
['amqp', configured((spec, collector, ctx) => { return amqpSource(spec.url, spec.queue, collector, { ...spec.options, budget: bytesOnly(ctx.budget) }); })],
|
|
18
|
+
['mqtt', configured((spec, collector, ctx) => { return mqttSource(spec.url, spec.topics, collector, { ...spec.options, budget: bytesOnly(ctx.budget) }); })],
|
|
19
|
+
['interval', configured((spec, collector) => { return intervalSource(spec.intervalMs, collector, spec.options); })],
|
|
20
|
+
['loki', configured((spec, collector) => { return lokiSource(spec.url, spec.query, spec.interval, collector, clock(), spec.options); })],
|
|
21
|
+
['modbus-tcp', configured((spec, collector) => { return modbusSource(spec.host, spec.port, spec.address, spec.count, spec.interval, collector, clock()); })],
|
|
22
|
+
['modbus-rtu', configured((spec, collector) => { return modbusRtuSource(spec.path, spec.serial, spec.address, spec.count, spec.interval, collector, clock()); })]
|
|
23
|
+
]);
|
|
24
|
+
}
|
|
25
|
+
export function sinks() {
|
|
26
|
+
const factories = new Map([
|
|
27
|
+
['amqp', (spec) => { return amqpSink(spec.url, spec.options); }],
|
|
28
|
+
['mqtt', (spec) => { return mqttSink(spec.url, spec.options); }],
|
|
29
|
+
['clickhouse', (spec) => { return clickhouseSink(spec.url, spec.table); }],
|
|
30
|
+
['postgres', (spec) => { return postgresSink(spec.url, spec.table, spec.columns); }]
|
|
31
|
+
]);
|
|
32
|
+
return new Map(Array.from(factories, ([name, factory]) => {
|
|
33
|
+
return [name, (spec, context) => { return factory(resolveSecrets(spec, context.secrets)); }];
|
|
34
|
+
}));
|
|
35
|
+
}
|
|
36
|
+
export function resources(spec) {
|
|
37
|
+
const {source} = spec;
|
|
38
|
+
if (source.type === 'serial') { return [...new Set(source.devices.map((device) => { return `serial:${device.path}`; }))]; }
|
|
39
|
+
if (source.type === 'modbus-rtu') { return [`serial:${source.path}`]; }
|
|
40
|
+
if (source.type === 'opcua') { return source.connections.map((connection) => { return `opcua:${connection.url}`; }); }
|
|
41
|
+
return [];
|
|
42
|
+
}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import taskState from './taskState.js';
|
|
2
|
+
import deadline from './deadline.js';
|
|
3
|
+
import budgetedSink from './budgetedSink.js';
|
|
4
|
+
|
|
5
|
+
function validate(spec, ports) {
|
|
6
|
+
if (!ports.sources.has(spec.source?.type)) { throw new TypeError('Unknown stream source'); }
|
|
7
|
+
if (!Array.isArray(spec.sinks) || !spec.sinks.length || spec.sinks.some((sink) => { return !ports.sinks.has(sink.type); })) {
|
|
8
|
+
throw new TypeError('Unknown stream sink');
|
|
9
|
+
}
|
|
10
|
+
if (!['live', 'durable'].includes(spec.delivery)) { throw new TypeError('Explicit delivery policy required'); }
|
|
11
|
+
const names = spec.sinks.map((sink, index) => { return sink.id ?? String(index); });
|
|
12
|
+
if (new Set(names).size !== names.length) { throw new TypeError('Duplicate sink id'); }
|
|
13
|
+
if (spec.delivery === 'durable' && spec.sinks.some((sink) => { return sink.type === 'mqtt' && ![1, 2].includes(sink.options?.qos); })) {
|
|
14
|
+
throw new TypeError('Durable MQTT delivery requires QoS 1 or 2');
|
|
15
|
+
}
|
|
16
|
+
if (spec.transform && !ports.transforms.has(spec.transform)) { throw new TypeError('Unknown stream transform'); }
|
|
17
|
+
if (spec.delivery === 'durable' && (!spec.stateVersion || !spec.compatibilityKey)) {
|
|
18
|
+
throw new TypeError('Durable stream requires stateVersion and compatibilityKey');
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
async function durable(bag, value) {
|
|
22
|
+
const {pending} = bag.state.snapshot();
|
|
23
|
+
if (pending?.event.eventId === value.eventId && JSON.stringify(pending.event) !== JSON.stringify(value)) {
|
|
24
|
+
throw new Error('Conflicting event identity');
|
|
25
|
+
}
|
|
26
|
+
await bag.state.resume(bag.sinks);
|
|
27
|
+
if (pending?.event.eventId !== value.eventId) { await bag.state.deliver(value, bag.sinks, value.checkpoint ?? null); }
|
|
28
|
+
}
|
|
29
|
+
async function accept(bag, record) {
|
|
30
|
+
bag.context.signal.throwIfAborted();
|
|
31
|
+
if (bag.stopped) { throw new Error('Stream stopped'); }
|
|
32
|
+
const release = bag.context.budget.reserve({ bytes: Buffer.byteLength(JSON.stringify(record)), records: 1 });
|
|
33
|
+
try {
|
|
34
|
+
const value = bag.transform ? await bag.transform(record) : record;
|
|
35
|
+
bag.context.signal.throwIfAborted();
|
|
36
|
+
if (value === null) { return; }
|
|
37
|
+
if (bag.state) { await durable(bag, value); }
|
|
38
|
+
else {
|
|
39
|
+
await Promise.all(Object.values(bag.sinks).map((sink) => {
|
|
40
|
+
bag.context.signal.throwIfAborted();
|
|
41
|
+
return deadline(() => { return sink.write([value], { signal: bag.context.signal }); }, bag.timeoutMs);
|
|
42
|
+
}));
|
|
43
|
+
}
|
|
44
|
+
bag.context.report?.({ state: 'running', delivered: 1 });
|
|
45
|
+
} finally { release(); }
|
|
46
|
+
}
|
|
47
|
+
function current(bag) {
|
|
48
|
+
bag.context.signal.throwIfAborted();
|
|
49
|
+
if (bag.stopped) { throw new Error('Stream stopped'); }
|
|
50
|
+
}
|
|
51
|
+
async function restore(bag) {
|
|
52
|
+
if (!bag.state) { return; }
|
|
53
|
+
const {checkpoint} = bag.state.snapshot();
|
|
54
|
+
if (checkpoint !== null && typeof bag.source.restore !== 'function') { throw new Error('Durable source cannot restore its checkpoint'); }
|
|
55
|
+
if (typeof bag.source.restore === 'function') { await bag.source.restore(checkpoint); }
|
|
56
|
+
}
|
|
57
|
+
async function start(bag) {
|
|
58
|
+
if (bag.state) { await bag.state.open(); }
|
|
59
|
+
current(bag);
|
|
60
|
+
await Promise.all(Object.values(bag.sinks).map((sink) => { return sink.start?.(); }));
|
|
61
|
+
current(bag);
|
|
62
|
+
if (bag.state) { await bag.state.resume(bag.sinks); }
|
|
63
|
+
current(bag);
|
|
64
|
+
await restore(bag);
|
|
65
|
+
current(bag);
|
|
66
|
+
await bag.source.start();
|
|
67
|
+
}
|
|
68
|
+
async function stop(bag) {
|
|
69
|
+
bag.stopped = true;
|
|
70
|
+
// A failed source cleanup must not be mistaken for a completed release.
|
|
71
|
+
try { await bag.source.stop(); } finally {
|
|
72
|
+
await Promise.all(Object.values(bag.sinks).map((sink) => { return sink.stop?.(); }));
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
function recipient(spec, stream, context, ports) {
|
|
76
|
+
const sink = ports.sinks.get(spec.type)(spec, context);
|
|
77
|
+
if (stream.delivery !== 'durable' || spec.type !== 'amqp') { return sink; }
|
|
78
|
+
return {
|
|
79
|
+
start() { return sink.start?.(); },
|
|
80
|
+
stop() { return sink.stop?.(); },
|
|
81
|
+
write(records, options) {
|
|
82
|
+
return sink.write(records.map((record) => { return { ...record,
|
|
83
|
+
options: { ...record.options, persistent: true, mandatory: true, messageId: record.eventId } }; }), options);
|
|
84
|
+
}
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
function create(spec, context, ports) {
|
|
88
|
+
if (spec.delivery === 'durable' && !context.stateStore) { throw new TypeError('Durable stateStore required'); }
|
|
89
|
+
const sinks = Object.fromEntries(spec.sinks.map((sink, index) => {
|
|
90
|
+
return [sink.id ?? String(index), budgetedSink(recipient(sink, spec, context, ports), context.budget, false)];
|
|
91
|
+
}));
|
|
92
|
+
const bag = { sinks, context, stopped: false, timeoutMs: spec.writeTimeoutMs ?? 10000,
|
|
93
|
+
transform: ports.transforms.get(spec.transform),
|
|
94
|
+
state: spec.delivery === 'durable' ? taskState(context.stateStore, { ...spec, taskId: context.taskId, signal: context.signal }) : null };
|
|
95
|
+
bag.source = ports.sources.get(spec.source.type)(spec.source, { accept(record) { return accept(bag, record); } }, context);
|
|
96
|
+
return { start() { return start(bag); }, stop() { return stop(bag); } };
|
|
97
|
+
}
|
|
98
|
+
/** One lifecycle for arbitrary registered source/transform/sink combinations. */
|
|
99
|
+
export default function streamTask(ports) {
|
|
100
|
+
return {
|
|
101
|
+
validate(spec) { validate(spec, ports); },
|
|
102
|
+
resources(spec) { return spec.resources ?? []; },
|
|
103
|
+
create(spec, context) { return create(spec, context, ports); }
|
|
104
|
+
};
|
|
105
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
function validate(value, name) {
|
|
2
|
+
if (!Number.isSafeInteger(value) || value < 0) { throw new TypeError(`Invalid ${name}`); }
|
|
3
|
+
}
|
|
4
|
+
/** No waiting queue: callers choose backpressure, durable spooling or explicit loss. */
|
|
5
|
+
export default function taskBudget(limits, parent) {
|
|
6
|
+
validate(limits.maxBytes, 'maxBytes');
|
|
7
|
+
validate(limits.maxRecords, 'maxRecords');
|
|
8
|
+
const usage = { bytes: 0, records: 0 };
|
|
9
|
+
return {
|
|
10
|
+
reserve({ bytes, records }) {
|
|
11
|
+
validate(bytes, 'reservation');
|
|
12
|
+
validate(records, 'reservation');
|
|
13
|
+
if (usage.bytes + bytes > limits.maxBytes || usage.records + records > limits.maxRecords) {
|
|
14
|
+
throw new Error('Task capacity exceeded');
|
|
15
|
+
}
|
|
16
|
+
const releaseParent = parent?.reserve({ bytes, records });
|
|
17
|
+
usage.bytes += bytes;
|
|
18
|
+
usage.records += records;
|
|
19
|
+
let released = false;
|
|
20
|
+
return () => {
|
|
21
|
+
if (!released) {
|
|
22
|
+
released = true;
|
|
23
|
+
usage.bytes -= bytes;
|
|
24
|
+
usage.records -= records;
|
|
25
|
+
releaseParent?.();
|
|
26
|
+
}
|
|
27
|
+
};
|
|
28
|
+
},
|
|
29
|
+
status() { return { ...usage }; }
|
|
30
|
+
};
|
|
31
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import taskBudget from './taskBudget.js';
|
|
2
|
+
|
|
3
|
+
/** Context never mutates environment or installs process-global handlers. */
|
|
4
|
+
export default function taskContext(slot, request, ctx) {
|
|
5
|
+
const budget = taskBudget({ maxBytes: request.spec.limits?.maxBytes ?? 1048576,
|
|
6
|
+
maxRecords: request.spec.limits?.maxRecords ?? 1024 }, ctx.budget);
|
|
7
|
+
const { controller } = slot;
|
|
8
|
+
return { ...ctx.services, taskId: slot.taskId, generation: slot.generation, signal: controller.signal, budget,
|
|
9
|
+
report(event) {
|
|
10
|
+
if (controller.signal.aborted) { return; }
|
|
11
|
+
slot.health = event.state;
|
|
12
|
+
if (event.delivered) { slot.lastDelivery = Date.now(); }
|
|
13
|
+
try { ctx.services.report?.({ taskId: slot.taskId, generation: slot.generation, ...event }); } catch {
|
|
14
|
+
// Observability cannot interrupt collection.
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
};
|
|
18
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/** Serial execution with admission bounded before adding anything to the chain. */
|
|
2
|
+
export default function taskQueue({ budget, signal }) {
|
|
3
|
+
let pending = Promise.resolve();
|
|
4
|
+
return {
|
|
5
|
+
run(operation, { bytes = 0, records = 1 } = {}) {
|
|
6
|
+
try {
|
|
7
|
+
signal.throwIfAborted();
|
|
8
|
+
const release = budget.reserve({ bytes, records });
|
|
9
|
+
const result = pending.then(() => {
|
|
10
|
+
signal.throwIfAborted();
|
|
11
|
+
return operation();
|
|
12
|
+
}).finally(release);
|
|
13
|
+
pending = result.catch(() => {
|
|
14
|
+
// Rejection belongs to this operation, not the next queued one.
|
|
15
|
+
});
|
|
16
|
+
return result;
|
|
17
|
+
} catch (error) { return Promise.reject(error); }
|
|
18
|
+
},
|
|
19
|
+
drain() { return pending; }
|
|
20
|
+
};
|
|
21
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import deadline from './deadline.js';
|
|
2
|
+
import taskContext from './taskContext.js';
|
|
3
|
+
|
|
4
|
+
export async function halt(slot, ctx) {
|
|
5
|
+
if (!slot.task) { return; }
|
|
6
|
+
slot.state = 'stopping';
|
|
7
|
+
slot.controller.abort(new Error('Task revoked'));
|
|
8
|
+
try {
|
|
9
|
+
await deadline(() => { return slot.task.stop({ signal: slot.controller.signal }); }, ctx.stopTimeoutMs);
|
|
10
|
+
ctx.claims.release(slot.taskId);
|
|
11
|
+
slot.task = null;
|
|
12
|
+
slot.state = 'stopped';
|
|
13
|
+
} catch (error) {
|
|
14
|
+
slot.state = 'failed';
|
|
15
|
+
ctx.fatal = true;
|
|
16
|
+
slot.reason = error.code ?? error.name;
|
|
17
|
+
throw error;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
function validateTask(task) {
|
|
21
|
+
if (!task || typeof task.start !== 'function' || typeof task.stop !== 'function') {
|
|
22
|
+
throw new TypeError('Adapter must return synchronous lifecycle object');
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
export async function launch(slot, request, ctx, resources) {
|
|
26
|
+
ctx.claims.acquire(slot.taskId, resources);
|
|
27
|
+
Object.assign(slot, { generation: request.generation, specHash: request.specHash, removed: false, state: 'starting', reason: null });
|
|
28
|
+
slot.controller = new AbortController();
|
|
29
|
+
const context = taskContext(slot, request, ctx);
|
|
30
|
+
try {
|
|
31
|
+
// Factories are synchronous: all I/O belongs in cancellable start().
|
|
32
|
+
slot.task = request.adapter.create(request.spec, context);
|
|
33
|
+
validateTask(slot.task);
|
|
34
|
+
await deadline(() => { return slot.task.start(); }, ctx.startTimeoutMs);
|
|
35
|
+
Object.assign(slot, { state: 'running' });
|
|
36
|
+
} catch (error) {
|
|
37
|
+
if (slot.task && typeof slot.task.stop === 'function') { await halt(slot, ctx); }
|
|
38
|
+
else { ctx.claims.release(slot.taskId); slot.task = null; }
|
|
39
|
+
Object.assign(slot, { state: 'failed', reason: error.code ?? error.name });
|
|
40
|
+
throw error;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
export function view(slot) {
|
|
44
|
+
return { taskId: slot.taskId, generation: slot.generation, specHash: slot.specHash,
|
|
45
|
+
state: slot.state, health: slot.health ?? 'unknown', lastDelivery: slot.lastDelivery ?? null, reason: slot.reason ?? null };
|
|
46
|
+
}
|