@winkjs/composer 0.6.0 → 0.8.0

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.
Files changed (39) hide show
  1. package/README.md +2 -2
  2. package/package.json +2 -2
  3. package/src/composer.js +1 -1
  4. package/src/core/emitter-manager/mqtt/connection.js +209 -0
  5. package/src/core/emitter-manager/mqtt/constants.js +43 -29
  6. package/src/core/emitter-manager/mqtt/emitter.js +103 -658
  7. package/src/core/emitter-manager/mqtt/health.js +151 -0
  8. package/src/core/emitter-manager/mqtt/index.js +68 -4
  9. package/src/core/emitter-manager/mqtt/publish.js +292 -0
  10. package/src/core/emitter-manager/mqtt/resolve-config.js +312 -0
  11. package/src/core/emitter-manager/mqtt/shutdown-drain.js +247 -0
  12. package/src/core/emitter-manager/terminal/index.js +16 -1
  13. package/src/core/env-vars.js +92 -24
  14. package/src/core/logger/index.js +1 -1
  15. package/src/core/source-manager/mqtt/client.js +197 -63
  16. package/src/core/source-manager/mqtt/dedup.js +16 -11
  17. package/src/core/source-manager/mqtt/index.js +32 -5
  18. package/src/core/source-manager/mqtt/status.js +168 -56
  19. package/src/core/storage-manager/questdb/address-policy.js +264 -0
  20. package/src/core/storage-manager/questdb/delivery-gate.js +218 -0
  21. package/src/core/storage-manager/questdb/flush-engine.js +852 -0
  22. package/src/core/storage-manager/questdb/flush-tracker.js +331 -0
  23. package/src/core/storage-manager/questdb/index.js +542 -862
  24. package/src/core/storage-manager/questdb/persist-plan.js +128 -167
  25. package/src/core/storage-manager/questdb/resolve-options.js +266 -0
  26. package/src/core/storage-manager/questdb/shutdown-drain.js +181 -0
  27. package/src/core/storage-manager/questdb/skip-warnings.js +180 -0
  28. package/src/core/test-utils/tcp-proxy.js +194 -1
  29. package/src/core/utils/address/index.js +362 -0
  30. package/src/core/utils/address/probe.js +210 -0
  31. package/src/core/utils/callback-guard/index.js +23 -2
  32. package/src/core/utils/clock/index.js +34 -0
  33. package/src/core/utils/jitter/index.js +51 -0
  34. package/src/core/utils/line-rate/index.js +104 -0
  35. package/src/core/utils/quantize/index.js +43 -19
  36. package/src/core/wiring/wire-emitters.js +3 -1
  37. package/src/core/wiring/wire-storages.js +10 -2
  38. package/src/flow/flow.js +24 -3
  39. package/src/flow/run.js +24 -2
@@ -0,0 +1,331 @@
1
+ // core/storage-manager/questdb/flush-tracker.js
2
+
3
+ /**
4
+ * @fileoverview The ledger of QuestDB flushes in flight, and the
5
+ * deadline on each one (ADR-029).
6
+ *
7
+ * The client copies the rows out of its buffer the moment a flush
8
+ * starts, before any network work. From that moment the rows are
9
+ * neither in the buffer nor confirmed. This module counts them. Every
10
+ * flush the adapter starts passes through `track`, so `inFlightRows`
11
+ * is exact: rows inside flushes that have not settled. The engine adds
12
+ * it to the buffered count for pressure, and the shutdown drain walks
13
+ * `entries` to settle every flush it owes.
14
+ *
15
+ * Why a deadline. A send against an unreachable server can retry
16
+ * without end (the client's undici transport, a 4.2.0 fact recorded
17
+ * in `flush-engine.js`), so a flush promise may never settle. Each
18
+ * tracked flush therefore gets one timer. The deadline comes from
19
+ * `flushDeadlineFor`: the client's own bound for a batch of that many
20
+ * rows plus a margin, or the fixed `flushDeadlineMs` when the operator
21
+ * set one. A flush past its deadline is abandoned. Its rows leave the
22
+ * count, `abandonedFlushes` goes up by one, and the owner is told with
23
+ * `abandoned: true`. The rows are reported lost. They may still land
24
+ * if the server recovers, so that count is a floor on uncertainty, not
25
+ * a proof of loss.
26
+ *
27
+ * Settles exactly once. An entry settles when the client's promise
28
+ * resolves, when it rejects, or when the deadline fires, whichever
29
+ * comes first. The `settled` flag makes every later event inert: a
30
+ * late result from an abandoned flush moves no counter and calls no
31
+ * handler. The deadline timer is cleared when the flush settles in
32
+ * time, so a healthy adapter carries one live timer per flush in
33
+ * flight and none per row.
34
+ *
35
+ * The owner's handler. `track( flushPromise, rows, onDone )` calls
36
+ * `onDone( err, abandoned )` once: `err` is null when delivered, the
37
+ * client's rejection when it failed, or the abandonment error when the
38
+ * deadline passed. Loss reporting stays with the owner: the engine
39
+ * reports through its failure handler, an explicit `flush()` rejects to
40
+ * its caller, and the shutdown drain tallies. The drain wraps
41
+ * `entry.onDone` to add its tally, so the field is read at settle time,
42
+ * not captured at track time.
43
+ *
44
+ * Outcomes for health. Every settle also records its outcome, so
45
+ * health can read delivery and not only buffering. A delivered flush
46
+ * zeroes `consecutiveFlushFailures` and stamps `lastFlushAt`. A failed
47
+ * or abandoned flush adds one to the count and replaces
48
+ * `lastFlushError` with `{ message, abandoned, at }`. The last error is
49
+ * never cleared: the count says whether it is current, and the message
50
+ * stays readable after recovery. `deliveryStateOf` turns these into
51
+ * the health ladder (yellow on the first failure, red on the second or
52
+ * on an abandonment, green on the next delivered flush). The engine's
53
+ * `getHealth()` reads that one function too.
54
+ *
55
+ * The pause is red as well. The delivery gate tells the ledger when it
56
+ * pauses and resumes delivery, and the ledger mirrors the pause instant
57
+ * in `pausedSince`. The ladder reads red while it is set, whatever the
58
+ * failure count says, because the gate holds new rows until a probe
59
+ * passes. While the gate runs, only its resume clears the mirror. An
60
+ * explicit or recovery flush can land during a pause, and the ladder
61
+ * stays red then. At shutdown the gate is out of the loop, so a
62
+ * landing clears the mirror and the restored line prints beside the
63
+ * clean stop.
64
+ *
65
+ * Edges print once. Every step up the ladder, and every return to
66
+ * green, prints one line through the logger facade, with or without
67
+ * an `onDeliveryFailure` handler, because the callback serves programs
68
+ * and a line serves people. Entering yellow prints at warn, entering
69
+ * red at error, and returning to green at warn with the episode length
70
+ * and the rows reported lost in it. The red line names its cause: the
71
+ * count of failed flushes, or the pause after that count. It prints at
72
+ * the pause, before the gate's own `CIRCUIT_OPEN` line, and a later
73
+ * failed flush adds no second red line. A resume that steps down to
74
+ * yellow prints nothing. The restored line comes when the catch-up
75
+ * flush lands. A resume that lands on green, because a flush landed
76
+ * during the pause, prints the restored line after the gate's resumed
77
+ * line. Nothing prints while a state persists, however long an outage
78
+ * lasts. The token is
79
+ * `DELIVERY_HEALTH` (a console token, like `CIRCUIT_OPEN`, not an
80
+ * `err.code`).
81
+ *
82
+ * Nothing here runs on the per-row path. One entry object, one timer,
83
+ * and two closures per flush; a flush carries thousands of rows by
84
+ * default. The error record and the edge lines allocate only on the
85
+ * failure path, once per flush at most.
86
+ *
87
+ * @see ADR-029
88
+ */
89
+
90
+ import { logger } from '../../logger/index.js';
91
+ import { flushDeadlineFor } from './resolve-options.js';
92
+ import { monotonicNow } from '../../utils/clock/index.js';
93
+
94
+ /**
95
+ * Consecutive failed flushes that read as red delivery. One failure is
96
+ * yellow, because the client's own retries make a single failure
97
+ * worth a look but not an alarm. Two in a row mean the server keeps
98
+ * refusing. An abandoned flush reads red on its own, because a
99
+ * deadline is already the client's full retry budget plus a margin.
100
+ */
101
+ const HEALTH_FLUSH_FAILURE_RED_THRESHOLD = 2;
102
+
103
+ /**
104
+ * The delivery state the ledger's outcome fields spell: the ladder.
105
+ * One function for both readers, the edge detector in this module and
106
+ * the engine's `getHealth()`, so a line and a health read never
107
+ * disagree.
108
+ *
109
+ * @param {{consecutiveFlushFailures: number, lastFlushError: {abandoned: boolean}|null, pausedSince?: number|null}} ledger - The outcome fields
110
+ * @returns {'green'|'yellow'|'red'} The delivery state
111
+ */
112
+ const deliveryStateOf = function ( ledger ) {
113
+ // A paused delivery is red whatever the count says: nothing lands
114
+ // until a probe passes (ADR-029 hold and probe). Health objects
115
+ // carry the same field, so a health read spells the same state.
116
+ const paused = ( ledger.pausedSince !== undefined ) && ( ledger.pausedSince !== null );
117
+ if ( paused ) {
118
+ return 'red';
119
+ }
120
+ const failures = ledger.consecutiveFlushFailures;
121
+ if ( failures === 0 ) {
122
+ return 'green';
123
+ }
124
+ if ( ( failures >= HEALTH_FLUSH_FAILURE_RED_THRESHOLD ) || ledger.lastFlushError.abandoned ) {
125
+ return 'red';
126
+ }
127
+ return 'yellow';
128
+ }; // deliveryStateOf()
129
+
130
+ /**
131
+ * The error an abandoned flush carries: to the caller of an explicit
132
+ * `flush()`, to `onDeliveryFailure`, and as `cause` on a lossy shutdown.
133
+ *
134
+ * @param {number} rows - Rows the flush carried
135
+ * @param {number} deadlineMs - The deadline that passed
136
+ * @returns {Error} Classified DELIVERY_FAILED
137
+ */
138
+ const abandonmentError = function ( rows, deadlineMs ) {
139
+ const err = new Error(
140
+ `winkComposer/questdb: flush abandoned, ${rows} row(s) lost [DELIVERY_FAILED]: no answer within ${deadlineMs} ms`
141
+ );
142
+ err.code = 'DELIVERY_FAILED';
143
+ return err;
144
+ }; // abandonmentError()
145
+
146
+ /**
147
+ * Builds the ledger. The engine reads `ledger.inFlightRows` on the
148
+ * write path and the outcome fields in health; the drain walks
149
+ * `ledger.entries`. Only `track` writes to them.
150
+ *
151
+ * @param {Object} settings - Resolved settings; the deadline inputs (`flushDeadlineMs`, `retryTimeout`)
152
+ * @param {function} isShuttingDown - `() => boolean`, the engine's shutdown flag; a landing clears the pause mirror only then
153
+ * @returns {{ledger: {inFlightRows: number, abandonedFlushes: number, consecutiveFlushFailures: number, lastFlushAt: number|null, lastFlushError: {message: string, abandoned: boolean, at: number}|null, entries: Set}, track: function}}
154
+ */
155
+ const createFlushTracker = function ( settings, isShuttingDown ) {
156
+ const ledger = {
157
+ inFlightRows: 0,
158
+ abandonedFlushes: 0,
159
+ consecutiveFlushFailures: 0,
160
+ lastFlushAt: null,
161
+ lastFlushError: null,
162
+ // The gate's pause instant, mirrored here so the ladder sees it.
163
+ pausedSince: null,
164
+ entries: new Set()
165
+ };
166
+
167
+ // The episode behind the restored line: when delivery left green,
168
+ // on the stopwatch so a step in the wall clock cannot change the
169
+ // length (ADR-018), and the rows reported lost since. Both reset
170
+ // when green returns.
171
+ let episodeStartedAt = null;
172
+ let episodeRowsLost = 0;
173
+
174
+ /**
175
+ * Prints the one line an edge of the ladder earns. The restored
176
+ * line names the episode. The other two carry the failure's own
177
+ * message, so an abandonment reads as a chain in the ADR-028 grammar.
178
+ *
179
+ * @param {'green'|'yellow'|'red'} after - The state just entered
180
+ * @param {Error|null} err - The failure that caused the edge, null on restore
181
+ */
182
+ const printEdge = function ( after, err ) {
183
+ if ( after === 'yellow' ) {
184
+ logger.warn( `winkComposer/questdb: delivery degraded, 1 flush failed [DELIVERY_HEALTH]: ${err.message}` );
185
+ return;
186
+ }
187
+ if ( after === 'red' ) {
188
+ // Red by the count, or red because the gate paused delivery
189
+ // after the failures so far.
190
+ const cause = ( ledger.pausedSince === null ) ?
191
+ ` after ${ledger.consecutiveFlushFailures} failed flush(es)` :
192
+ `, paused after ${ledger.consecutiveFlushFailures} failed flush(es)`;
193
+ logger.error( `winkComposer/questdb: delivery red${cause} [DELIVERY_HEALTH]: ${err.message}` );
194
+ return;
195
+ }
196
+ const seconds = Math.round( ( monotonicNow() - episodeStartedAt ) / 1000 );
197
+ logger.warn(
198
+ `winkComposer/questdb: delivery restored after ${seconds} s, ${episodeRowsLost} row(s) reported lost meanwhile [DELIVERY_HEALTH]`
199
+ );
200
+ }; // printEdge()
201
+
202
+ /**
203
+ * Steps the ladder from `before` to the state the ledger reads now,
204
+ * and prints the edge line when the two differ. Every edge site
205
+ * calls it: a settled flush, the gate's pause, the gate's resume.
206
+ * The episode starts on leaving green and ends on returning to it.
207
+ *
208
+ * @param {'green'|'yellow'|'red'} before - The state before the change
209
+ * @param {Error|null} err - The failure behind the edge, null on a landing or resume
210
+ */
211
+ const stepLadder = function ( before, err ) {
212
+ const after = deliveryStateOf( ledger );
213
+ if ( after === before ) {
214
+ return;
215
+ }
216
+ if ( before === 'green' ) {
217
+ episodeStartedAt = monotonicNow();
218
+ }
219
+ printEdge( after, err );
220
+ if ( after === 'green' ) {
221
+ episodeStartedAt = null;
222
+ episodeRowsLost = 0;
223
+ }
224
+ }; // stepLadder()
225
+
226
+ /**
227
+ * Records one settled flush for health and prints the edge line
228
+ * when the ladder changed. Runs once per flush, never per row.
229
+ *
230
+ * @param {Error|null} err - Null when delivered
231
+ * @param {boolean} abandoned - Whether the deadline ended it
232
+ * @param {number} rows - Rows the flush carried
233
+ */
234
+ const recordOutcome = function ( err, abandoned, rows ) {
235
+ const now = Date.now();
236
+ const before = deliveryStateOf( ledger );
237
+ if ( err === null ) {
238
+ ledger.consecutiveFlushFailures = 0;
239
+ ledger.lastFlushAt = now;
240
+ // While the gate runs, it owns the mirror. An explicit or
241
+ // recovery flush can land during a pause, and the ladder
242
+ // stays red until the gate's probe passes. At shutdown the
243
+ // gate is out of the loop, so a landing clears the mirror
244
+ // and the restored line prints beside the clean stop.
245
+ if ( isShuttingDown() ) {
246
+ ledger.pausedSince = null;
247
+ }
248
+ } else {
249
+ ledger.consecutiveFlushFailures += 1;
250
+ ledger.lastFlushError = { message: err.message, abandoned, at: now };
251
+ episodeRowsLost += rows;
252
+ }
253
+ stepLadder( before, err );
254
+ }; // recordOutcome()
255
+
256
+ /**
257
+ * Records the gate's pause in the ledger and prints the red edge
258
+ * when the pause is what turned the ladder red. A pause from green
259
+ * happens when a flush landed between the failed flush and the
260
+ * probe's result. The episode then starts at the pause.
261
+ *
262
+ * @param {number} pausedSince - The gate's pause instant
263
+ * @param {string} finding - The probe's operator text
264
+ */
265
+ const recordPause = function ( pausedSince, finding ) {
266
+ const before = deliveryStateOf( ledger );
267
+ ledger.pausedSince = pausedSince;
268
+ stepLadder( before, { message: finding } );
269
+ }; // recordPause()
270
+
271
+ /**
272
+ * Records the gate's resume in the ledger. Red to yellow prints
273
+ * nothing: one failure stays on the count until a flush lands, and
274
+ * the restored line comes then. Red to green happens when a flush
275
+ * landed during the pause, and the restored line prints here, after
276
+ * the gate's resumed line.
277
+ */
278
+ const recordResume = function () {
279
+ ledger.pausedSince = null;
280
+ if ( deliveryStateOf( ledger ) === 'green' ) {
281
+ stepLadder( 'red', null );
282
+ }
283
+ }; // recordResume()
284
+
285
+ /**
286
+ * Registers a flush the moment it is called and arms its deadline.
287
+ *
288
+ * @param {Promise} flushPromise - The just-fired `sender.flush()`
289
+ * @param {number} rows - Rows the flush copy carries
290
+ * @param {function} onDone - `( err|null, abandoned )`, called exactly once
291
+ * @returns {{rows: number, onDone: function, settled: boolean, timer: Object}} The tracked entry
292
+ */
293
+ const track = function ( flushPromise, rows, onDone ) {
294
+ const entry = { rows, onDone, settled: false, timer: null };
295
+ ledger.inFlightRows += rows;
296
+ ledger.entries.add( entry );
297
+
298
+ const finish = function ( err, abandoned ) {
299
+ if ( entry.settled ) {
300
+ return;
301
+ }
302
+ entry.settled = true;
303
+ clearTimeout( entry.timer );
304
+ ledger.entries.delete( entry );
305
+ ledger.inFlightRows -= rows;
306
+ recordOutcome( err, abandoned, rows );
307
+ entry.onDone( err, abandoned );
308
+ };
309
+
310
+ const deadlineMs = flushDeadlineFor( rows, settings );
311
+ entry.timer = setTimeout( function () {
312
+ ledger.abandonedFlushes += 1;
313
+ finish( abandonmentError( rows, deadlineMs ), true );
314
+ }, deadlineMs );
315
+ entry.timer.unref();
316
+
317
+ flushPromise.then(
318
+ function () {
319
+ finish( null, false );
320
+ },
321
+ function ( err ) {
322
+ finish( err, false );
323
+ }
324
+ );
325
+ return entry;
326
+ }; // track()
327
+
328
+ return { ledger, track, recordPause, recordResume };
329
+ }; // createFlushTracker()
330
+
331
+ export { createFlushTracker, deliveryStateOf };