@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 +12 -0
- package/charts/scada/Chart.yaml +2 -2
- package/charts/scada/templates/rabbitmq.yaml +6 -0
- package/charts/scada/templates/supervisor-sink.yaml +4 -0
- package/charts/scada/values.yaml +1 -0
- package/package.json +2 -2
- package/src/application/deploy/releaseValues.js +2 -1
- package/src/application/supervisorSink.js +6 -0
- package/src/domain/segment/segmentCoalesce.js +26 -10
- package/src/infrastructure/ingest/codecs/segmentCodec.js +10 -3
- package/src/infrastructure/ingest/pipelines/segmentPipeline.js +3 -1
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.
|
package/charts/scada/Chart.yaml
CHANGED
|
@@ -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
|
package/charts/scada/values.yaml
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@yarkivaev/scada",
|
|
3
|
-
"version": "2.3.
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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
|