@yarkivaev/scada 2.3.113 → 2.3.114

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 CHANGED
@@ -274,3 +274,15 @@ Depend on a pinned version (`"@yarkivaev/scada": "…#v2.3.46"`), not a local pa
274
274
  ## CI/CD
275
275
 
276
276
  GitHub Actions via `yarkivaev/npm-workflows` (lint, test, version check). This repo publishes an npm package only; container images are built in downstream deploy repos.
277
+
278
+ ### Ingest delivery and quarantine
279
+
280
+ `STOMP_PREFETCH` sets a positive, bounded segment delivery window (default 128).
281
+ `SEGMENTS_DEAD_LETTER_ENABLED=true` opts into rejecting malformed segment records
282
+ without requeue; transient persistence failures are retried. Enable this only with
283
+ a durable DLQ and routing policy. In the deployment DSL, configure
284
+ `profiles.<profile>.modules.message_bus.dead_letter.queue: scada.ingest.dlq`.
285
+ The scada chart declares the queue and applies an ingest-only dead-letter policy,
286
+ without changing existing queue arguments. The profile `env` is passed to both
287
+ site-server and supervisor-sink. DLQ records retain the original body and RabbitMQ
288
+ `x-death` metadata for inspection and deliberate replay; they are not auto-expired.
@@ -2,5 +2,5 @@ apiVersion: v2
2
2
  name: scada
3
3
  description: Generic SCADA site — site-server, supervisor-sink, collectors, Postgres, RabbitMQ, optional ClickHouse
4
4
  type: application
5
- version: 0.2.3
6
- appVersion: "2.3.113"
5
+ version: 0.2.4
6
+ appVersion: "2.3.114"
@@ -35,6 +35,11 @@
35
35
  {{- if $outgoing }}
36
36
  {{- $queue = append $queue (dict "name" "scada.telemetry.outbox" "vhost" "/" "durable" true "auto_delete" false) -}}
37
37
  {{- end }}
38
+ {{- $policies := list -}}
39
+ {{- with .Values.rabbitmq.deadLetter }}
40
+ {{- $queue = append $queue (dict "name" .queue "vhost" "/" "durable" true "auto_delete" false) -}}
41
+ {{- $policies = append $policies (dict "name" "ingest-dlq" "vhost" "/" "pattern" "^scada\\.(segments|operations|telemetry)\\.ingest$" "apply-to" "queues" "priority" 10 "definition" (dict "dead-letter-exchange" "" "dead-letter-routing-key" .queue)) -}}
42
+ {{- end }}
38
43
  {{- $binding := list -}}
39
44
  {{- range $key := $ingest }}
40
45
  {{- $binding = append $binding (dict "source" "scada.telemetry" "vhost" "/" "destination" "scada.telemetry.ingest" "destination_type" "queue" "routing_key" $key) -}}
@@ -54,6 +59,7 @@
54
59
  "permissions" (list (dict "user" "guest" "vhost" "/" "configure" ".*" "write" ".*" "read" ".*"))
55
60
  "exchanges" $exchange
56
61
  "queues" $queue
62
+ "policies" $policies
57
63
  "bindings" $binding
58
64
  -}}
59
65
  apiVersion: v1
@@ -32,6 +32,10 @@ spec:
32
32
  - name: STATE_HTTP_PORT
33
33
  value: {{ .Values.service.internalPort | quote }}
34
34
  {{- include "scada.linkEnv" . | nindent 12 }}
35
+ {{- range .Values.env }}
36
+ - name: {{ .name }}
37
+ value: {{ .value | quote }}
38
+ {{- end }}
35
39
  readinessProbe:
36
40
  tcpSocket:
37
41
  port: state
@@ -21,6 +21,7 @@ postgres:
21
21
  data: /var/lib/postgresql/data/pgdata
22
22
  rabbitmq:
23
23
  enabled: true
24
+ deadLetter: null
24
25
  image: rabbitmq:3.13-management
25
26
  storage: 5Gi
26
27
  storageClass: ""
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yarkivaev/scada",
3
- "version": "2.3.113",
3
+ "version": "2.3.114",
4
4
  "description": "SCADA domain objects, state persistence, and plant monitoring",
5
5
  "repository": {
6
6
  "type": "git",
@@ -31,7 +31,7 @@
31
31
  "@clickhouse/client": "^1.0.0",
32
32
  "@modelcontextprotocol/sdk": "^1.24.3",
33
33
  "@yarkivaev/simple-server": "1.0.2",
34
- "@yarkivaev/source-to-sink": "1.3.8",
34
+ "@yarkivaev/source-to-sink": "1.3.10",
35
35
  "amqplib": "^0.10.9",
36
36
  "pg": "^8.0.0",
37
37
  "stompit": "^1.0.0",
@@ -16,7 +16,8 @@ function bindOne(name, spec, secret) {
16
16
  return ['postgres', { enabled: spec.enabled !== false, secret: secret || 'scada-postgres' }];
17
17
  }
18
18
  if (name === 'message_bus') {
19
- return ['rabbitmq', { enabled: spec.enabled !== false }];
19
+ return ['rabbitmq', { enabled: spec.enabled !== false,
20
+ ...(spec.dead_letter ? { deadLetter: spec.dead_letter } : {}) }];
20
21
  }
21
22
  if (name === 'telemetry_store') {
22
23
  return ['clickhouse', { enabled: spec.enabled !== false }];
@@ -23,6 +23,10 @@ const appRoot = path.dirname(fileURLToPath(import.meta.url));
23
23
  */
24
24
  export function readDeploymentConfig(env) {
25
25
  const root = path.join(appRoot, '../..');
26
+ const prefetch = Number(env.STOMP_PREFETCH || '128');
27
+ if (!Number.isSafeInteger(prefetch) || prefetch < 1) {
28
+ throw new Error('STOMP_PREFETCH must be a positive safe integer');
29
+ }
26
30
  return {
27
31
  stomp: env.STOMP_URL,
28
32
  postgres: env.POSTGRES_URL,
@@ -39,6 +43,8 @@ export function readDeploymentConfig(env) {
39
43
  requestTimeoutMs: parseRequestTimeoutMs(env.REQUEST_TIMEOUT_MS),
40
44
  pipelines: env.SINK_PIPELINES !== 'false',
41
45
  pipeline: {
46
+ prefetch,
47
+ deadLetter: env.SEGMENTS_DEAD_LETTER_ENABLED === 'true',
42
48
  size: parseInt(env.BATCH_SIZE || '50', 10),
43
49
  interval: parseInt(env.FLUSH_INTERVAL || '2', 10),
44
50
  threshold: parseInt(env.CIRCUIT_THRESHOLD || '5', 10),
@@ -40,7 +40,7 @@ function isHeartbeat(parsed) {
40
40
  * @param {object} codec - downstream codec
41
41
  * @returns {Promise<void>}
42
42
  */
43
- async function flushLane(lane, codec) {
43
+ async function flushLane(lane, codec, deadLetter) {
44
44
  const batch = lane.pending;
45
45
  if (!batch) {
46
46
  return;
@@ -57,9 +57,11 @@ async function flushLane(lane, codec) {
57
57
  }
58
58
  } catch (error) {
59
59
  for (const settle of batch.settles) {
60
- settle.nack();
60
+ settle.nack(!(deadLetter && error.permanent));
61
+ }
62
+ if (!error.permanent) {
63
+ throw error;
61
64
  }
62
- throw error;
63
65
  }
64
66
  }
65
67
 
@@ -78,7 +80,12 @@ async function bufferHeartbeat(ctx, envelope, parsed) {
78
80
  lane.pending.payload = envelope.payload;
79
81
  lane.pending.settles.push(envelope.settle);
80
82
  } else {
81
- await api.flush(lane);
83
+ try {
84
+ await api.flush(lane);
85
+ } catch (error) {
86
+ envelope.settle.nack(true);
87
+ throw error;
88
+ }
82
89
  const pending = {
83
90
  key,
84
91
  destination: envelope.destination,
@@ -88,7 +95,7 @@ async function bufferHeartbeat(ctx, envelope, parsed) {
88
95
  const timer = setTimeout(() => {
89
96
  lane.queue = lane.queue.then(() => {
90
97
  return api.flush(lane);
91
- });
98
+ }).catch(() => { return undefined; });
92
99
  }, intervalMs);
93
100
  Object.assign(lane, { pending, timer });
94
101
  }
@@ -110,12 +117,12 @@ async function step(ctx, envelope, parsed) {
110
117
  await bufferHeartbeat(ctx, envelope, parsed);
111
118
  return;
112
119
  }
113
- await ctx.api.flush(ctx.lane);
114
120
  try {
121
+ await ctx.api.flush(ctx.lane);
115
122
  await ctx.codec.accept({ destination: envelope.destination, payload: envelope.payload });
116
123
  envelope.settle.ack();
117
124
  } catch (error) {
118
- envelope.settle.nack();
125
+ envelope.settle.nack(!(ctx.deadLetter && error.permanent));
119
126
  throw error;
120
127
  }
121
128
  }
@@ -148,9 +155,18 @@ export default function segmentCoalesce(codec, options = {}) {
148
155
  const lanes = new Map();
149
156
  const api = {
150
157
  accept(envelope) {
151
- const parsed = JSON.parse(envelope.payload);
158
+ let parsed;
159
+ try {
160
+ parsed = JSON.parse(envelope.payload);
161
+ if (!parsed || typeof parsed !== 'object') {
162
+ throw new Error('Segment must be an object');
163
+ }
164
+ } catch {
165
+ envelope.settle.nack(!options.deadLetter);
166
+ return Promise.resolve();
167
+ }
152
168
  const lane = laneState(lanes, parsed.machine);
153
- const ctx = { api, lane, codec, size, intervalMs };
169
+ const ctx = { api, lane, codec, size, intervalMs, deadLetter: options.deadLetter };
154
170
  lane.queue = lane.queue.then(() => {
155
171
  return step(ctx, envelope, parsed);
156
172
  }).catch(() => {
@@ -159,7 +175,7 @@ export default function segmentCoalesce(codec, options = {}) {
159
175
  return lane.queue;
160
176
  },
161
177
  async flush(lane) {
162
- await flushLane(lane, codec);
178
+ await flushLane(lane, codec, options.deadLetter);
163
179
  }
164
180
  };
165
181
  return api;
@@ -32,12 +32,19 @@ export default function segmentCodec(collector) {
32
32
  return {
33
33
  async accept(raw) {
34
34
  try {
35
- const parsed = JSON.parse(raw.payload);
35
+ let parsed;
36
+ let record;
37
+ try {
38
+ parsed = JSON.parse(raw.payload);
39
+ record = parsed.type === 'split-family' ? familyRecord(parsed) : segmentNormalize(parsed);
40
+ } catch (error) {
41
+ error.permanent = true;
42
+ throw error;
43
+ }
36
44
  if (parsed.type === 'split-family') {
37
- await collector.accept(familyRecord(parsed));
45
+ await collector.accept(record);
38
46
  return;
39
47
  }
40
- const record = segmentNormalize(parsed);
41
48
  await collector.accept(record);
42
49
  } catch (error) {
43
50
  processingErrorLog('segment_codec', error, { destination: raw.destination, payload: raw.payload });
@@ -60,6 +60,7 @@ export default function segmentPipeline(stomp, postgres, pool, config) {
60
60
  const dispatch = segmentDispatch(continued, retagSink(pool), splitSink, closeOrphanOpen(pool), pool);
61
61
  const coalesce = segmentCoalesce(segmentCodec(dispatch), {
62
62
  size: config.size,
63
+ deadLetter: config.deadLetter,
63
64
  interval: config.interval
64
65
  });
65
66
  const destination = config.segmentsDestination || segmentsIngestDestination;
@@ -68,7 +69,8 @@ export default function segmentPipeline(stomp, postgres, pool, config) {
68
69
  passcode: config.passcode,
69
70
  host: config.host,
70
71
  serial: false,
71
- manualAck: true
72
+ manualAck: true,
73
+ prefetch: config.prefetch
72
74
  });
73
75
  const silence = silentOpenWatch(closeSilentOpen(pool), budget, {
74
76
  intervalMs: (config.poll || 5) * 1000