@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 +11 -1
- package/db/migrations/C0007__central_segment_replace_delete.sql +1 -0
- package/package.json +1 -1
- package/src/application/shopWithTimeline.js +54 -14
- package/src/domain/segment/dispatch.js +13 -0
- package/src/domain/segment/splitFamily.js +263 -0
- package/src/infrastructure/http/plant/routes/timelineRoute.js +12 -1
- package/src/infrastructure/ingest/codecs/segmentCodec.js +18 -0
- package/src/infrastructure/ingest/db/migrationProfile.js +1 -0
- package/src/infrastructure/ingest/pipelines/segmentPipeline.js +7 -3
- package/src/infrastructure/ingest/silentOpenWatch.js +13 -0
- package/src/infrastructure/ingest/sinks/abutSegments.js +11 -12
- package/src/infrastructure/messaging/ownership/httpTimeline.js +29 -8
- package/src/infrastructure/messaging/stomp/timeline.js +3 -0
- package/src/infrastructure/messaging/stomp/userDecisions.js +17 -0
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 (
|
|
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
|
@@ -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
|
-
|
|
143
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
19
|
-
|
|
20
|
-
|
|
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
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
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
|
}
|