@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.
Files changed (104) hide show
  1. package/README.md +158 -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 +14 -5
  6. package/src/application/deploy/collectorProcesses.js +3 -2
  7. package/src/application/deploy/readDocument.js +4 -1
  8. package/src/application/plantServer.js +1 -1
  9. package/src/application/runtime/additionalAdapters.js +139 -0
  10. package/src/application/runtime/assignment.js +30 -0
  11. package/src/application/runtime/budgetedSink.js +17 -0
  12. package/src/application/runtime/collectorSignals.js +14 -0
  13. package/src/application/runtime/compositeLifecycle.js +49 -0
  14. package/src/application/runtime/createRegistry.js +20 -0
  15. package/src/application/runtime/deadline.js +8 -0
  16. package/src/application/runtime/index.js +10 -0
  17. package/src/application/runtime/manifest.js +38 -0
  18. package/src/application/runtime/pipelineAdapters.js +60 -0
  19. package/src/application/runtime/resolveSecrets.js +12 -0
  20. package/src/application/runtime/resourceClaims.js +21 -0
  21. package/src/application/runtime/runCollector.js +50 -0
  22. package/src/application/runtime/runtime.js +86 -0
  23. package/src/application/runtime/runtimeHealth.js +29 -0
  24. package/src/application/runtime/runtimeServices.js +15 -0
  25. package/src/application/runtime/stateDocument.js +22 -0
  26. package/src/application/runtime/streamPorts.js +42 -0
  27. package/src/application/runtime/streamTask.js +105 -0
  28. package/src/application/runtime/taskBudget.js +31 -0
  29. package/src/application/runtime/taskContext.js +18 -0
  30. package/src/application/runtime/taskQueue.js +21 -0
  31. package/src/application/runtime/taskSlot.js +46 -0
  32. package/src/application/runtime/taskState.js +73 -0
  33. package/src/application/siteCollection.js +14 -0
  34. package/src/application/siteServer.js +7 -6
  35. package/src/application/siteTelemetry.js +1 -1
  36. package/src/application/supervisorSink.js +9 -8
  37. package/src/application/sync/siteSyncBind.js +1 -1
  38. package/src/application/sync/siteSyncTargets.js +1 -1
  39. package/src/bin/collector.js +7 -0
  40. package/src/bin/modbus-to-mqtt.js +11 -9
  41. package/src/infrastructure/deploy/executeRelease.js +45 -3
  42. package/src/infrastructure/ingest/activity/activityTracking.js +15 -21
  43. package/src/infrastructure/ingest/activity/activityTransformer.js +16 -15
  44. package/src/infrastructure/ingest/durableConsume.js +2 -169
  45. package/src/infrastructure/ingest/ingestBatch.js +13 -0
  46. package/src/infrastructure/ingest/modbus/mx210Tcp.js +4 -4
  47. package/src/infrastructure/ingest/modbus/silentStreams.js +39 -16
  48. package/src/infrastructure/ingest/mqtt/metricsTransformer.js +4 -5
  49. package/src/infrastructure/ingest/mqtt/modbusMqtt.js +17 -19
  50. package/src/infrastructure/ingest/mqtt/modbusPublish.js +4 -0
  51. package/src/infrastructure/ingest/mqtt/mqttMetrics.js +17 -21
  52. package/src/infrastructure/ingest/mqtt/registerPublish.js +3 -3
  53. package/src/infrastructure/ingest/pipelines/alertPipeline.js +7 -6
  54. package/src/infrastructure/ingest/pipelines/decisionPipeline.js +7 -6
  55. package/src/infrastructure/ingest/pipelines/segmentPipeline.js +8 -17
  56. package/src/infrastructure/ingest/pipelines/stompOptions.js +7 -0
  57. package/src/infrastructure/ingest/silentOpenWatch.js +5 -42
  58. package/src/infrastructure/ingest/telemetry/amqpMetricsIngest.js +16 -28
  59. package/src/infrastructure/ingest/telemetry/amqpMqttRelay.js +17 -22
  60. package/src/infrastructure/ingest/telemetry/foldingSink.js +5 -2
  61. package/src/infrastructure/messaging/stomp/segmentRetags.js +1 -1
  62. package/src/infrastructure/messaging/stomp/userDecisions.js +1 -1
  63. package/src/infrastructure/opcua/certificates.js +11 -0
  64. package/src/infrastructure/opcua/config.js +38 -0
  65. package/src/infrastructure/opcua/delivery.js +38 -0
  66. package/src/infrastructure/opcua/endpoint.js +90 -0
  67. package/src/infrastructure/opcua/index.js +10 -0
  68. package/src/infrastructure/opcua/session.js +70 -0
  69. package/src/infrastructure/opcua/source.js +38 -0
  70. package/src/infrastructure/opcua/subscriptionHealth.js +28 -0
  71. package/src/infrastructure/operators/operatorsSync.js +5 -29
  72. package/src/infrastructure/persistence/clickhouse/streamHub.js +21 -15
  73. package/src/infrastructure/persistence/metricsSensor.js +9 -6
  74. package/src/infrastructure/persistence/runtime/taskStatePg.js +24 -0
  75. package/src/infrastructure/streams/README.md +291 -0
  76. package/src/infrastructure/streams/abortable.js +14 -0
  77. package/src/infrastructure/streams/amqpSink/publication.js +46 -0
  78. package/src/infrastructure/streams/amqpSink/session.js +96 -0
  79. package/src/infrastructure/streams/amqpSink.js +47 -0
  80. package/src/infrastructure/streams/amqpSource/session.js +127 -0
  81. package/src/infrastructure/streams/amqpSource.js +97 -0
  82. package/src/infrastructure/streams/brokerClose.js +19 -0
  83. package/src/infrastructure/streams/budgetAccept.js +6 -0
  84. package/src/infrastructure/streams/clickhouseSink.js +48 -0
  85. package/src/infrastructure/streams/deadline.js +1 -0
  86. package/src/infrastructure/streams/follow.js +41 -0
  87. package/src/infrastructure/streams/index.js +15 -0
  88. package/src/infrastructure/streams/lokiSource.js +88 -0
  89. package/src/infrastructure/streams/modbusConnection.js +173 -0
  90. package/src/infrastructure/streams/modbusRtuBusSource.js +124 -0
  91. package/src/infrastructure/streams/modbusSource.js +185 -0
  92. package/src/infrastructure/streams/mqttSink/session.js +51 -0
  93. package/src/infrastructure/streams/mqttSink.js +54 -0
  94. package/src/infrastructure/streams/mqttSource.js +66 -0
  95. package/src/infrastructure/streams/pollingSource.js +1 -0
  96. package/src/infrastructure/streams/positive.js +5 -0
  97. package/src/infrastructure/streams/postgresSink.js +124 -0
  98. package/src/infrastructure/streams/serialReceive.js +80 -0
  99. package/src/infrastructure/streams/serialSource.js +87 -0
  100. package/src/infrastructure/streams/sqliteSink.js +55 -0
  101. package/src/infrastructure/streams/stompBody.js +46 -0
  102. package/src/infrastructure/streams/stompSend.js +89 -0
  103. package/src/infrastructure/streams/stompSource.js +201 -0
  104. package/src/infrastructure/sync/operationSyncIngest.js +4 -16
@@ -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';
@@ -15,7 +15,7 @@ const appRoot = path.dirname(fileURLToPath(import.meta.url));
15
15
 
16
16
  /**
17
17
  * Reads deployment environment into a normalized config object.
18
- * `SINK_PIPELINES=false` leaves Postgres and the state HTTP API in this process
18
+ * `COLLECTOR_MODE=external` or `SINK_PIPELINES=false` leaves Postgres and the state HTTP API in this process
19
19
  * and leaves the STOMP consumers to another container.
20
20
  *
21
21
  * @param {NodeJS.ProcessEnv} env - process environment
@@ -41,7 +41,7 @@ export function readDeploymentConfig(env) {
41
41
  retentionDays: parseInt(env.RETENTION_DAYS || '30', 10),
42
42
  migrationsDir: path.join(root, 'db', 'migrations'),
43
43
  requestTimeoutMs: parseRequestTimeoutMs(env.REQUEST_TIMEOUT_MS),
44
- pipelines: env.SINK_PIPELINES !== 'false',
44
+ pipelines: env.COLLECTOR_MODE !== 'external' && env.SINK_PIPELINES !== 'false',
45
45
  pipeline: {
46
46
  prefetch,
47
47
  deadLetter: env.SEGMENTS_DEAD_LETTER_ENABLED === 'true',
@@ -60,6 +60,12 @@ export function readDeploymentConfig(env) {
60
60
  };
61
61
  }
62
62
 
63
+ function startPipelines(cfg, pool) {
64
+ const pipelines = [segmentPipeline(cfg.stomp, cfg.postgres, pool, cfg.pipeline),
65
+ decisionPipeline(cfg.stomp, pool, cfg.pipeline), alertPipeline(cfg.stomp, pool, cfg.pipeline)];
66
+ return Promise.all(pipelines.map((pipeline) => { return pipeline.start(); }));
67
+ }
68
+
63
69
  /**
64
70
  * Wires STOMP pipelines, migrations, and state data access from env config.
65
71
  * HTTP binding uses edgeApi (metrics, checkpoint, retention).
@@ -70,9 +76,6 @@ export function readDeploymentConfig(env) {
70
76
  export default function supervisorSink(env = process.env) {
71
77
  const cfg = readDeploymentConfig(env);
72
78
  const pool = holdClient(new pg.Pool({ connectionString: cfg.postgres }));
73
- const segments = segmentPipeline(cfg.stomp, cfg.postgres, pool, cfg.pipeline);
74
- const decisions = decisionPipeline(cfg.stomp, pool, cfg.pipeline);
75
- const alerts = alertPipeline(cfg.stomp, pool, cfg.pipeline);
76
79
  const metricsEnabled = cfg.sinkDbProfile === 'edge';
77
80
  const dataAccess = stateDataFromPool(pool, { metricsEnabled });
78
81
  return {
@@ -97,9 +100,7 @@ export default function supervisorSink(env = process.env) {
97
100
  await adminPool.end();
98
101
  }
99
102
  if (cfg.pipelines) {
100
- await segments.start();
101
- decisions.start();
102
- alerts.start();
103
+ await startPipelines(cfg, pool);
103
104
  }
104
105
  if (httpServer) {
105
106
  httpServer.start();
@@ -1,4 +1,4 @@
1
- import { clickhouseSink } from '@yarkivaev/source-to-sink';
1
+ import { clickhouseSink } from '../../infrastructure/streams/index.js';
2
2
  import { jobRoutes, jobs } from '@yarkivaev/simple-server';
3
3
  import exportQuery from '../export/exportQuery.js';
4
4
  import scadaClient from '../../infrastructure/client/scadaClient.js';
@@ -1,4 +1,4 @@
1
- import { postgresSink } from '@yarkivaev/source-to-sink';
1
+ import { postgresSink } from '../../infrastructure/streams/index.js';
2
2
  import {
3
3
  alertRow,
4
4
  listedItems,
@@ -0,0 +1,7 @@
1
+ #!/usr/bin/env node
2
+ import { createRegistry, runCollector } from '../application/runtime/index.js';
3
+
4
+ try { await runCollector({ registry: createRegistry() }); } catch (error) {
5
+ process.stderr.write(`collector: ${error.name}\n`);
6
+ process.exitCode = 1;
7
+ }
@@ -3,12 +3,7 @@
3
3
  *
4
4
  * Polls ADDRESS or MODBUS_DEVICES and publishes STREAM + MAPPING document paths.
5
5
  */
6
- process.on('unhandledRejection', (err) => {
7
- process.stderr.write(`Unhandled rejection: ${String(err)}\n`);
8
- process.exit(1);
9
- });
10
-
11
- import modbusPublish from '../infrastructure/ingest/mqtt/modbusPublish.js';
6
+ import { createRegistry, runCollector } from '../application/runtime/index.js';
12
7
 
13
8
  /**
14
9
  * Parses a JSON array environment value.
@@ -41,7 +36,7 @@ function integer(text) {
41
36
  }
42
37
 
43
38
  const addresses = jsonList(process.env.ADDRESS);
44
- const pipeline = modbusPublish(process.env.MQTT_URL, {
39
+ const config = {
45
40
  address: integer(process.env.MODBUS_ADDRESS),
46
41
  addresses,
47
42
  clientId: process.env.CLIENT_ID,
@@ -52,6 +47,13 @@ const pipeline = modbusPublish(process.env.MQTT_URL, {
52
47
  streams: jsonList(process.env.STREAM) || [],
53
48
  threshold: parseInt(process.env.CIRCUIT_THRESHOLD || '5', 10),
54
49
  timeout: parseInt(process.env.CIRCUIT_TIMEOUT || '60', 10)
55
- });
50
+ };
56
51
 
57
- pipeline.start();
52
+ try {
53
+ const env = { ...process.env, COLLECTOR_MANIFEST: JSON.stringify({ assignments: [{ taskId: 'modbus', generation: 0, specVersion: 1,
54
+ spec: { adapter: 'modbus', url: { $secret: 'MQTT_URL' }, config } }] }) };
55
+ await runCollector({ registry: createRegistry(), env, argv: [] });
56
+ } catch (error) {
57
+ process.stderr.write(`collector: ${error.name}\n`);
58
+ process.exitCode = 1;
59
+ }
@@ -1,11 +1,27 @@
1
1
  import { execFileSync } from 'node:child_process';
2
2
 
3
+ /** Retire only explicit Deployments with a single exact app label selector. */
4
+ function validateRetirements(plan) {
5
+ if (plan.retirements !== undefined && !Array.isArray(plan.retirements)) {
6
+ throw new Error('retirements must be an array');
7
+ }
8
+ for (const retirement of plan.retirements || []) {
9
+ const name = retirement?.workload?.match(/^deployment\/(?<name>[a-z0-9](?:[a-z0-9.-]*[a-z0-9])?)$/u)?.groups.name;
10
+ const label = retirement?.selector?.match(/^app=(?<label>[A-Za-z0-9](?:[A-Za-z0-9_.-]*[A-Za-z0-9])?)$/u)?.groups.label;
11
+ if (!name || name.length > 253 || name.split('.').some((part) => { return !part || part.length > 63; })
12
+ || !label || label.length > 63 || (plan.workloads || []).includes(retirement.workload)) {
13
+ throw new Error('retirement requires an exact old deployment and app label outside desired workloads');
14
+ }
15
+ }
16
+ }
17
+
3
18
  /** Validates the target before helm or kubectl can fall back to a default. */
4
19
  function validate(plan, action) {
5
20
  if (!['render', 'check', 'diff', 'apply', 'adopt', 'check-adoption'].includes(action)) {
6
21
  throw new Error('action must be render, check, diff or apply (adopt/check-adoption for migration)');
7
22
  }
8
23
  const fields = action === 'render' ? ['chart', 'release', 'namespace'] : ['chart', 'release', 'namespace', 'context', 'fieldManager'];
24
+ validateRetirements(plan);
9
25
  fields.forEach((key) => {
10
26
  if (typeof plan[key] !== 'string' || !plan[key].trim()) { throw new Error(`release requires ${key}`); }
11
27
  });
@@ -13,9 +29,9 @@ function validate(plan, action) {
13
29
 
14
30
  /** Every Kubernetes command, including the post-rollout hook, shares this target. */
15
31
  function kubeAt(plan, execute) {
16
- return (args, input) => {
32
+ return (args, input, capture = false) => {
17
33
  return execute('kubectl', ['--context', plan.context, '--namespace', plan.namespace, ...args],
18
- { input, encoding: 'utf8', stdio: ['pipe', 'inherit', 'inherit'] });
34
+ { input, encoding: 'utf8', stdio: ['pipe', capture ? 'pipe' : 'inherit', 'inherit'] });
19
35
  };
20
36
  }
21
37
 
@@ -25,8 +41,31 @@ function applyWith(kube, manifests, flags, checkOnly) {
25
41
  if (!checkOnly) { kube(args, manifests); }
26
42
  }
27
43
 
44
+ /** A missing Deployment can still have terminating pods from an interrupted attempt. */
45
+ function retireWorkloads(plan, kube) {
46
+ for (const { workload, selector } of plan.retirements || []) {
47
+ if (kube(['get', workload, '--ignore-not-found', '-o', 'name'], undefined, true).trim()) {
48
+ kube(['delete', workload, '--cascade=foreground', '--wait=true', '--timeout=300s']);
49
+ }
50
+ if (kube(['get', 'pods', '-l', selector, '-o', 'name'], undefined, true).trim()) {
51
+ throw new Error(`retirement pods remain for ${workload} (${selector})`);
52
+ }
53
+ }
54
+ }
55
+
56
+ function preflightRetirements(plan, action, kube, manifests) {
57
+ if (['apply', 'adopt'].includes(action) && plan.retirements?.length) {
58
+ if (action === 'adopt') {
59
+ applyWith(kube, manifests, ['--field-manager=kubectl-client-side-apply'], true);
60
+ }
61
+ applyWith(kube, manifests, ['--server-side', `--field-manager=${plan.fieldManager}`], true);
62
+ retireWorkloads(plan, kube);
63
+ }
64
+ }
65
+
28
66
  /** Adopt legacy fields, then enforce the complete manifest before waiting for rollouts. */
29
67
  function applyRelease(plan, action, kube, manifests) {
68
+ preflightRetirements(plan, action, kube, manifests);
30
69
  if (['adopt', 'check-adoption'].includes(action)) {
31
70
  applyWith(kube, manifests, ['--field-manager=kubectl-client-side-apply'], action === 'check-adoption');
32
71
  if (action === 'check-adoption') { return ''; }
@@ -44,7 +83,10 @@ function applyRelease(plan, action, kube, manifests) {
44
83
  * Renders a Helm release and executes the selected kubectl action synchronously.
45
84
  * Supply chart, release, namespace and values. Cluster actions additionally require
46
85
  * an explicit context and fieldManager. prerequisites and workloads are kubectl
47
- * resource/name references. afterReady(plan, kube) runs after a successful apply.
86
+ * resource/name references. Optional retirements are { workload: "deployment/name",
87
+ * selector: "app=label" }; callers bind these to the old declarative app. They are
88
+ * removed after preflight and before any writes, with no selected pods remaining.
89
+ * afterReady(plan, kube) runs after a successful apply.
48
90
  */
49
91
  export default function executeRelease(plan, action = 'render', execute = execFileSync) {
50
92
  validate(plan, action);
@@ -1,19 +1,14 @@
1
- import {
2
- batch,
3
- circuit,
4
- clickhouseSink,
5
- clock,
6
- lokiSource,
7
- timedBatch
8
- } from '@yarkivaev/source-to-sink';
1
+ import { clock } from '@yarkivaev/source-to-sink';
2
+ import { clickhouseSink, lokiSource } from '../../streams/index.js';
3
+ import ingestBatch from '../ingestBatch.js';
9
4
  import activityCodec from './activityTransformer.js';
10
5
 
11
6
  /**
12
7
  * Pipeline for streaming Loki HTTP access logs to ClickHouse.
13
8
  *
14
- * Polls Loki for log entries matching the query and batches them
15
- * before inserting into ClickHouse `activity.http_access` table.
16
- * Uses circuit breaker for failure isolation.
9
+ * Polls Loki and durably inserts each accepted log into ClickHouse
10
+ * before advancing the serial source cursor.
11
+ * Bounds pending records and bytes and waits for durable writes.
17
12
  *
18
13
  * @example
19
14
  * const pipeline = activityTracking(
@@ -31,10 +26,10 @@ import activityCodec from './activityTransformer.js';
31
26
  * @param {string} query - LogQL query string
32
27
  * @param {object} config - Pipeline configuration
33
28
  * @param {number} config.poll - Loki polling interval in seconds
34
- * @param {number} config.size - Batch size before flush
29
+ * @param {number} config.size - Legacy option; serial durable acceptance writes one record at a time
35
30
  * @param {number} config.interval - Seconds before time-based flush
36
- * @param {number} config.threshold - Circuit breaker failure threshold
37
- * @param {number} config.timeout - Circuit breaker timeout in seconds
31
+ * @param {number} config.threshold - Legacy option (retained for configuration compatibility)
32
+ * @param {number} config.timeout - Legacy option (use writeTimeoutMs for write deadlines)
38
33
  * @returns {object} Pipeline with start() and stop() methods
39
34
  */
40
35
  export default function activityTracking(loki, clickhouse, query, config) {
@@ -51,24 +46,23 @@ export default function activityTracking(loki, clickhouse, query, config) {
51
46
  throw new Error('Config must be an object');
52
47
  }
53
48
  const clk = clock();
54
- const breaker = circuit(config.threshold, config.timeout, clk);
55
49
  const sink = clickhouseSink(clickhouse, 'activity.http_access');
56
- const collector = timedBatch(batch(sink, config.size, breaker), config.interval);
50
+ const collector = ingestBatch(sink, { ...config, size: 1 });
57
51
  const transformer = activityCodec(collector);
58
- const source = lokiSource(loki, query, config.poll, transformer, clk);
52
+ const source = lokiSource(loki, query, config.poll, transformer, clk, { ...config.lokiOptions, budget: config.budget });
59
53
  return {
60
54
  /**
61
55
  * Starts the pipeline.
62
56
  */
63
57
  start() {
64
- source.start();
58
+ return source.start();
65
59
  },
66
60
  /**
67
61
  * Stops the pipeline.
68
62
  */
69
- stop() {
70
- source.stop();
71
- collector.stop();
63
+ async stop() {
64
+ await source.stop();
65
+ try { await collector.stop(); } finally { await sink.stop?.(); }
72
66
  }
73
67
  };
74
68
  }
@@ -22,24 +22,25 @@ export default function activityCodec(collector) {
22
22
  * @param {object} entry - Log entry with ts and line
23
23
  */
24
24
  accept(entry) {
25
+ let log;
25
26
  try {
26
- const log = JSON.parse(entry.line);
27
- if (!log.StartUTC || !log.RequestMethod || !log.RequestPath) {
28
- return;
29
- }
30
- collector.accept({
31
- ts: new Date(log.StartUTC).getTime(),
32
- user: log['request_Remote-User'] || '',
33
- email: log['request_Remote-Email'] || '',
34
- method: log.RequestMethod || '',
35
- path: log.RequestPath || '',
36
- status: parseInt(log.DownstreamStatus, 10) || 0,
37
- duration: parseFloat(log.Duration) / 1e9 || 0,
38
- ip: log.ClientHost || ''
39
- });
27
+ log = JSON.parse(entry.line);
40
28
  } catch {
41
- // Skip malformed log entries
29
+ return undefined;
42
30
  }
31
+ if (!log || !log.StartUTC || !log.RequestMethod || !log.RequestPath) {
32
+ return undefined;
33
+ }
34
+ return collector.accept({
35
+ ts: new Date(log.StartUTC).getTime(),
36
+ user: log['request_Remote-User'] || '',
37
+ email: log['request_Remote-Email'] || '',
38
+ method: log.RequestMethod || '',
39
+ path: log.RequestPath || '',
40
+ status: parseInt(log.DownstreamStatus, 10) || 0,
41
+ duration: parseFloat(log.Duration) / 1e9 || 0,
42
+ ip: log.ClientHost || ''
43
+ });
43
44
  }
44
45
  };
45
46
  }
@@ -1,169 +1,2 @@
1
- import amqp from 'amqplib';
2
-
3
- function connectBroker(url) {
4
- return amqp.connect(url, { heartbeat: 10 });
5
- }
6
-
7
- function scheduleLater(fn, pause) {
8
- return setTimeout(fn, pause);
9
- }
10
-
11
- function clearLater(handle) {
12
- clearTimeout(handle);
13
- }
14
-
15
- function reportFailure(error) {
16
- process.stderr.write(`amqp consume failed ${error.message}\n`);
17
- }
18
-
19
- function nextDelay(delay) {
20
- return Math.min(delay * 2, 30000);
21
- }
22
-
23
- function blankState() {
24
- return {
25
- generation: 0,
26
- timer: undefined,
27
- stopping: false,
28
- delay: 1000,
29
- session: undefined
30
- };
31
- }
32
-
33
- function running(state) {
34
- return Boolean(state.session) || (state.generation > 0 && !state.stopping);
35
- }
36
-
37
- function scheduleRetry(ctx, generation) {
38
- if (ctx.state.stopping || generation !== ctx.state.generation) {
39
- return;
40
- }
41
- const pause = ctx.state.delay;
42
- ctx.state.delay = nextDelay(ctx.state.delay);
43
- ctx.state.timer = ctx.schedule(() => {
44
- ctx.state.timer = undefined;
45
- return ctx.open(generation);
46
- }, pause);
47
- }
48
-
49
- function bindClose(ctx, generation, live) {
50
- let dropped = false;
51
- function lose() {
52
- if (dropped || ctx.state.stopping || generation !== ctx.state.generation) {
53
- return;
54
- }
55
- dropped = true;
56
- if (ctx.state.session === live) {
57
- ctx.state.session = undefined;
58
- }
59
- scheduleRetry(ctx, generation);
60
- }
61
- live.connection.on('error', lose);
62
- live.connection.on('close', lose);
63
- live.channel.on('error', lose);
64
- live.channel.on('close', lose);
65
- }
66
-
67
- async function accept(ctx, generation, connection) {
68
- if (ctx.state.stopping || generation !== ctx.state.generation) {
69
- await connection.close();
70
- return null;
71
- }
72
- return connection.createChannel();
73
- }
74
-
75
- async function keep(ctx, generation, connection, channel, tag) {
76
- if (ctx.state.stopping || generation !== ctx.state.generation) {
77
- await channel.close();
78
- await connection.close();
79
- return false;
80
- }
81
- const live = { connection, channel, tag };
82
- ctx.state.session = live;
83
- ctx.state.delay = 1000;
84
- bindClose(ctx, generation, live);
85
- return true;
86
- }
87
-
88
- async function open(ctx, generation) {
89
- if (ctx.state.stopping || generation !== ctx.state.generation) {
90
- return;
91
- }
92
- try {
93
- const connection = await ctx.connect(ctx.url);
94
- const channel = await accept(ctx, generation, connection);
95
- if (!channel) {
96
- return;
97
- }
98
- const tag = await ctx.subscribe(channel);
99
- await keep(ctx, generation, connection, channel, tag);
100
- } catch (error) {
101
- ctx.report(error);
102
- scheduleRetry(ctx, generation);
103
- }
104
- }
105
-
106
- function begin(ctx) {
107
- if (running(ctx.state)) {
108
- return Promise.resolve();
109
- }
110
- ctx.state.stopping = false;
111
- ctx.state.generation += 1;
112
- ctx.state.delay = 1000;
113
- return open(ctx, ctx.state.generation);
114
- }
115
-
116
- async function end(ctx) {
117
- ctx.state.stopping = true;
118
- ctx.state.generation += 1;
119
- if (ctx.state.timer) {
120
- ctx.clear(ctx.state.timer);
121
- ctx.state.timer = undefined;
122
- }
123
- const live = ctx.state.session;
124
- ctx.state.session = undefined;
125
- if (!live) {
126
- return;
127
- }
128
- await live.channel.cancel(live.tag);
129
- await live.channel.close();
130
- await live.connection.close();
131
- }
132
-
133
- /**
134
- * Keeps one AMQP consumer subscribed across broker restarts.
135
- *
136
- * @param {string} url - RabbitMQ AMQP URL
137
- * @param {function} subscribe - async (channel) => consumerTag
138
- * @param {object} [options] - connect, schedule, clear, report overrides
139
- * @returns {object} session with start and stop
140
- *
141
- * @example
142
- * const session = durableConsume('amqp://localhost', async (channel) => {
143
- * const tag = await channel.consume('scada.telemetry.ingest', () => {});
144
- * return tag.consumerTag;
145
- * });
146
- * await session.start();
147
- */
148
- export default function durableConsume(url, subscribe, options = {}) {
149
- const ctx = {
150
- url,
151
- subscribe,
152
- connect: options.connect || connectBroker,
153
- schedule: options.schedule || scheduleLater,
154
- clear: options.clear || clearLater,
155
- report: options.report || reportFailure,
156
- state: blankState(),
157
- open(generation) {
158
- return open(ctx, generation);
159
- }
160
- };
161
- return {
162
- start() {
163
- return begin(ctx);
164
- },
165
- stop() {
166
- return end(ctx);
167
- }
168
- };
169
- }
1
+ // Compatibility facade; transport lifecycle is owned by the scada stream adapters.
2
+ export { amqpSession as default } from '../streams/index.js';
@@ -0,0 +1,13 @@
1
+ import { boundedBatch } from '@yarkivaev/source-to-sink';
2
+
3
+ /** Shared production limits; acceptance resolves only after the sink write. */
4
+ export default function ingestBatch(sink, config = {}) {
5
+ return boundedBatch(sink, {
6
+ ...(config.budget ? { budget: config.budget } : {}),
7
+ size: config.size ?? 100,
8
+ maxRecords: config.maxRecords ?? 1000,
9
+ maxBytes: config.maxBytes ?? 1048576,
10
+ maxWaitMs: config.maxWaitMs ?? (config.interval ?? 5) * 1000,
11
+ writeTimeoutMs: config.writeTimeoutMs ?? 10000
12
+ });
13
+ }
@@ -1,4 +1,4 @@
1
- import { modbusSource } from '@yarkivaev/source-to-sink';
1
+ import { modbusSource } from '../../streams/index.js';
2
2
 
3
3
  const layout = [
4
4
  { offset: 0, channel: 'AI1' },
@@ -34,13 +34,13 @@ export function mx210Metrics(device, collector) {
34
34
  return {
35
35
  accept(registers) {
36
36
  const ts = Date.now();
37
- for (const entry of layout) {
38
- collector.accept({
37
+ return Promise.all(layout.map((entry) => {
38
+ return collector.accept({
39
39
  topic: `MX210/${device}/GET/${entry.channel}/VALUE`,
40
40
  ts,
41
41
  value: decode(registers[entry.offset], registers[entry.offset + 1])
42
42
  });
43
- }
43
+ }));
44
44
  }
45
45
  };
46
46
  }