@yarkivaev/scada 2.3.116 → 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.
Files changed (95) hide show
  1. package/README.md +90 -0
  2. package/charts/scada/templates/processes.yaml +22 -0
  3. package/charts/scada/values.schema.json +12 -0
  4. package/db/migrations/V0009__collector_task_state.sql +6 -0
  5. package/package.json +11 -5
  6. package/src/application/deploy/readDocument.js +4 -1
  7. package/src/application/plantServer.js +1 -1
  8. package/src/application/runtime/additionalAdapters.js +139 -0
  9. package/src/application/runtime/assignment.js +30 -0
  10. package/src/application/runtime/budgetedSink.js +17 -0
  11. package/src/application/runtime/collectorSignals.js +14 -0
  12. package/src/application/runtime/compositeLifecycle.js +49 -0
  13. package/src/application/runtime/createRegistry.js +20 -0
  14. package/src/application/runtime/deadline.js +8 -0
  15. package/src/application/runtime/index.js +10 -0
  16. package/src/application/runtime/manifest.js +38 -0
  17. package/src/application/runtime/pipelineAdapters.js +60 -0
  18. package/src/application/runtime/resolveSecrets.js +12 -0
  19. package/src/application/runtime/resourceClaims.js +21 -0
  20. package/src/application/runtime/runCollector.js +50 -0
  21. package/src/application/runtime/runtime.js +86 -0
  22. package/src/application/runtime/runtimeHealth.js +29 -0
  23. package/src/application/runtime/runtimeServices.js +15 -0
  24. package/src/application/runtime/stateDocument.js +22 -0
  25. package/src/application/runtime/streamPorts.js +42 -0
  26. package/src/application/runtime/streamTask.js +105 -0
  27. package/src/application/runtime/taskBudget.js +31 -0
  28. package/src/application/runtime/taskContext.js +18 -0
  29. package/src/application/runtime/taskQueue.js +21 -0
  30. package/src/application/runtime/taskSlot.js +46 -0
  31. package/src/application/runtime/taskState.js +73 -0
  32. package/src/application/siteCollection.js +14 -0
  33. package/src/application/siteServer.js +7 -6
  34. package/src/application/siteTelemetry.js +1 -1
  35. package/src/application/supervisorSink.js +9 -8
  36. package/src/application/sync/siteSyncBind.js +1 -1
  37. package/src/application/sync/siteSyncTargets.js +1 -1
  38. package/src/bin/collector.js +7 -0
  39. package/src/bin/modbus-to-mqtt.js +11 -9
  40. package/src/infrastructure/ingest/activity/activityTracking.js +15 -21
  41. package/src/infrastructure/ingest/activity/activityTransformer.js +16 -15
  42. package/src/infrastructure/ingest/durableConsume.js +2 -2
  43. package/src/infrastructure/ingest/ingestBatch.js +13 -0
  44. package/src/infrastructure/ingest/modbus/mx210Tcp.js +4 -4
  45. package/src/infrastructure/ingest/modbus/silentStreams.js +33 -8
  46. package/src/infrastructure/ingest/mqtt/metricsTransformer.js +4 -5
  47. package/src/infrastructure/ingest/mqtt/modbusMqtt.js +17 -19
  48. package/src/infrastructure/ingest/mqtt/modbusPublish.js +4 -0
  49. package/src/infrastructure/ingest/mqtt/mqttMetrics.js +17 -21
  50. package/src/infrastructure/ingest/mqtt/registerPublish.js +3 -3
  51. package/src/infrastructure/ingest/pipelines/alertPipeline.js +7 -6
  52. package/src/infrastructure/ingest/pipelines/decisionPipeline.js +7 -6
  53. package/src/infrastructure/ingest/pipelines/segmentPipeline.js +8 -17
  54. package/src/infrastructure/ingest/pipelines/stompOptions.js +7 -0
  55. package/src/infrastructure/ingest/telemetry/amqpMetricsIngest.js +10 -11
  56. package/src/infrastructure/ingest/telemetry/amqpMqttRelay.js +10 -6
  57. package/src/infrastructure/ingest/telemetry/foldingSink.js +5 -2
  58. package/src/infrastructure/messaging/stomp/segmentRetags.js +1 -1
  59. package/src/infrastructure/messaging/stomp/userDecisions.js +1 -1
  60. package/src/infrastructure/opcua/certificates.js +11 -0
  61. package/src/infrastructure/opcua/delivery.js +38 -0
  62. package/src/infrastructure/opcua/endpoint.js +12 -2
  63. package/src/infrastructure/opcua/session.js +20 -10
  64. package/src/infrastructure/opcua/source.js +6 -3
  65. package/src/infrastructure/persistence/runtime/taskStatePg.js +24 -0
  66. package/src/infrastructure/streams/README.md +291 -0
  67. package/src/infrastructure/streams/abortable.js +14 -0
  68. package/src/infrastructure/streams/amqpSink/publication.js +46 -0
  69. package/src/infrastructure/streams/amqpSink/session.js +96 -0
  70. package/src/infrastructure/streams/amqpSink.js +47 -0
  71. package/src/infrastructure/streams/amqpSource/session.js +127 -0
  72. package/src/infrastructure/streams/amqpSource.js +97 -0
  73. package/src/infrastructure/streams/brokerClose.js +19 -0
  74. package/src/infrastructure/streams/budgetAccept.js +6 -0
  75. package/src/infrastructure/streams/clickhouseSink.js +48 -0
  76. package/src/infrastructure/streams/deadline.js +1 -0
  77. package/src/infrastructure/streams/follow.js +41 -0
  78. package/src/infrastructure/streams/index.js +15 -0
  79. package/src/infrastructure/streams/lokiSource.js +88 -0
  80. package/src/infrastructure/streams/modbusConnection.js +173 -0
  81. package/src/infrastructure/streams/modbusRtuBusSource.js +124 -0
  82. package/src/infrastructure/streams/modbusSource.js +185 -0
  83. package/src/infrastructure/streams/mqttSink/session.js +51 -0
  84. package/src/infrastructure/streams/mqttSink.js +54 -0
  85. package/src/infrastructure/streams/mqttSource.js +66 -0
  86. package/src/infrastructure/streams/pollingSource.js +1 -0
  87. package/src/infrastructure/streams/positive.js +5 -0
  88. package/src/infrastructure/streams/postgresSink.js +124 -0
  89. package/src/infrastructure/streams/serialReceive.js +80 -0
  90. package/src/infrastructure/streams/serialSource.js +87 -0
  91. package/src/infrastructure/streams/sqliteSink.js +55 -0
  92. package/src/infrastructure/streams/stompBody.js +46 -0
  93. package/src/infrastructure/streams/stompSend.js +89 -0
  94. package/src/infrastructure/streams/stompSource.js +201 -0
  95. package/src/infrastructure/sync/operationSyncIngest.js +1 -1
@@ -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
+ }
@@ -0,0 +1,73 @@
1
+ import deadline from './deadline.js';
2
+ import stateDocument from './stateDocument.js';
3
+
4
+ async function save(ctx, changes) {
5
+ if (ctx.failed) { throw new Error('Task state requires reload'); }
6
+ ctx.options.signal?.throwIfAborted();
7
+ const next = { ...ctx.data, ...changes };
8
+ try {
9
+ const version = await ctx.store.commit({ ...next, expectedVersion: ctx.data.version, ownership: ctx.options.ownership });
10
+ ctx.options.signal?.throwIfAborted();
11
+ Object.assign(ctx, { data: { ...next, version } });
12
+ } catch (error) { Object.assign(ctx, { failed: true }); throw error; }
13
+ }
14
+ async function resume(ctx, sinks) {
15
+ if (ctx.failed) { throw new Error('Task state requires reload'); }
16
+ if (!ctx.data) { throw new Error('Task state must be opened'); }
17
+ if (!ctx.data.pending) { return; }
18
+ const { event, remaining, checkpoint } = ctx.data.pending;
19
+ for (const name of remaining) {
20
+ ctx.options.signal?.throwIfAborted();
21
+ if (!sinks[name]) { throw new Error('Missing required sink'); }
22
+ // Sequential fan-out persists every durable acknowledgement before continuing.
23
+ // eslint-disable-next-line no-await-in-loop
24
+ await deadline(() => { return sinks[name].write([event], { signal: ctx.options.signal }); }, ctx.options.writeTimeoutMs ?? 10000);
25
+ const pending = { event, checkpoint, remaining: ctx.data.pending.remaining.filter((item) => { return item !== name; }) };
26
+ // eslint-disable-next-line no-await-in-loop
27
+ await save(ctx, { pending });
28
+ }
29
+ await save(ctx, { checkpoint, pending: null });
30
+ }
31
+ function exclusive(ctx, action) {
32
+ if (ctx.busy) { return Promise.reject(new Error('Task state operation in progress')); }
33
+ ctx.busy = true;
34
+ return Promise.resolve().then(action).finally(() => { ctx.busy = false; });
35
+ }
36
+ async function open(ctx) {
37
+ const saved = await ctx.store.load(ctx.options.taskId);
38
+ if (saved && (saved.stateVersion !== ctx.options.stateVersion || saved.compatibilityKey !== ctx.options.compatibilityKey)) {
39
+ throw new Error('Recovered task state is incompatible');
40
+ }
41
+ ctx.options.signal?.throwIfAborted();
42
+ Object.assign(ctx, { data: saved ?? { taskId: ctx.options.taskId, version: 0, stateVersion: ctx.options.stateVersion,
43
+ compatibilityKey: ctx.options.compatibilityKey, checkpoint: null, pending: null } });
44
+ Object.assign(ctx, { failed: false });
45
+ }
46
+ async function deliver(ctx, event, sinks, checkpoint) {
47
+ if (ctx.failed) { throw new Error('Task state requires reload'); }
48
+ if (!ctx.data) { throw new Error('Task state must be opened'); }
49
+ if (ctx.data.pending) { throw new Error('Pending delivery must be resumed first'); }
50
+ if (typeof event.eventId !== 'string' || !event.eventId || Object.keys(sinks).length === 0) {
51
+ throw new TypeError('Stable eventId and required sinks are mandatory');
52
+ }
53
+ const pending = stateDocument({ event, remaining: Object.keys(sinks), checkpoint }, ctx.options.maxBytes ?? 1048576);
54
+ await save(ctx, { pending });
55
+ await resume(ctx, sinks);
56
+ }
57
+ /** Durable per-recipient outbox. CAS detects conflicts; it is not distributed fencing. */
58
+ export default function taskState(store, options) {
59
+ const ctx = { store, options, data: null, busy: false, failed: false };
60
+ return {
61
+ open() { return exclusive(ctx, () => { return open(ctx); }); },
62
+ deliver(event, sinks, checkpoint) { return exclusive(ctx, () => { return deliver(ctx, event, sinks, checkpoint); }); },
63
+ resume(sinks) { return exclusive(ctx, () => { return resume(ctx, sinks); }); },
64
+ checkpoint(value) {
65
+ return exclusive(ctx, () => {
66
+ if (!ctx.data) { throw new Error('Task state must be opened'); }
67
+ if (ctx.data.pending) { throw new Error('Pending delivery must be resumed first'); }
68
+ return save(ctx, { checkpoint: stateDocument(value, options.maxBytes ?? 1048576) });
69
+ });
70
+ },
71
+ snapshot() { return structuredClone(ctx.data); }
72
+ };
73
+ }
@@ -0,0 +1,14 @@
1
+ function external(config) {
2
+ const mode = config.collectorMode ?? config.env?.COLLECTOR_MODE ?? process.env.COLLECTOR_MODE ?? 'embedded';
3
+ if (!['embedded', 'external'].includes(mode)) { throw new TypeError('Invalid collectorMode'); }
4
+ return mode === 'external';
5
+ }
6
+ /** Keep site HTTP/state services while moving autonomous ingestion to executors. */
7
+ export function collectionEnvironment(config) {
8
+ const env = config.env ?? process.env;
9
+ return external(config) ? { ...env, SINK_PIPELINES: 'false' } : env;
10
+ }
11
+ export function startCollection(config, start) {
12
+ if (external(config)) { return undefined; }
13
+ return start();
14
+ }
@@ -1,4 +1,4 @@
1
- import { clickhouseSink } from '@yarkivaev/source-to-sink';
1
+ import { clickhouseSink } from '../infrastructure/streams/index.js';
2
2
  import supervisorSink from './supervisorSink.js';
3
3
  import plantServer from './plantServer.js';
4
4
  import plantOperations from './plantOperations.js';
@@ -11,6 +11,7 @@ import startTelemetryIngest from './siteTelemetry.js';
11
11
  import timelineOperatorFromEnv from './timelineOperatorFromEnv.js';
12
12
  import { buildSiteOperatorCatalog } from './siteOperatorCatalog.js';
13
13
  import siteSyncBind from './sync/siteSyncBind.js';
14
+ import { collectionEnvironment, startCollection } from './siteCollection.js';
14
15
 
15
16
  function stompFromEnv(env) {
16
17
  return {
@@ -118,7 +119,7 @@ function siteExtraRoutes(catalog, extraRoutes, syncRoutes) {
118
119
  * await siteServer({ plantFactory: ({ alerts, userDecisions }, sink) => edgePlant(machines, { metrics: sink.dataAccess.metrics, alerts, userDecisions }), kindSources: { temp } });
119
120
  */
120
121
  export default async function siteServer(config) {
121
- const env = config.env || process.env;
122
+ const env = collectionEnvironment(config);
122
123
  const sink = supervisorSink(env);
123
124
  const ops = bindSiteOperations(sink, config.kindSources || config.operationSources);
124
125
  const http = edgeApi(sink.dataAccess, {
@@ -132,15 +133,15 @@ export default async function siteServer(config) {
132
133
  runRetention
133
134
  });
134
135
  await sink.run(http);
135
- const mqtt = startMqtt(sink, env);
136
- const telemetry = startTelemetryIngest(
136
+ const mqtt = startCollection(config, () => { return startMqtt(sink, env); });
137
+ const telemetry = startCollection(config, () => { return startTelemetryIngest(
137
138
  env,
138
139
  config.streams,
139
140
  mqttConfigFromEnv(env),
140
141
  sink.pool,
141
142
  config.intervalDevices
142
- );
143
- const operationSync = startOperationSync(sink, env);
143
+ ); });
144
+ const operationSync = startCollection(config, () => { return startOperationSync(sink, env); });
144
145
  const basePath = config.basePath || '/api/v1';
145
146
  const catalog = buildSiteOperatorCatalog(config, basePath, sink, env);
146
147
  if (catalog.sync) {
@@ -1,4 +1,4 @@
1
- import { clickhouseSink } from '@yarkivaev/source-to-sink';
1
+ import { clickhouseSink } from '../infrastructure/streams/index.js';
2
2
  import amqpMetricsIngest from '../infrastructure/ingest/telemetry/amqpMetricsIngest.js';
3
3
  import foldedMetricsSink from './foldedMetricsSink.js';
4
4
  import bindSilentStreams from './bindSilentStreams.js';