@yarkivaev/scada 2.3.78 → 2.3.81

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
@@ -83,6 +83,16 @@ edgeApi (:8081) ← metrics batch, checkpoints, retention (edge profile)
83
83
 
84
84
  **Edge site** (`siteServer`): runs supervisor-sink HTTP, plant API, and optional MQTT ingest in one process. Metrics land in PostgreSQL on edge; on central they go to ClickHouse.
85
85
 
86
+ ## Split family
87
+
88
+ One `off` phase can be replaced by contiguous pieces. There is no parent row and no nested segment. Each piece is a normal `segments` row: `kind = phase`, `name = off`, at most one tag. An empty tag is SQL NULL. The family key is `properties.split_of`, the original start in epoch milliseconds. The first piece keeps that `start_time`, so the existing unique key `(machine, kind, start_time)` still identifies it.
89
+
90
+ A later edit sends the whole family again. `timeline.respond` / `PATCH /segments` take `{ splits: [{ start, end, tag, open }] }` addressed by the original start. The supervisor publishes one STOMP body, `type: split-family`, with `origin` in epoch millis and the pieces. The sink deletes family starts the new cut does not use, then upserts the pieces, in one transaction.
91
+
92
+ The replacement must keep the original start and, for a closed off, the latest end of the stored family. Removing every piece but one therefore restores those bounds. `splits: []` is refused before any delete, and the stored family stays. A closed piece is at least 60 seconds. Only the tail of a still-open off may have `duration = 0`. A duration-0 heartbeat does not extend a closed piece that already has `split_of`.
93
+
94
+ `C0007` grants `DELETE` on `segments` to `supervisor_sink` on the privileged admin path. The sink role does not run that grant itself. Central previously revoked this privilege (`C0002`).
95
+
86
96
  ## Public exports
87
97
 
88
98
  Main entry (`import { … } from '@yarkivaev/scada'`):
@@ -172,7 +182,7 @@ SQL files in `db/migrations/` use profile prefixes:
172
182
  |--------|------------|
173
183
  | `V*` | Both central and edge |
174
184
  | `E*` | Edge only (metrics tables, retention) |
175
- | `C*` | Central only (revoke delete on metrics) |
185
+ | `C*` | Central only (central privileges, including segment DELETE) |
176
186
 
177
187
  Set `SINK_DB_PROFILE=central|edge`. Migrations run at supervisor-sink startup via `POSTGRES_ADMIN_URL`.
178
188
 
@@ -0,0 +1 @@
1
+ GRANT DELETE ON segments TO supervisor_sink;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yarkivaev/scada",
3
- "version": "2.3.78",
3
+ "version": "2.3.81",
4
4
  "description": "SCADA domain objects, state persistence, and plant monitoring",
5
5
  "repository": {
6
6
  "type": "git",
@@ -6,6 +6,7 @@ import memoryTimelineStore, { memoryTimelineRead } from '../infrastructure/persi
6
6
  import ownerTimeline from '../infrastructure/messaging/ownership/ownerTimeline.js';
7
7
  import stompTimeline from '../infrastructure/messaging/stomp/timeline.js';
8
8
  import retagBody from '../infrastructure/messaging/stomp/retagBody.js';
9
+ import { checkSplit, familyCover, loadFamily, replaceSplit, splitRows } from '../domain/segment/splitFamily.js';
9
10
 
10
11
  function parseStart(requestId) {
11
12
  const raw = decodeURIComponent(String(requestId));
@@ -92,6 +93,28 @@ async function persistTags(pool, patch) {
92
93
  return state.rowAt(patch.machine, patch.start);
93
94
  }
94
95
 
96
+ function asPiece(piece) {
97
+ return {
98
+ start: new Date(piece.start),
99
+ end: new Date(piece.end),
100
+ tag: piece.tag || '',
101
+ open: Boolean(piece.open)
102
+ };
103
+ }
104
+
105
+ async function applySplit(scope) {
106
+ if (ownsLocal(scope.owners, scope.machine)) {
107
+ const ordered = checkSplit(familyCover(await loadFamily(scope.pool, scope.machine, scope.start)), scope.splits);
108
+ const pieces = splitRows(scope.machine, ordered);
109
+ await replaceSplit(scope.pool, {
110
+ machine: scope.machine,
111
+ origin: ordered[0].start.getTime(),
112
+ pieces
113
+ });
114
+ }
115
+ await scope.write.respondSplit(scope.start, scope.splits.map(asPiece), scope.audit);
116
+ }
117
+
95
118
  async function confirm(pool, patch, segments) {
96
119
  const row = await persistTags(pool, patch);
97
120
  if (segments) {
@@ -100,6 +123,35 @@ async function confirm(pool, patch, segments) {
100
123
  return row;
101
124
  }
102
125
 
126
+ async function answer(scope, requestId, body, audit) {
127
+ const start = parseStart(requestId);
128
+ if (Array.isArray(body.splits)) {
129
+ await applySplit({
130
+ pool: scope.pool,
131
+ owners: scope.owners,
132
+ machine: scope.name,
133
+ start,
134
+ splits: body.splits,
135
+ write: scope.write,
136
+ audit
137
+ });
138
+ scope.bus.emit({ type: 'resolved', request: { id: requestId, start }, audit });
139
+ return { id: requestId, ...body };
140
+ }
141
+ if (ownsLocal(scope.owners, scope.name)) {
142
+ await confirm(scope.pool, {
143
+ machine: scope.name,
144
+ start,
145
+ tags: body.tags,
146
+ properties: body.properties || {},
147
+ resolved: true
148
+ }, scope.segments);
149
+ }
150
+ await scope.write.respond(start, body.tags, body.properties || {}, audit);
151
+ scope.bus.emit({ type: 'resolved', request: { id: requestId, start }, audit });
152
+ return { id: requestId, ...body };
153
+ }
154
+
103
155
  /**
104
156
  * Builds a machine timeline from PostgreSQL persistence and STOMP user decisions.
105
157
  *
@@ -139,20 +191,8 @@ export default function shopWithTimeline(name, options) {
139
191
  await write.retag(start, tags, properties, audit);
140
192
  bus.emit({ type: 'resolved', segment: resolvedStub(start, tags, properties), audit });
141
193
  },
142
- async respond(requestId, body, audit) {
143
- const start = parseStart(requestId);
144
- if (ownsLocal(owners, name)) {
145
- await confirm(pool, {
146
- machine: name,
147
- start,
148
- tags: body.tags,
149
- properties: body.properties || {},
150
- resolved: true
151
- }, segments);
152
- }
153
- await write.respond(start, body.tags, body.properties || {}, audit);
154
- bus.emit({ type: 'resolved', request: { id: requestId, start }, audit });
155
- return { id: requestId, ...body };
194
+ respond(requestId, body, audit) {
195
+ return answer({ pool, owners, name, segments, write, bus }, requestId, body, audit);
156
196
  }
157
197
  };
158
198
  }
@@ -1,3 +1,5 @@
1
+ import { replaceSplit } from './splitFamily.js';
2
+
1
3
  /**
2
4
  * Persists a pending heartbeat, optionally inside one pool transaction.
3
5
  *
@@ -45,6 +47,13 @@ async function persistPending(segmentSink, closer, pool, record) {
45
47
  * const route = segmentDispatch(segmentSink, retag, splitSink, closer, pool);
46
48
  * await route.accept({ type: 'segment', machine: 'm1', start_time: '...', duration: 0 });
47
49
  */
50
+ async function acceptFamily(pool, record) {
51
+ if (!pool || typeof pool.connect !== 'function') {
52
+ throw new Error('split-family requires a pool');
53
+ }
54
+ await replaceSplit(pool, record);
55
+ }
56
+
48
57
  export default function segmentDispatch(segmentSink, retag, splitSink, closer, pool) {
49
58
  return {
50
59
  async accept(record) {
@@ -56,6 +65,10 @@ export default function segmentDispatch(segmentSink, retag, splitSink, closer, p
56
65
  await splitSink.write([record]);
57
66
  return;
58
67
  }
68
+ if (record.type === 'split-family') {
69
+ await acceptFamily(pool, record);
70
+ return;
71
+ }
59
72
  if (record.type !== 'segment') {
60
73
  throw new Error(`Segment command type ${record.type} is not defined`);
61
74
  }
@@ -0,0 +1,263 @@
1
+ const MIN_MS = 60000;
2
+
3
+ function dated(piece) {
4
+ return {
5
+ start: new Date(piece.start),
6
+ end: new Date(piece.end),
7
+ tag: piece.tag || '',
8
+ open: Boolean(piece.open)
9
+ };
10
+ }
11
+
12
+ function byStart(left, right) {
13
+ return left.start - right.start;
14
+ }
15
+
16
+ function hasTag(piece) {
17
+ return Boolean(piece.tag);
18
+ }
19
+
20
+ function refuseGap(ordered, piece, index) {
21
+ const last = index === ordered.length - 1;
22
+ if (piece.open && !last) {
23
+ throw new RangeError('open piece is not the tail');
24
+ }
25
+ if (index > 0 && ordered[index - 1].end.getTime() !== piece.start.getTime()) {
26
+ throw new RangeError('split has a gap');
27
+ }
28
+ }
29
+
30
+ function refuseSpan(piece) {
31
+ const span = piece.end.getTime() - piece.start.getTime();
32
+ if (!piece.open && span < MIN_MS) {
33
+ throw new RangeError('piece is shorter than 60 seconds');
34
+ }
35
+ if (piece.open && span < 0) {
36
+ throw new RangeError('open tail ends before it starts');
37
+ }
38
+ }
39
+
40
+ function refuseCover(parent, tail) {
41
+ if (parent.duration === 0) {
42
+ return;
43
+ }
44
+ const end = new Date(parent.end_time).getTime();
45
+ if (tail.open || tail.end.getTime() !== end) {
46
+ throw new RangeError('closed off is not covered through its end');
47
+ }
48
+ }
49
+
50
+ function orderedPieces(parent, splits) {
51
+ if (!parent || parent.name !== 'off') {
52
+ throw new RangeError(`refusing to split ${parent && parent.name}`);
53
+ }
54
+ if (!Array.isArray(splits) || splits.length === 0) {
55
+ throw new RangeError('refusing an empty split');
56
+ }
57
+ const ordered = splits.map(dated).sort(byStart);
58
+ if (ordered[0].start.getTime() !== new Date(parent.start_time).getTime()) {
59
+ throw new RangeError('split must keep the original start');
60
+ }
61
+ if (!ordered.some(hasTag)) {
62
+ throw new RangeError('split has no tagged piece');
63
+ }
64
+ return ordered;
65
+ }
66
+
67
+ function visit(ordered, piece, index) {
68
+ refuseGap(ordered, piece, index);
69
+ refuseSpan(piece);
70
+ }
71
+
72
+ function rowOf(machine, origin, piece) {
73
+ return {
74
+ type: 'split',
75
+ machine,
76
+ name: 'off',
77
+ kind: 'phase',
78
+ start_time: piece.start.toISOString(),
79
+ end_time: piece.end.toISOString(),
80
+ duration: piece.open ? 0 : (piece.end.getTime() - piece.start.getTime()) / 1000,
81
+ options: null,
82
+ tags: piece.tag ? JSON.stringify([piece.tag]) : null,
83
+ properties: JSON.stringify({ split_of: origin }),
84
+ resolved: true,
85
+ updated_at: new Date().toISOString()
86
+ };
87
+ }
88
+
89
+ function startOf(piece) {
90
+ return piece.start_time;
91
+ }
92
+
93
+ function writePiece(client, piece) {
94
+ return client.query(
95
+ `INSERT INTO segments
96
+ (machine, kind, name, start_time, end_time, duration, options, tags, properties, resolved, updated_at)
97
+ VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11)
98
+ ON CONFLICT (machine, kind, start_time) DO UPDATE SET
99
+ name = EXCLUDED.name,
100
+ end_time = EXCLUDED.end_time,
101
+ duration = EXCLUDED.duration,
102
+ options = EXCLUDED.options,
103
+ tags = EXCLUDED.tags,
104
+ properties = EXCLUDED.properties,
105
+ resolved = EXCLUDED.resolved,
106
+ updated_at = EXCLUDED.updated_at`,
107
+ [piece.machine, piece.kind, piece.name, piece.start_time, piece.end_time, piece.duration,
108
+ piece.options, piece.tags, piece.properties, piece.resolved, piece.updated_at]
109
+ );
110
+ }
111
+
112
+ function nextPiece(client, chain, piece) {
113
+ return chain.then(() => {
114
+ return writePiece(client, piece);
115
+ });
116
+ }
117
+
118
+ async function writePieces(client, pieces) {
119
+ await pieces.reduce((chain, piece) => {
120
+ return nextPiece(client, chain, piece);
121
+ }, Promise.resolve());
122
+ }
123
+
124
+ function openRow(row) {
125
+ return row.duration === 0 || row.duration === '0';
126
+ }
127
+
128
+ function byRowStart(left, right) {
129
+ return new Date(left.start_time) - new Date(right.start_time);
130
+ }
131
+
132
+ function widen(span, row) {
133
+ const end = new Date(row.end_time).getTime();
134
+ if (row.name !== 'off') {
135
+ throw new RangeError(`refusing to split ${row.name}`);
136
+ }
137
+ return {
138
+ end: end > span.end ? end : span.end,
139
+ pending: span.pending || openRow(row)
140
+ };
141
+ }
142
+
143
+ function refuseEmpty(record) {
144
+ if (!record || !Array.isArray(record.pieces) || record.pieces.length === 0) {
145
+ throw new RangeError('refusing an empty split');
146
+ }
147
+ }
148
+
149
+ async function removeObsolete(client, record) {
150
+ const starts = record.pieces.map(startOf);
151
+ const origin = new Date(Number(record.origin)).toISOString();
152
+ await client.query(
153
+ `DELETE FROM segments
154
+ WHERE machine = $1 AND kind = 'phase'
155
+ AND (start_time = $2 OR (properties IS NOT NULL AND properties::jsonb ->> 'split_of' = $3))
156
+ AND NOT (start_time = ANY($4::timestamptz[]))`,
157
+ [record.machine, origin, String(record.origin), starts]
158
+ );
159
+ }
160
+
161
+ /**
162
+ * Reads every phase row of one split family. The key is the original
163
+ * start: the first piece keeps that start_time, later pieces store it
164
+ * in properties.split_of as epoch millis.
165
+ *
166
+ * @param {object} pool - pg pool
167
+ * @param {string} machine - machine id
168
+ * @param {Date} start - original off start
169
+ * @returns {Promise<Array<object>>} family rows ordered by start
170
+ */
171
+ export async function loadFamily(pool, machine, start) {
172
+ const result = await pool.query(
173
+ `SELECT name, start_time, end_time, duration, properties
174
+ FROM segments
175
+ WHERE machine = $1 AND kind = 'phase'
176
+ AND (start_time = $2 OR (properties IS NOT NULL AND properties::jsonb ->> 'split_of' = $3))
177
+ ORDER BY start_time`,
178
+ [machine, start, String(start.getTime())]
179
+ );
180
+ return result.rows;
181
+ }
182
+
183
+ /**
184
+ * Bounds of one stored family. A later edit must cover this span, so
185
+ * collapsing every piece but one restores the original start and end.
186
+ *
187
+ * @param {Array<object>} rows - phase rows of one family
188
+ * @returns {object} parent off with start_time, end_time, and duration
189
+ *
190
+ * @example
191
+ * const parent = familyCover(await loadFamily(pool, machine, start));
192
+ */
193
+ export function familyCover(rows) {
194
+ if (!Array.isArray(rows) || rows.length === 0) {
195
+ throw new RangeError('split family is empty');
196
+ }
197
+ const ordered = rows.slice().sort(byRowStart);
198
+ const start = new Date(ordered[0].start_time).getTime();
199
+ const span = ordered.reduce(widen, { end: start, pending: false });
200
+ return {
201
+ name: 'off',
202
+ start_time: new Date(start),
203
+ end_time: new Date(span.end),
204
+ duration: span.pending ? 0 : (span.end - start) / 1000
205
+ };
206
+ }
207
+
208
+ /**
209
+ * Checks one operator split against the parent off row.
210
+ * Pieces stay contiguous, the first keeps the parent start, a closed
211
+ * parent is covered through its end, and at least one piece has a tag.
212
+ *
213
+ * @param {object} parent - off row with name, start_time, end_time, duration
214
+ * @param {Array<object>} splits - pieces with start, end, tag, open
215
+ * @returns {Array<object>} pieces ordered by start
216
+ */
217
+ export function checkSplit(parent, splits) {
218
+ const ordered = orderedPieces(parent, splits);
219
+ ordered.forEach((piece, index) => {
220
+ visit(ordered, piece, index);
221
+ });
222
+ refuseCover(parent, ordered[ordered.length - 1]);
223
+ return ordered;
224
+ }
225
+
226
+ /**
227
+ * Builds persistence rows for one checked split. split_of is the parent
228
+ * start in epoch millis so a later edit can find the whole family.
229
+ *
230
+ * @param {string} machine - machine id
231
+ * @param {Array<object>} splits - checked pieces
232
+ * @returns {Array<object>} segment rows
233
+ */
234
+ export function splitRows(machine, splits) {
235
+ const origin = String(splits[0].start.getTime());
236
+ return splits.map((piece) => {
237
+ return rowOf(machine, origin, piece);
238
+ });
239
+ }
240
+
241
+ /**
242
+ * Replaces one split family inside a transaction: drops starts that the
243
+ * new cut no longer uses, then upserts the pieces.
244
+ *
245
+ * @param {object} pool - pg pool with connect()
246
+ * @param {object} record - machine, origin millis, pieces
247
+ * @returns {Promise<void>}
248
+ */
249
+ export async function replaceSplit(pool, record) {
250
+ refuseEmpty(record);
251
+ const client = await pool.connect();
252
+ try {
253
+ await client.query('BEGIN');
254
+ await removeObsolete(client, record);
255
+ await writePieces(client, record.pieces);
256
+ await client.query('COMMIT');
257
+ } catch (error) {
258
+ await client.query('ROLLBACK');
259
+ throw error;
260
+ } finally {
261
+ client.release();
262
+ }
263
+ }
@@ -18,7 +18,14 @@ async function rejectUnknownTags(timeline, parsed, machineId, res, decorate) {
18
18
  return false;
19
19
  }
20
20
  const [gated] = await decorate(machineId, [row]);
21
- if (allowedSegmentTags(gated.options, gated.tags, parsed.tags)) {
21
+ const wanted = Array.isArray(parsed.splits)
22
+ ? parsed.splits.map((piece) => {
23
+ return piece.tag;
24
+ }).filter((id) => {
25
+ return id;
26
+ })
27
+ : parsed.tags;
28
+ if (allowedSegmentTags(gated.options, gated.tags, wanted)) {
22
29
  return false;
23
30
  }
24
31
  errorResponse('BAD_REQUEST', `Tag is not in segment options for ${machineId}`, 400).send(res);
@@ -88,6 +95,10 @@ export default function timelineRoute(basePath, plant, operatorOptions, decorate
88
95
  return;
89
96
  }
90
97
  await handleOperatorWrite(gate, parsed, async (audit) => {
98
+ if (Array.isArray(parsed.splits)) {
99
+ await result.machine.timeline.respond(new Date(parsed.start).toISOString(), parsed, audit);
100
+ return;
101
+ }
91
102
  await result.machine.timeline.retag(new Date(parsed.start), parsed.tags, parsed.properties, audit);
92
103
  });
93
104
  jsonResponse({ status: 'updated' }).send(res);
@@ -11,6 +11,20 @@ import processingErrorLog from '../processingErrorLog.js';
11
11
  * const codec = segmentCodec(collector);
12
12
  * codec.accept({ destination: '/exchange/scada.segments', payload: '{"machine":"m2","name":"on","start":1700000000,"end":1700003600,"duration":3600}' });
13
13
  */
14
+ function familyRecord(parsed) {
15
+ if (typeof parsed.machine !== 'string' || !Array.isArray(parsed.pieces) || parsed.pieces.length === 0) {
16
+ throw new Error('split-family missing machine or pieces');
17
+ }
18
+ return {
19
+ type: 'split-family',
20
+ machine: parsed.machine,
21
+ origin: parsed.origin,
22
+ pieces: parsed.pieces.map((piece) => {
23
+ return segmentNormalize({ ...piece, type: 'split', machine: parsed.machine });
24
+ })
25
+ };
26
+ }
27
+
14
28
  export default function segmentCodec(collector) {
15
29
  if (!collector || typeof collector.accept !== 'function') {
16
30
  throw new Error('Collector must have an accept() method');
@@ -19,6 +33,10 @@ export default function segmentCodec(collector) {
19
33
  async accept(raw) {
20
34
  try {
21
35
  const parsed = JSON.parse(raw.payload);
36
+ if (parsed.type === 'split-family') {
37
+ await collector.accept(familyRecord(parsed));
38
+ return;
39
+ }
22
40
  const record = segmentNormalize(parsed);
23
41
  await collector.accept(record);
24
42
  } catch (error) {
@@ -20,6 +20,7 @@ const privilegedOnly = new Set([
20
20
  'C0004__central_operations_grants.sql',
21
21
  'C0005__central_operators_grants.sql',
22
22
  'C0006__central_operators_registration.sql',
23
+ 'C0007__central_segment_replace_delete.sql',
23
24
  'E0002__edge_retention_delete.sql',
24
25
  'E0003__edge_operations_grants.sql',
25
26
  'E0004__edge_operations_delete.sql'
@@ -6,6 +6,7 @@ import segmentDispatch from '../../../domain/segment/dispatch.js';
6
6
  import segmentCoalesce from '../../../domain/segment/segmentCoalesce.js';
7
7
  import silenceBudget from '../../../domain/segment/silenceBudget.js';
8
8
  import segmentCodec from '../codecs/segmentCodec.js';
9
+ import processingErrorLog from '../processingErrorLog.js';
9
10
  import silentOpenWatch from '../silentOpenWatch.js';
10
11
  import abutSegments from '../sinks/abutSegments.js';
11
12
  import closeOrphanOpen from '../sinks/closeOrphanOpen.js';
@@ -17,8 +18,8 @@ export const segmentColumns = ['machine', 'kind', 'name', 'start_time', 'end_tim
17
18
  'options', 'tags', 'properties', 'resolved', 'updated_at'];
18
19
  export const segmentConflict = ['machine', 'kind', 'start_time'];
19
20
  export const segmentUpdateColumns = ['name', 'end_time', 'duration', 'options', 'resolved', 'updated_at'];
20
- export const splitUpdateColumns = ['name', 'end_time', 'duration', 'tags', 'options', 'resolved'];
21
- export const segmentUpdateWhere = 'segments.duration = 0 OR EXCLUDED.duration > 0 OR EXCLUDED.end_time > segments.end_time';
21
+ export const splitUpdateColumns = ['name', 'end_time', 'duration', 'tags', 'options', 'properties', 'resolved'];
22
+ export const segmentUpdateWhere = '(segments.duration = 0 OR EXCLUDED.duration > 0 OR EXCLUDED.end_time > segments.end_time) AND NOT (segments.duration > 0 AND EXCLUDED.duration = 0 AND COALESCE(segments.properties, \'\') LIKE \'%"split_of"%\')';
22
23
  export const segmentsIngestDestination = '/queue/scada.segments.ingest';
23
24
 
24
25
  export { default as segmentDispatch } from '../../../domain/segment/dispatch.js';
@@ -75,7 +76,10 @@ export default function segmentPipeline(stomp, postgres, pool, config) {
75
76
  return {
76
77
  destination,
77
78
  async start() {
78
- await abutSegments(pool, budget);
79
+ const repair = abutSegments(pool, budget);
80
+ void repair.catch((error) => {
81
+ processingErrorLog('abut_segments', error, { budgetSeconds: budget });
82
+ });
79
83
  source.start();
80
84
  await silence.start();
81
85
  },
@@ -18,14 +18,27 @@ export default function silentOpenWatch(closer, budget, options) {
18
18
  throw new Error(`Budget must be a positive number: ${budget}`);
19
19
  }
20
20
  const intervalMs = options && options.intervalMs ? options.intervalMs : 5000;
21
+ const gate = { busy: false };
21
22
  let timer;
22
23
  let active = false;
24
+ function claim() {
25
+ const free = !gate.busy;
26
+ gate.busy = true;
27
+ return free;
28
+ }
29
+ function release() {
30
+ gate.busy = false;
31
+ }
23
32
  async function tick() {
33
+ if (!claim()) {
34
+ return;
35
+ }
24
36
  try {
25
37
  await closer.close(budget);
26
38
  } catch {
27
39
  /* next tick retries */
28
40
  }
41
+ release();
29
42
  }
30
43
  return {
31
44
  async start() {
@@ -15,20 +15,19 @@ export default async function abutSegments(pool, budgetSeconds) {
15
15
  `UPDATE segments AS left_row
16
16
  SET end_time = right_row.start_time,
17
17
  duration = EXTRACT(EPOCH FROM (right_row.start_time - left_row.start_time))
18
- FROM segments AS right_row
19
- WHERE left_row.machine = right_row.machine
20
- AND left_row.kind = right_row.kind
18
+ FROM LATERAL (
19
+ SELECT next.start_time, next.name
20
+ FROM segments AS next
21
+ WHERE next.machine = left_row.machine
22
+ AND next.kind = left_row.kind
23
+ AND next.start_time > left_row.start_time
24
+ ORDER BY next.start_time
25
+ LIMIT 1
26
+ ) AS right_row
27
+ WHERE left_row.duration > 0
21
28
  AND left_row.name = right_row.name
22
- AND left_row.duration > 0
23
29
  AND right_row.start_time > left_row.end_time
24
- AND right_row.start_time - left_row.end_time <= make_interval(secs => $1)
25
- AND right_row.start_time = (
26
- SELECT MIN(next.start_time)
27
- FROM segments AS next
28
- WHERE next.machine = left_row.machine
29
- AND next.kind = left_row.kind
30
- AND next.start_time > left_row.start_time
31
- )`,
30
+ AND right_row.start_time - left_row.end_time <= make_interval(secs => $1)`,
32
31
  [budgetSeconds]
33
32
  );
34
33
  }
@@ -10,6 +10,30 @@ function authHeaders(token) {
10
10
  return headers;
11
11
  }
12
12
 
13
+ function operatorId(body, audit) {
14
+ if (audit && audit.id !== undefined && audit.id !== null) {
15
+ body.operatorId = audit.id;
16
+ }
17
+ return body;
18
+ }
19
+
20
+ function splitBody(splits, audit) {
21
+ return operatorId({
22
+ splits: (splits || []).map((piece) => {
23
+ return {
24
+ start: piece.start.toISOString(),
25
+ end: piece.end.toISOString(),
26
+ tag: piece.tag || '',
27
+ open: Boolean(piece.open)
28
+ };
29
+ })
30
+ }, audit);
31
+ }
32
+
33
+ function respondBody(tags, properties, audit) {
34
+ return operatorId({ tags: tags || [], properties: properties || {} }, audit);
35
+ }
36
+
13
37
  function writeBody(start, tags, properties, audit) {
14
38
  const body = {
15
39
  start: start.toISOString(),
@@ -86,14 +110,11 @@ export default function httpTimeline(site, machineId) {
86
110
  },
87
111
  async respond(start, tags, properties, audit) {
88
112
  const requestId = encodeURIComponent(start.toISOString());
89
- const body = {
90
- tags: tags || [],
91
- properties: properties || {}
92
- };
93
- if (audit && audit.id !== undefined && audit.id !== null) {
94
- body.operatorId = audit.id;
95
- }
96
- await send('POST', `/machines/${encodeURIComponent(machineId)}/requests/${requestId}/respond`, body);
113
+ await send('POST', `/machines/${encodeURIComponent(machineId)}/requests/${requestId}/respond`, respondBody(tags, properties, audit));
114
+ },
115
+ async respondSplit(start, splits, audit) {
116
+ const requestId = encodeURIComponent(start.toISOString());
117
+ await send('POST', `/machines/${encodeURIComponent(machineId)}/requests/${requestId}/respond`, splitBody(splits, audit));
97
118
  }
98
119
  });
99
120
  }
@@ -16,6 +16,9 @@ export default function stompTimeline(decisions, machineId) {
16
16
  },
17
17
  async respond(start, tags, properties, audit) {
18
18
  await decisions.publish(machineId, start, tags, properties, audit);
19
+ },
20
+ async respondSplit(start, splits, audit) {
21
+ await decisions.publishSplit(machineId, start, splits, audit);
19
22
  }
20
23
  };
21
24
  }
@@ -3,6 +3,18 @@ import userDecisionBody from './userDecisionBody.js';
3
3
 
4
4
  const DEFAULT_DESTINATION = '/exchange/scada.user_decisions';
5
5
 
6
+ function splitWire(piece) {
7
+ const row = {
8
+ start: piece.start.getTime() / 1000,
9
+ end: piece.end.getTime() / 1000,
10
+ tag: piece.tag || ''
11
+ };
12
+ if (piece.open) {
13
+ row.open = true;
14
+ }
15
+ return row;
16
+ }
17
+
6
18
  /**
7
19
  * Publishes operator tag decisions to RabbitMQ for Scala supervisor.
8
20
  *
@@ -37,6 +49,11 @@ export default function userDecisions(config) {
37
49
  async publish(machine, start, tags, properties, audit) {
38
50
  const body = userDecisionBody(machine, start, tags, properties, audit);
39
51
  await stompSend(config.stompUrl, destination, body, stompOptions);
52
+ },
53
+ async publishSplit(machine, start, splits, audit) {
54
+ const body = userDecisionBody(machine, start, [], {}, audit);
55
+ body.splits = (splits || []).map(splitWire);
56
+ await stompSend(config.stompUrl, destination, body, stompOptions);
40
57
  }
41
58
  };
42
59
  }