@yarkivaev/scada 2.4.6 → 2.4.8

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
@@ -485,3 +485,40 @@ their integration tests live in `src/infrastructure/streams` and are exported as
485
485
  `@yarkivaev/scada/streams`. This includes AMQP, MQTT, STOMP, serial, Modbus, Loki,
486
486
  PostgreSQL, ClickHouse and SQLite; OPC UA stays in `@yarkivaev/scada/opcua`.
487
487
  Plant-specific transformations stay in the plant package.
488
+
489
+ ## Segment transaction batches
490
+
491
+ `supervisor-sink` can read a plant policy from `SEGMENTS_TX_POLICY_FILE` (JSON).
492
+ Without a policy or `SEGMENTS_TX_*` settings, existing deployments keep their current behaviour.
493
+ Environment values override every policy field, so a ConfigMap update and a Recreate restart
494
+ of the same image can tune persistence without an application release.
495
+
496
+ | Policy field | Environment override | Default when enabled | Meaning |
497
+ | --- | --- | --- | --- |
498
+ | `enabled` | `SEGMENTS_TX_ENABLED` | `true` | Explicit `false` disables batching and bypasses an obsolete policy path. |
499
+ | `maxMessages` | `SEGMENTS_TX_MAX_MESSAGES` | 32 | Original deliveries per transaction, capped by STOMP prefetch. |
500
+ | `maxBytes` | `SEGMENTS_TX_MAX_BYTES` | 1048576 | Batch payload byte target; a transport-valid oversized delivery runs alone. |
501
+ | `maxWaitMs` | `SEGMENTS_TX_MAX_WAIT_MS` | 100 | Age after which a partial batch becomes eligible to write. |
502
+ | `completedSegments` | `SEGMENTS_TX_COMPLETED_SEGMENTS` | 1 | Flush after this many distinct completed segment keys; `0` disables this trigger. |
503
+ | `concurrency` | `SEGMENTS_TX_CONCURRENCY` | 2 | Concurrent transactions for independent machines. |
504
+ | `timeoutMs` | `SEGMENTS_TX_TIMEOUT_MS` | 10000 | Bounds connection acquisition, statements and COMMIT for a transaction. |
505
+ | `statsIntervalMs` | `SEGMENTS_TX_STATS_INTERVAL_MS` | 10000 | Aggregate transaction log interval; `0` disables it. |
506
+
507
+ `SEGMENTS_DB_POOL_MAX` controls the owned pool (default 10); transaction concurrency must fit it.
508
+ `STOMP_PREFETCH`, `STOMP_MAX_MESSAGE_BYTES`, `STOMP_MAX_PENDING_BYTES`,
509
+ `STOMP_ACCEPT_TIMEOUT_MS` and `STOMP_STOP_TIMEOUT_MS` remain independently configurable.
510
+ The pending-byte lease remains held until actual commit or rejection. Prefetch, byte limits
511
+ and the partial-batch timer prevent waiting indefinitely for a long segment's end.
512
+
513
+ Each machine retains FIFO command order and every input command, including pending snapshots,
514
+ terminal transitions, retags and split families. This preserves orphan-close and continuation
515
+ side effects. Different machines can progress independently.
516
+ Every receipt is acknowledged only after its batch commits. A confirmed permanent row error
517
+ rolls the batch back and isolates commands into individual transactions. Uncertain COMMIT,
518
+ connection failure or timeout fences the subscription; RabbitMQ retains unacknowledged deliveries
519
+ for idempotent replay. WAL durability remains enabled.
520
+
521
+ `[segment-transactions]` JSON logs contain committed message/record counts, COMMIT and rollback
522
+ counts, SQL-call count, and maximum transaction/wait durations. They contain no payloads or tags.
523
+ Compare these counters with RabbitMQ ACK/drain rates and process memory when tuning. More messages
524
+ per batch reduce COMMIT frequency; the number of SQL writes still depends on command semantics.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yarkivaev/scada",
3
- "version": "2.4.6",
3
+ "version": "2.4.8",
4
4
  "description": "SCADA domain objects, state persistence, and plant monitoring",
5
5
  "repository": {
6
6
  "type": "git",
@@ -0,0 +1,18 @@
1
+ /** Payload-free transaction counters for comparing configurations on the same image. */
2
+ export default function segmentBatchStats(intervalMs, output = line => { process.stdout.write(line); }, clock = () => { return performance.now(); }) {
3
+ let started = clock();
4
+ let totals = { commits: 0, rollbacks: 0, messages: 0, records: 0, sqlCalls: 0, maxTransactionMs: 0, maxWaitMs: 0 };
5
+ return event => {
6
+ if (intervalMs === 0) { return; }
7
+ if (event.committed) {
8
+ totals.commits += 1;totals.messages += event.messages;totals.records += event.records;totals.sqlCalls += event.sqlCalls;
9
+ totals.maxTransactionMs = Math.max(totals.maxTransactionMs, event.durationMs);
10
+ totals.maxWaitMs = Math.max(totals.maxWaitMs, event.waitMs);
11
+ } else { totals.rollbacks += 1; }
12
+ const now = clock();
13
+ if (now - started >= intervalMs) {
14
+ output(`[segment-transactions] ${JSON.stringify({ intervalMs: now - started, ...totals })}\n`);
15
+ started = now;totals = { commits: 0, rollbacks: 0, messages: 0, records: 0, sqlCalls: 0, maxTransactionMs: 0, maxWaitMs: 0 };
16
+ }
17
+ };
18
+ }
@@ -0,0 +1,26 @@
1
+ import { readFileSync } from 'node:fs';
2
+ import segmentBatchPolicy from '../infrastructure/ingest/pipelines/segmentBatchPolicy.js';
3
+
4
+ const variables = { maxMessages: 'MAX_MESSAGES', maxBytes: 'MAX_BYTES', maxWaitMs: 'MAX_WAIT_MS',
5
+ completedSegments: 'COMPLETED_SEGMENTS', concurrency: 'CONCURRENCY', timeoutMs: 'TIMEOUT_MS', statsIntervalMs: 'STATS_INTERVAL_MS' };
6
+
7
+ function loadPolicy(env) {
8
+ const policy = env.SEGMENTS_TX_POLICY_FILE ? JSON.parse(readFileSync(env.SEGMENTS_TX_POLICY_FILE, 'utf8')) : {};
9
+ if (env.SEGMENTS_TX_ENABLED !== undefined) {
10
+ if (!['true', 'false'].includes(env.SEGMENTS_TX_ENABLED)) { throw new TypeError('SEGMENTS_TX_ENABLED must be true or false'); }
11
+ policy.enabled = env.SEGMENTS_TX_ENABLED === 'true';
12
+ }
13
+ for (const [key, suffix] of Object.entries(variables)) {
14
+ const value = env[`SEGMENTS_TX_${suffix}`];
15
+ if (value !== undefined) { policy[key] = Number(value); }
16
+ }
17
+ return policy;
18
+ }
19
+ /** Plant policy supplies defaults; a ConfigMap can override every operational setting. */
20
+ export default function segmentTransactionConfig(env, poolMax) {
21
+ if (env.SEGMENTS_TX_ENABLED === 'false') { return { enabled: false }; }
22
+ if (!env.SEGMENTS_TX_POLICY_FILE && !Object.keys(env).some(key => {return key.startsWith('SEGMENTS_TX_')})) { return { enabled: false }; }
23
+ const result = segmentBatchPolicy(loadPolicy(env));
24
+ if (result.enabled && result.concurrency > poolMax) { throw new TypeError('Transaction concurrency exceeds SEGMENTS_DB_POOL_MAX'); }
25
+ return result;
26
+ }
@@ -1,4 +1,6 @@
1
1
  import ingestHealth from './ingestHealth.js';
2
+ import segmentTransactionConfig from './segmentTransactionConfig.js';
3
+ import segmentBatchStats from './segmentBatchStats.js';
2
4
  import segmentPipeline, {
3
5
  segmentsIngestDestination
4
6
  } from '../infrastructure/ingest/pipelines/segmentPipeline.js';
@@ -34,7 +36,9 @@ export function readDeploymentConfig(env) {
34
36
  if (!Number.isSafeInteger(prefetch) || prefetch < 1) {
35
37
  throw new Error('STOMP_PREFETCH must be a positive safe integer');
36
38
  }
39
+ const dbPoolMax = milliseconds(env, 'SEGMENTS_DB_POOL_MAX', 10);
37
40
  return {
41
+ dbPoolMax,
38
42
  stomp: env.STOMP_URL,
39
43
  postgres: env.POSTGRES_URL,
40
44
  postgresAdmin: env.POSTGRES_ADMIN_URL,
@@ -50,6 +54,9 @@ export function readDeploymentConfig(env) {
50
54
  requestTimeoutMs: parseRequestTimeoutMs(env.REQUEST_TIMEOUT_MS),
51
55
  pipelines: env.COLLECTOR_MODE !== 'external' && env.SINK_PIPELINES !== 'false',
52
56
  pipeline: {
57
+ transactions: segmentTransactionConfig(env, dbPoolMax),
58
+ maxMessageBytes: milliseconds(env, 'STOMP_MAX_MESSAGE_BYTES', 1048576),
59
+ maxPendingBytes: milliseconds(env, 'STOMP_MAX_PENDING_BYTES', prefetch * 1048576),
53
60
  prefetch,
54
61
  acceptTimeoutMs: milliseconds(env, 'STOMP_ACCEPT_TIMEOUT_MS', 10000),
55
62
  stopTimeoutMs: milliseconds(env, 'STOMP_STOP_TIMEOUT_MS', 5000),
@@ -87,7 +94,8 @@ export default function supervisorSink(env = process.env, options = {}) {
87
94
  const cfg = readDeploymentConfig(env);
88
95
  const health = ingestHealth(options);
89
96
  cfg.pipeline.onFatal = (error) => { health.fail(error); };
90
- const pool = holdClient(new pg.Pool({ connectionString: cfg.postgres }));
97
+ if (cfg.pipeline.transactions.enabled) { cfg.pipeline.onBatch = segmentBatchStats(cfg.pipeline.transactions.statsIntervalMs); }
98
+ const pool = holdClient(new pg.Pool({ connectionString: cfg.postgres, max: cfg.dbPoolMax }));
91
99
  const metricsEnabled = cfg.sinkDbProfile === 'edge';
92
100
  const dataAccess = stateDataFromPool(pool, { metricsEnabled });
93
101
  return {
@@ -9,6 +9,10 @@ import { replaceSplit } from './splitFamily.js';
9
9
  * @param {object} record - normalized segment record
10
10
  * @returns {Promise<void>}
11
11
  */
12
+ async function pendingInClient(segmentSink, closer, record, transaction) {
13
+ await closer.close(record.machine, record.start_time, record.kind || 'phase', transaction);
14
+ await segmentSink.write([record], transaction);
15
+ }
12
16
  async function persistPending(segmentSink, closer, pool, record) {
13
17
  const kind = record.kind || 'phase';
14
18
  if (!pool || typeof pool.connect !== 'function') {
@@ -47,36 +51,36 @@ async function persistPending(segmentSink, closer, pool, record) {
47
51
  * const route = segmentDispatch(segmentSink, retag, splitSink, closer, pool);
48
52
  * await route.accept({ type: 'segment', machine: 'm1', start_time: '...', duration: 0 });
49
53
  */
50
- async function acceptFamily(pool, record) {
51
- if (!pool || typeof pool.connect !== 'function') {
54
+ async function acceptFamily(pool, record, transaction) {
55
+ if (!transaction && (!pool || typeof pool.connect !== 'function')) {
52
56
  throw new Error('split-family requires a pool');
53
57
  }
54
- await replaceSplit(pool, record);
58
+ await replaceSplit(pool, record, transaction);
55
59
  }
56
60
 
57
61
  export default function segmentDispatch(segmentSink, retag, splitSink, closer, pool) {
58
62
  return {
59
- async accept(record) {
63
+ async accept(record, transaction) {
60
64
  if (record.type === 'retag') {
61
- await retag.accept(record);
65
+ await retag.accept(record, transaction);
62
66
  return;
63
67
  }
64
68
  if (record.type === 'split') {
65
- await splitSink.write([record]);
69
+ await splitSink.write([record], transaction);
66
70
  return;
67
71
  }
68
72
  if (record.type === 'split-family') {
69
- await acceptFamily(pool, record);
73
+ await acceptFamily(pool, record, transaction);
70
74
  return;
71
75
  }
72
76
  if (record.type !== 'segment') {
73
77
  throw new Error(`Segment command type ${record.type} is not defined`);
74
78
  }
75
79
  if (record.duration !== 0) {
76
- await segmentSink.write([record]);
80
+ await segmentSink.write([record], transaction);
77
81
  return;
78
82
  }
79
- await persistPending(segmentSink, closer, pool, record);
83
+ await (transaction ? pendingInClient(segmentSink, closer, record, transaction) : persistPending(segmentSink, closer, pool, record));
80
84
  }
81
85
  };
82
86
  }
@@ -241,8 +241,13 @@ export function splitRows(machine, splits) {
241
241
  * @param {object} record - machine, origin millis, pieces
242
242
  * @returns {Promise<void>}
243
243
  */
244
- export async function replaceSplit(pool, record) {
244
+ export async function replaceSplit(pool, record, transaction) {
245
245
  refuseEmpty(record);
246
+ if (transaction) {
247
+ await removeObsolete(transaction, record);
248
+ await writePieces(transaction, record.pieces);
249
+ return;
250
+ }
246
251
  const client = await pool.connect();
247
252
  try {
248
253
  await client.query('BEGIN');
@@ -25,28 +25,25 @@ function familyRecord(parsed) {
25
25
  };
26
26
  }
27
27
 
28
+ /** Decode once before admission so invalid envelopes never enter a transaction batch. */
29
+ export function decodeSegment(raw) {
30
+ try {
31
+ const parsed = JSON.parse(raw.payload);
32
+ return parsed.type === 'split-family' ? familyRecord(parsed) : segmentNormalize(parsed);
33
+ } catch (error) {
34
+ error.permanent = true;
35
+ throw error;
36
+ }
37
+ }
38
+
28
39
  export default function segmentCodec(collector) {
29
40
  if (!collector || typeof collector.accept !== 'function') {
30
41
  throw new Error('Collector must have an accept() method');
31
42
  }
32
43
  return {
33
44
  async accept(raw) {
34
- try {
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
- }
44
- if (parsed.type === 'split-family') {
45
- await collector.accept(record);
46
- return;
47
- }
48
- await collector.accept(record);
49
- } catch (error) {
45
+ try { await collector.accept(decodeSegment(raw)); }
46
+ catch (error) {
50
47
  processingErrorLog('segment_codec', error, { destination: raw.destination, payload: raw.payload });
51
48
  throw error;
52
49
  }
@@ -0,0 +1,157 @@
1
+ import deadline from '../../streams/deadline.js';
2
+ import segmentBatchPolicy from './segmentBatchPolicy.js';
3
+
4
+ function notify(callback, value) {
5
+ try { callback?.(value); } catch {
6
+ // Observers cannot change commit or fencing semantics.
7
+ }
8
+ }
9
+ function rejectEntries(entries, error) {
10
+ for (const entry of entries) {
11
+ for (const receipt of entry.receipts) {
12
+ if (!receipt.done) { receipt.done = true;receipt.reject(error); }
13
+ }
14
+ }
15
+ }
16
+ function abort(state, error) {
17
+ if (state.stopped) { return; }
18
+ state.stopped = true;
19
+ for (const lane of state.lanes.values()) {
20
+ for (const packet of lane.packets) { clearTimeout(packet.timer);rejectEntries(packet.entries, error); }
21
+ lane.packets = [];
22
+ }
23
+ for (const packet of state.inflight) { rejectEntries(packet.entries, error); }
24
+ state.ready = [];
25
+ notify(state.onAbort, error);
26
+ }
27
+ function fail(state, error) {
28
+ if (state.failed || state.stopped) { return; }
29
+ state.failed = error;abort(state, error);notify(state.onFatal, error);
30
+ }
31
+ function ackReceipt(state, receipt) {
32
+ if (!receipt.done) {
33
+ receipt.done = true;
34
+ try { if (!state.stopped) { receipt.settle.ack(); } }
35
+ catch (error) { fail(state, error); }
36
+ receipt.resolve();
37
+ }
38
+ }
39
+ function acknowledge(state, entries) {
40
+ for (const entry of entries) { entry.receipts.forEach(receipt => { ackReceipt(state, receipt); }); }
41
+ }
42
+ function nackReceipt(state, receipt, error) {
43
+ if (!receipt.done) {
44
+ receipt.done = true;
45
+ try { if (!state.stopped) { receipt.settle.nack(!(state.deadLetter && error.permanent)); } }
46
+ catch (failure) { fail(state, failure); }
47
+ receipt.reject(error);
48
+ }
49
+ }
50
+ function refuse(state, entry, error) { entry.receipts.forEach(receipt => { nackReceipt(state, receipt, error); }); }
51
+ function completedKeys(record) {
52
+ const rows = record.type === 'split-family' ? record.pieces : [record];
53
+ return rows.filter(row => {return ['segment', 'split'].includes(row.type) && row.duration > 0})
54
+ .map(row => {return `${row.machine}\0${row.kind}\0${row.start_time}`});
55
+ }
56
+ function queueReady(state, lane) {
57
+ if (!state.stopped && !lane.running && !lane.queued && lane.packets[0]?.ready) {
58
+ lane.queued = true;state.ready.push(lane);state.pump();
59
+ }
60
+ }
61
+ function ready(state, lane, packet, reason) {
62
+ if (state.stopped || packet.ready) { return; }
63
+ packet.ready = true;packet.reason = reason;clearTimeout(packet.timer);queueReady(state, lane);
64
+ }
65
+ function packetFor(state, lane, bytes) {
66
+ let packet = lane.packets.at(-1);
67
+ if (packet && !packet.ready && packet.bytes + bytes > state.policy.maxBytes) { ready(state, lane, packet, 'bytes'); }
68
+ packet = lane.packets.at(-1);
69
+ if (!packet || packet.ready) {
70
+ packet = { entries: [], messages: 0, bytes: 0, completed: new Set(), ready: false, createdAt: Date.now() };
71
+ const pending = packet;
72
+ packet.timer = setTimeout(() => { ready(state, lane, pending, 'age'); }, state.policy.maxWaitMs);
73
+ lane.packets.push(packet);
74
+ }
75
+ return packet;
76
+ }
77
+ function append(state, lane, input, receipt) {
78
+ const packet = packetFor(state, lane, input.bytes);
79
+ packet.entries.push({ record: input.record, receipts: [receipt] });
80
+ packet.messages += 1;packet.bytes += input.bytes;
81
+ for (const key of completedKeys(input.record)) { packet.completed.add(key); }
82
+ if (packet.messages >= state.policy.maxMessages) { ready(state, lane, packet, 'messages'); }
83
+ else if (packet.bytes >= state.policy.maxBytes) { ready(state, lane, packet, 'bytes'); }
84
+ else if (state.policy.completedSegments > 0 && packet.completed.size >= state.policy.completedSegments) { ready(state, lane, packet, 'segments'); }
85
+ }
86
+ async function persist(state, entries, packet) {
87
+ const start = Date.now();
88
+ const result = await state.write(entries.map(entry => {return entry.record}));
89
+ acknowledge(state, entries);
90
+ notify(state.onBatch, { committed: true, messages: entries.reduce((sum, entry) => {return sum + entry.receipts.length}, 0),
91
+ records: entries.length, durationMs: Date.now() - start, waitMs: start - packet.createdAt,
92
+ reason: packet.reason, sqlCalls: result?.sqlCalls ?? 0 });
93
+ }
94
+ async function persistOne(state, entry, packet) {
95
+ if (state.stopped) { throw state.failed ?? new Error('Segment batches stopped'); }
96
+ try { await persist(state, [entry], packet); }
97
+ catch (failure) {
98
+ if (!failure.permanent) { throw failure; }
99
+ refuse(state, entry, failure);
100
+ }
101
+ }
102
+ async function runPacket(state, packet) {
103
+ try { await persist(state, packet.entries, packet); }
104
+ catch (error) {
105
+ if (!error.permanent || state.stopped) { throw error; }
106
+ notify(state.onBatch, { committed: false, messages: packet.messages, records: packet.entries.length, reason: 'rollback' });
107
+ // Confirmed permanent failure: isolate the bad command after the batch rollback.
108
+ for (const entry of packet.entries) {
109
+ // eslint-disable-next-line no-await-in-loop
110
+ await persistOne(state, entry, packet);
111
+ }
112
+ }
113
+ }
114
+ function startPacket(state, lane) {
115
+ const packet = lane.packets.shift();
116
+ lane.running = true;state.active += 1;state.inflight.add(packet);
117
+ const task = Promise.resolve().then(() => { return runPacket(state, packet); }).catch(error => { fail(state, error); }).finally(() => {
118
+ state.active -= 1;lane.running = false;state.tasks.delete(task);state.inflight.delete(packet);
119
+ if (lane.packets.length === 0) { state.lanes.delete(lane.machine); }
120
+ else { queueReady(state, lane); }
121
+ state.pump();
122
+ });
123
+ state.tasks.add(task);
124
+ }
125
+ function pump(state) {
126
+ while (!state.stopped && state.active < state.policy.concurrency && state.ready.length > 0) {
127
+ const lane = state.ready.shift();lane.queued = false;
128
+ if (!lane.running && lane.packets[0]?.ready) { startPacket(state, lane); }
129
+ }
130
+ }
131
+
132
+ /** Bounded FIFO machine lanes; each accept retains its lease until commit or rejection. */
133
+ export default function segmentBatch(write, options = {}) {
134
+ const { prefetch = 32, onFatal, onAbort, onBatch, deadLetter = false, stopTimeoutMs = 5000, ...settings } = options;
135
+ const policy = segmentBatchPolicy(settings);
136
+ if (!Number.isSafeInteger(prefetch) || prefetch < 1) { throw new TypeError('prefetch must be a positive integer'); }
137
+ policy.maxMessages = Math.min(policy.maxMessages, prefetch);
138
+ const state = { policy, write, onFatal, onAbort, onBatch, deadLetter,
139
+ lanes: new Map(), ready: [], active: 0, tasks: new Set(), inflight: new Set(), stopped: false, failed: null };
140
+ state.pump = () => { pump(state); };
141
+ return {
142
+ accept(input) {
143
+ if (state.stopped) { return Promise.reject(state.failed ?? new Error('Segment batches stopped')); }
144
+ return new Promise((resolve, reject) => {
145
+ const receipt = { settle: input.settle, resolve, reject, done: false };
146
+ let lane = state.lanes.get(input.record.machine);
147
+ if (!lane) { lane = { machine: input.record.machine, packets: [], running: false, queued: false };state.lanes.set(lane.machine, lane); }
148
+ append(state, lane, input, receipt);
149
+ });
150
+ },
151
+ abort(error = new Error('Segment batches stopped')) { abort(state, error); },
152
+ stop() {
153
+ abort(state, new Error('Segment batches stopped'));
154
+ return deadline(Promise.allSettled([...state.tasks]), stopTimeoutMs, 'Segment batch stop');
155
+ }
156
+ };
157
+ }
@@ -0,0 +1,23 @@
1
+ const defaults = { enabled: true, maxMessages: 32, maxBytes: 1048576, maxWaitMs: 100,
2
+ completedSegments: 1, concurrency: 2, timeoutMs: 10000, statsIntervalMs: 10000 };
3
+
4
+ function checkLimits(policy) {
5
+ for (const key of ['maxMessages', 'maxBytes', 'maxWaitMs', 'concurrency', 'timeoutMs', 'completedSegments', 'statsIntervalMs']) {
6
+ const minimum = ['completedSegments', 'statsIntervalMs'].includes(key) ? 0 : 1;
7
+ if (!Number.isSafeInteger(policy[key]) || policy[key] < minimum) { throw new TypeError(`${key} must be a bounded integer >= ${minimum}`); }
8
+ }
9
+ for (const key of ['maxWaitMs', 'timeoutMs', 'statsIntervalMs']) {
10
+ if (policy[key] > 2147483647) { throw new TypeError(`${key} exceeds the timer limit`); }
11
+ }
12
+ }
13
+
14
+ /** Finite batch limits shared by environment loading and direct collector composition. */
15
+ export default function segmentBatchPolicy(input = {}) {
16
+ const unknown = Object.keys(input).find(key => { return !Object.hasOwn(defaults, key); });
17
+ if (unknown) { throw new TypeError(`Unknown segment transaction setting: ${unknown}`); }
18
+ const policy = { ...defaults, ...input };
19
+ if (typeof policy.enabled !== 'boolean') { throw new TypeError('enabled must be a boolean'); }
20
+ if (!policy.enabled) { return { enabled: false }; }
21
+ checkLimits(policy);
22
+ return policy;
23
+ }
@@ -1,4 +1,7 @@
1
1
  import stompOptions from './stompOptions.js';
2
+ import segmentTransactions from './segmentTransactions.js';
3
+ import segmentBatchPolicy from './segmentBatchPolicy.js';
4
+ import stopSegmentPipeline from './stopSegmentPipeline.js';
2
5
  import deadline from '../../streams/deadline.js';
3
6
  import { postgresSink, stompSource } from '../../streams/index.js';
4
7
  import segmentDispatch from '../../../domain/segment/dispatch.js';
@@ -53,7 +56,8 @@ function segmentSinks(postgres, pool) {
53
56
  * @returns {object} Pipeline with start() and stop() methods
54
57
  */
55
58
  export default function segmentPipeline(stomp, postgres, pool, config) {
56
- const budget = silenceBudget(config.segmentWindow || 15);
59
+ const settings = config.transactions?.enabled ? { ...config, transactions: segmentBatchPolicy(config.transactions) } : config;
60
+ const budget = silenceBudget(settings.segmentWindow || 15);
57
61
  const { segmentSink, splitSink } = segmentSinks(postgres, pool);
58
62
  const continued = continuedSegments(segmentSink, pool, budget);
59
63
  const dispatch = segmentDispatch(continued, retagSink(pool), splitSink, closeOrphanOpen(pool), pool);
@@ -62,21 +66,24 @@ export default function segmentPipeline(stomp, postgres, pool, config) {
62
66
  const codec = segmentCodec(dispatch);
63
67
  const bounded = { accept(envelope) {
64
68
  return deadline(Promise.resolve().then(() => { return codec.accept(envelope); }),
65
- config.acceptTimeoutMs ?? 10000, 'STOMP acceptance', (error) => { source.fail(error); });
69
+ settings.acceptTimeoutMs ?? 10000, 'STOMP acceptance', (error) => { source.fail(error); });
66
70
  } };
67
71
  // Heartbeat timer flushes outlive source.accept and need their own execution deadline.
68
- const coalesce = segmentCoalesce(bounded, {
69
- size: config.size,
70
- deadLetter: config.deadLetter,
71
- interval: config.interval
72
+ const coalesce = settings.transactions?.enabled ? segmentTransactions(dispatch, pool, settings, error => { source.fail(error); }) : segmentCoalesce(bounded, {
73
+ size: settings.size,
74
+ deadLetter: settings.deadLetter,
75
+ interval: settings.interval
72
76
  });
73
- const destination = config.segmentsDestination || segmentsIngestDestination;
77
+ const destination = settings.segmentsDestination || segmentsIngestDestination;
74
78
  // Each admitted frame can flush one heartbeat and then persist itself; prefetch bounds queue depth.
75
- const deliveryTimeoutMs = Math.min(2147483647, (config.acceptTimeoutMs ?? 10000) * (config.prefetch ?? 32) * 2);
79
+ const executionMs = settings.transactions?.enabled ? settings.transactions.timeoutMs : settings.acceptTimeoutMs ?? 10000;
80
+ const deliveryTimeoutMs = Math.min(2147483647, executionMs * (settings.prefetch ?? 32) * 2 + (settings.transactions?.maxWaitMs ?? 0));
76
81
  source = stompSource(stomp, destination, coalesce,
77
- stompOptions(config, { serial: false, manualAck: true, deliveryTimeoutMs }));
82
+ stompOptions(settings, { serial: false, manualAck: true, deliveryTimeoutMs, onFatal(error) {
83
+ coalesce.abort?.(error);settings.onFatal?.(error);
84
+ } }));
78
85
  const silence = silentOpenWatch(closeSilentOpen(pool), budget, {
79
- intervalMs: (config.poll || 5) * 1000
86
+ intervalMs: (settings.poll || 5) * 1000
80
87
  });
81
88
  return {
82
89
  health() { return source.health(); },
@@ -89,10 +96,8 @@ export default function segmentPipeline(stomp, postgres, pool, config) {
89
96
  await source.start();
90
97
  await silence.start();
91
98
  },
92
- async stop() {
93
- await silence.stop();
94
- await source.stop();
95
- await pool.end();
99
+ stop() {
100
+ return stopSegmentPipeline(source, coalesce, silence, pool, settings.stopTimeoutMs ?? 5000);
96
101
  }
97
102
  };
98
103
  }
@@ -0,0 +1,25 @@
1
+ import segmentBatch from './segmentBatch.js';
2
+ import segmentTransaction from '../sinks/segmentTransaction.js';
3
+ import { decodeSegment } from '../codecs/segmentCodec.js';
4
+
5
+ /** Decode before enqueue; every command and receipt remains ordered until durable commit. */
6
+ export default function segmentTransactions(dispatch, pool, config, onFatal) {
7
+ const options = config.transactions;
8
+ const writer = segmentTransaction(pool, dispatch, { timeoutMs: options.timeoutMs, onFatal });
9
+ const batch = segmentBatch(records => { return writer.write(records); }, {
10
+ ...options, prefetch: config.prefetch, deadLetter: config.deadLetter,
11
+ stopTimeoutMs: config.stopTimeoutMs, onFatal, onAbort(error) { writer.abort(error); }, onBatch: config.onBatch
12
+ });
13
+ return {
14
+ accept(envelope) {
15
+ try {
16
+ return batch.accept({ record: decodeSegment(envelope), settle: envelope.settle, bytes: Buffer.byteLength(envelope.payload) });
17
+ } catch (error) {
18
+ envelope.settle.nack(!(config.deadLetter && error.permanent));
19
+ return Promise.reject(error);
20
+ }
21
+ },
22
+ abort(error) { batch.abort(error); },
23
+ stop() { return batch.stop(); }
24
+ };
25
+ }
@@ -0,0 +1,18 @@
1
+ import deadline from '../../streams/deadline.js';
2
+
3
+ function attempt(action) {
4
+ try { return Promise.resolve(action()); } catch (error) { return Promise.reject(error); }
5
+ }
6
+ /** Every cleanup runs even if another component throws or cannot drain. */
7
+ export default async function stopSegmentPipeline(source, collector, silence, pool, timeoutMs = 5000) {
8
+ const end = performance.now() + timeoutMs;
9
+ const stops = [attempt(() => {return source.stop()}), attempt(() => {return collector.abort?.()}),
10
+ attempt(() => {return silence.stop()}), attempt(() => {return collector.stop?.()})];
11
+ try {
12
+ const results = await deadline(Promise.allSettled(stops), timeoutMs, 'Segment pipeline stop');
13
+ const failed = results.find(result => {return result.status === 'rejected'});
14
+ if (failed) { throw failed.reason; }
15
+ } finally {
16
+ await deadline(attempt(() => {return pool.end()}), Math.max(1, end - performance.now()), 'Segment pool close');
17
+ }
18
+ }
@@ -26,11 +26,11 @@ export default function retagSink(pool) {
26
26
  *
27
27
  * @param {object} record - Record with machine, start_time, tags, properties
28
28
  */
29
- async accept({ machine, start_time: startTime, tags, properties, options, resolved }) {
29
+ async accept({ machine, start_time: startTime, tags, properties, options, resolved }, transaction) {
30
30
  try {
31
31
  const opts = options === undefined || options === null ? null : options;
32
32
  const flag = typeof resolved === 'boolean' ? resolved : true;
33
- await pool.query(
33
+ await (transaction || pool).query(
34
34
  'UPDATE segments SET tags = $1, properties = $2, options = $3, resolved = $4 WHERE machine = $5 AND start_time = $6',
35
35
  [tags, properties, opts, flag, machine, startTime]
36
36
  );
@@ -0,0 +1,76 @@
1
+ import deadline from '../../streams/deadline.js';
2
+
3
+ function release(token, destroy = false) {
4
+ if (token.client && !token.released) {
5
+ token.released = true;
6
+ if (token.onError) { token.client.removeListener?.('error', token.onError); }
7
+ token.client.release(destroy);
8
+ }
9
+ }
10
+ function permanent(error) {
11
+ return error.permanent === true || error instanceof RangeError || /^(?:22|23)/u.test(error.code ?? '');
12
+ }
13
+ async function prepare(pool, state, token) {
14
+ token.client = await pool.connect();
15
+ if (token.cancelled || state.stopped) { throw state.failure ?? new Error('Segment transaction stopped'); }
16
+ token.onError = error => { state.fail(error); };
17
+ token.client.on?.('error', token.onError);
18
+ await token.runner.query('BEGIN');
19
+ await token.runner.query("SELECT set_config('statement_timeout', $1, true)", [String(state.timeoutMs)]);
20
+ }
21
+ async function rollback(token) {
22
+ if (!token.client || token.cancelled) { return false; }
23
+ try { await token.runner.query('ROLLBACK');return true; }
24
+ catch {
25
+ // Unknown transaction outcome: the owner fences and destroys the connection.
26
+ return false;
27
+ }
28
+ }
29
+ async function apply(dispatch, records, token) {
30
+ for (const record of records) {
31
+ // eslint-disable-next-line no-await-in-loop
32
+ await dispatch.accept(record, token.runner);
33
+ }
34
+ }
35
+ async function execute(pool, dispatch, records, state, token) {
36
+ let phase = 'prepare';
37
+ try {
38
+ await prepare(pool, state, token);
39
+ phase = 'apply';await apply(dispatch, records, token);
40
+ phase = 'commit';await token.runner.query('COMMIT');
41
+ return { sqlCalls: token.calls };
42
+ } catch (error) {
43
+ const confirmed = await rollback(token);
44
+ if (confirmed && phase !== 'prepare' && permanent(error)) { error.permanent = true; }
45
+ else if (!state.stopped && !token.cancelled) { state.fail(error); }
46
+ throw error;
47
+ } finally {
48
+ state.active.delete(token);
49
+ release(token, token.cancelled || state.stopped);
50
+ }
51
+ }
52
+
53
+ /** Owns one durable transaction per batch, including late-connect and timeout fencing. */
54
+ export default function segmentTransaction(pool, dispatch, { timeoutMs = 10000, onFatal } = {}) {
55
+ const state = { stopped: false, failure: null, active: new Set(), timeoutMs,
56
+ fail(error) {
57
+ if (state.failure || state.stopped) { return; }
58
+ state.failure = error;onFatal?.(error);
59
+ } };
60
+ return {
61
+ write(records) {
62
+ if (state.stopped || state.failure) { return Promise.reject(state.failure ?? new Error('Segment transaction stopped')); }
63
+ const token = { client: null, released: false, cancelled: false, calls: 0 };
64
+ token.runner = { query(...args) { token.calls += 1;return token.client.query(...args); } };
65
+ state.active.add(token);
66
+ return deadline(execute(pool, dispatch, records, state, token), timeoutMs, 'Segment transaction', error => {
67
+ token.cancelled = true;
68
+ try { release(token, true); } finally { state.fail(error); }
69
+ });
70
+ },
71
+ abort(error = new Error('Segment transaction stopped')) {
72
+ state.stopped = true;state.failure ??= error;
73
+ for (const token of state.active) { token.cancelled = true;release(token, true); }
74
+ }
75
+ };
76
+ }
@@ -56,7 +56,10 @@ export function telemetryConsumer(codec, channel, onSeen) {
56
56
  */
57
57
  export default function amqpMetricsIngest(amqpUrl, queue, sink, options = {}) {
58
58
  const prefetch = options.prefetch || 32;
59
- const collector = ingestBatch(sink, options);
59
+ // Unacknowledged deliveries occupy the broker window until the batch commits.
60
+ // A larger batch can never fill and would throttle ingestion to the flush timer.
61
+ const size = Math.min(options.size ?? 100, prefetch);
62
+ const collector = ingestBatch(sink, { ...options, size });
60
63
  const codec = metricsCodec(collector);
61
64
  const session = amqpSource(amqpUrl, queue, {
62
65
  accept(delivery) {
@@ -92,9 +92,7 @@ async function deliver(ctx, body) {
92
92
  settle.ack();
93
93
  }
94
94
  } catch (error) {
95
- if (!ctx.manualAck) {
96
- settle.nack();
97
- }
95
+ settle.nack();
98
96
  throw error;
99
97
  }
100
98
  }