@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,852 @@
1
+ // core/storage-manager/questdb/flush-engine.js
2
+
3
+ /**
4
+ * @fileoverview The flush engine of the QuestDB adapter (ADR-029). It
5
+ * owns the hot path (`write`), every flush, the counters behind
6
+ * pressure and health, and the drain at shutdown. The factory in
7
+ * `index.js` builds the connected sender and the persist plans, then
8
+ * hands them to `createFlushEngine`, which returns the handle methods.
9
+ *
10
+ * Three modules carry the engine's hardest concerns, so this file
11
+ * stays the handle and nothing else. `flush-tracker.js` is the ledger
12
+ * of flushes in flight: exact in-flight counts and the deadline that
13
+ * abandons a flush that never settles. `delivery-gate.js` is hold and
14
+ * probe: it pauses delivery while the endpoint is unreachable and
15
+ * resumes it when a probe passes. `shutdown-drain.js` is the
16
+ * drain-then-close shutdown with its exact dropped count. Each carries
17
+ * its own header; this one says how the engine uses them.
18
+ *
19
+ * Composer owns every flush. The client's own trigger is off
20
+ * (`auto_flush=off`, set by the factory), so a batch leaves the process
21
+ * only when this module says so. Two triggers exist. A write that
22
+ * brings the buffer to `flushRows` starts a flush at once, inside
23
+ * `write()`, and does not wait for it. A timer starts a flush every
24
+ * `flushIntervalMs` when anything is buffered, so a slow stream still
25
+ * lands within about one interval. Only one engine flush runs at a
26
+ * time. While one is in flight, both triggers wait and rows collect in
27
+ * the buffer. An explicit `flush()`, the shutdown drain, and the
28
+ * mid-row recovery flush are the caller's own decisions and start
29
+ * regardless.
30
+ *
31
+ * Why the module counts exactly. The client copies the rows out of
32
+ * its buffer the moment a flush starts, before any network work. So
33
+ * `bufferedRows` counts rows waiting for the next flush, and the
34
+ * ledger's `inFlightRows` counts rows inside flushes that have not
35
+ * settled. Every flush start and settle passes through the tracker, so
36
+ * both numbers are exact, not estimates. A failed flush has lost its
37
+ * rows, because the copy is gone with it. The engine reports that loss
38
+ * once per flush: to `onDeliveryFailure` when the caller gave one, as
39
+ * `( err, { trigger, rowsLost, abandoned, probe } )`, otherwise as one
40
+ * classified `DELIVERY_FAILED` console line ending with the probe
41
+ * finding when a probe ran. The process keeps running either way. An
42
+ * unattended deployment must report a lost batch, not stop on it.
43
+ *
44
+ * The lines are bounded during a streak. A server that answers an
45
+ * error for hours never pauses delivery, because the probe passes, so
46
+ * a flush fails every interval. The first two reported losses of an
47
+ * episode print in full. After that the losses are counted, and one
48
+ * summary line prints per minute of streak with the counts. An
49
+ * episode ends at a landing. An explicit `flush()` that fails rejects
50
+ * to its caller and spends none of the lines. The handler still hears
51
+ * every loss.
52
+ *
53
+ * One count is off by one row per rare event. When the client refuses
54
+ * an append at its byte ceiling, its promise rejects after the plan
55
+ * has returned, so the row was already counted as buffered. The next
56
+ * flush then carries one row fewer than the count says. The drift is
57
+ * one row per such event, and it resets when that flush settles.
58
+ *
59
+ * What a failure sets in motion. A failed or abandoned engine flush
60
+ * goes to the gate. The gate runs the ADR-030 probe once, holds the
61
+ * single-flight guard until the probe answers, and pauses delivery
62
+ * when the probe fails. While paused, `write()` and the timer start no
63
+ * flush, rows collect up to the ceiling, and each tick asks the gate to
64
+ * probe again. When the gate resumes, the engine sends everything held
65
+ * in one flush. Two flushes own their own errors and never reach the
66
+ * gate: an explicit `flush()` rejects to its caller, and a flush that
67
+ * fails during the drain is reported by shutdown alone.
68
+ *
69
+ * The ceiling (ADR-018 §12). Once the rows buffered plus the rows in
70
+ * flight reach `bufferCeilingRows`, `write()` refuses new rows with
71
+ * `STORAGE_FULL`. The refusal is one shared object, so shedding costs
72
+ * no allocation. A shed row is a capacity refusal, not a sender error,
73
+ * and does not touch the error counter. Health reads red at the
74
+ * ceiling. With hold and probe, the ceiling is the outage the adapter
75
+ * rides through without loss. A shedding episode prints two lines and
76
+ * no more: one at the first refusal, one when the interval tick finds
77
+ * room again, with the count refused. The ended line can lag the
78
+ * first free slot by one interval; that keeps the accept branch free
79
+ * of the check.
80
+ *
81
+ * Health (ADR-018 §8). `connected` is derived, because the ILP client
82
+ * exposes no socket state. It is false while shutting down, while
83
+ * delivery is paused, after five consecutive write errors, or when
84
+ * delivery reads red. Delivery reads red after two failed flushes in
85
+ * a row, or after one abandoned flush. `status` is `red` when not
86
+ * connected or at capacity (`pressure >= 1`). It is `yellow` when
87
+ * `pressure >= 0.66`, when a write error is outstanding, or when one
88
+ * flush has failed. Otherwise it is `green`. One successful write
89
+ * clears the write error count, and one delivered flush clears the
90
+ * flush failure count. The health object also carries
91
+ * `abandonedFlushes`, `pausedSince`, `consecutiveFlushFailures`,
92
+ * `lastFlushAt`, and `lastFlushError` (`{ message, abandoned, at }`,
93
+ * kept after recovery so the last failure stays readable). The ladder
94
+ * itself is `deliveryStateOf` in `flush-tracker.js`, which also prints
95
+ * one line at every change of it, so a log reader and a health reader
96
+ * see the same state.
97
+ *
98
+ * Shutdown (ADR-018 drain-then-close). A clean resolve is a delivery
99
+ * statement: everything buffered or in flight was delivered. The
100
+ * engine stops the timer, refuses new rows, and hands the drain to
101
+ * `shutdown-drain.js`, which settles every unsettled flush plus one
102
+ * final flush and reports any loss with an exact `dropped` count. The
103
+ * outcome is latched here, so repeated calls cannot contradict it.
104
+ * Shutdown attempts the final flush even while delivery is paused,
105
+ * because the endpoint may have returned since the last tick. A flush
106
+ * that fails during the drain is reported by shutdown alone.
107
+ *
108
+ * Mid-row recovery (ADR-018 — a rejected message costs that message,
109
+ * nothing else):
110
+ * - A persist plan that throws between sender.table() and sender.at() leaves
111
+ * the client holding a half-written row. Without recovery, every later
112
+ * write fails with "Table name has already been set" — the 2026-06-10
113
+ * silent-write-failure incident lost 98.6% of a replay's rows this way.
114
+ * - write()'s catch therefore calls recoverSender(), which composes two
115
+ * documented client calls: flush() ships every COMPLETED row out of the
116
+ * buffer (the copy-out happens synchronously, before any network I/O; the
117
+ * client documents that an unfinished row stays behind), then reset()
118
+ * clears the buffer — at that point holding only the broken stub — and
119
+ * lowers the client's row-in-progress flags.
120
+ * - The recovery flush runs only when completed rows are buffered. It
121
+ * carries real data and is tracked like every flush. If it fails, the
122
+ * loss is reported like any other:
123
+ * `onDeliveryFailure( err, { trigger: 'recovery', rowsLost, abandoned, probe } )`,
124
+ * or one `DELIVERY_FAILED` line.
125
+ * - With nothing buffered, reset() alone clears the broken row. An empty
126
+ * flush sends nothing and resolves at once, so tracking it would stamp
127
+ * a success the endpoint never gave (the 2026-09-08 review found the
128
+ * ladder climbing to yellow on exactly that).
129
+ * - While delivery is paused, a mid-row throw still runs this flush.
130
+ * The held rows go into the dead endpoint and are lost. The client
131
+ * offers no other way to clear the broken row. A row-cancel call
132
+ * would remove that loss; see the upstream issue below.
133
+ * - The client (4.2.0) has no row-cancel API, while its sibling clients do
134
+ * (.NET CancelRow, Rust/C rewind_to_marker, Java recovers automatically).
135
+ * Upstream issue #60 tracks the gap:
136
+ * https://github.com/questdb/nodejs-questdb-client/issues/60
137
+ * When a release ships cancelRow(), recoverSender() becomes that one call.
138
+ *
139
+ * Long-running commitments (ADR-018 §12). One interval timer, created
140
+ * at setup and cleared at shutdown; it does not hold the process open.
141
+ * One deadline timer per flush, cleared when the flush settles, never
142
+ * one per row. Every counter is bounded by the ceiling, except the
143
+ * plain event counts (`abandonedFlushes`, write errors), which are
144
+ * integers. The per-row path allocates nothing in this module; the
145
+ * persist plan documents its own one derived promise per row. The
146
+ * probe runs only after a failure or once per tick while paused, so a
147
+ * healthy adapter never opens a probe socket. Reconnection and request
148
+ * retries belong to the client. No listeners are attached.
149
+ *
150
+ * Client facts this module relies on (`@questdb/nodejs-client`, pinned
151
+ * `~4.2.0`; re-verify each on an upgrade):
152
+ * - `flush()` is `async` and copies the completed rows out of the
153
+ * buffer before it sends (copy-out).
154
+ * - `at()` is `async`; its append runs first and synchronously.
155
+ * - `tryFlush()` checks `auto_flush` first, so `auto_flush=off` stops
156
+ * every client-side flush.
157
+ * - `close()` on the HTTP transport is an empty function. Nothing here
158
+ * can abort an in-flight send; process exit is the backstop.
159
+ * - A send against an unreachable server can retry without end
160
+ * (undici `RetryAgent`, `maxRetries: Infinity`), so a flush promise
161
+ * may never settle. The per-flush deadline is what bounds it here.
162
+ * Under that transport a refused connection shows up as an
163
+ * abandonment after the deadline, not as a fast rejection.
164
+ *
165
+ * The adapter's `index.js` header carries the full `err.code`
166
+ * vocabulary, the defaults table, and the deprecated options.
167
+ *
168
+ * @see ADR-018
169
+ * @see ADR-029
170
+ */
171
+
172
+ import { logger } from '../../logger/index.js';
173
+ import { wrapCallback } from '../../utils/callback-guard/index.js';
174
+ import { createFlushTracker, deliveryStateOf } from './flush-tracker.js';
175
+ import { createDeliveryGate } from './delivery-gate.js';
176
+ import { createShutdownDrain } from './shutdown-drain.js';
177
+ import { monotonicNow } from '../../utils/clock/index.js';
178
+
179
+ // ============================================================================
180
+ // HOT-PATH SINGLETONS
181
+ // ============================================================================
182
+
183
+ /**
184
+ * Singleton success result reused on every successful write. Hot-path zero
185
+ * allocation per ADR-013 / ADR-004. Plain literal — not frozen (V8 hot paths
186
+ * handle plain objects more predictably; no caller mutates this).
187
+ * @type {{ok: true}}
188
+ */
189
+ const RESULT_OK = { ok: true };
190
+
191
+ /**
192
+ * Shared refusal for a write after `shutdown()` was called. The timer is
193
+ * stopped and the final flush may have run, so a row accepted now would
194
+ * have no flusher left. Static text, one object: refusing allocates
195
+ * nothing.
196
+ * @type {{ok: false, error: {code: string, message: string}}}
197
+ */
198
+ const RESULT_SHUTTING_DOWN = {
199
+ ok: false,
200
+ error: {
201
+ code: 'SHUTTING_DOWN',
202
+ message: 'winkComposer/questdb: write rejected [SHUTTING_DOWN]: the storage is shutting down'
203
+ }
204
+ };
205
+
206
+ /**
207
+ * Shared refusal for a write at the buffer ceiling (ADR-029). Shedding
208
+ * happens under stress, when the endpoint is not taking rows, so the
209
+ * refusal must cost nothing per call. Static text, one object.
210
+ * @type {{ok: false, error: {code: string, message: string}}}
211
+ */
212
+ const RESULT_STORAGE_FULL = {
213
+ ok: false,
214
+ error: {
215
+ code: 'STORAGE_FULL',
216
+ message: 'winkComposer/questdb: write rejected [STORAGE_FULL]: the buffer holds bufferCeilingRows rows ' +
217
+ 'and the endpoint has not taken them; the row was shed'
218
+ }
219
+ };
220
+
221
+ /** The release for the recovery flush, which owns no single-flight guard. */
222
+ const NOOP = function () {
223
+ // Intentionally empty.
224
+ }; // NOOP()
225
+
226
+ /**
227
+ * Console channel for the callback guard: one classified line in this
228
+ * adapter's family. Receives an already-safe detail string, never the
229
+ * raw thrown value.
230
+ */
231
+ const reportCallbackFault = function ( severity, name, detail ) {
232
+ logger.error(
233
+ `winkComposer/questdb: user callback ${name} failed [CALLBACK_FAILED]: ${detail}`
234
+ );
235
+ }; // reportCallbackFault()
236
+
237
+ // Error results (INVALID_INSIGHT_TYPE, SEND_FAILED) are constructed per-call
238
+ // because each carries dynamic content (the offending insightType name and the
239
+ // underlying err.message respectively). These paths are rare; per-occurrence
240
+ // allocation on errors is acceptable. The singletons above cover the
241
+ // per-message hot path and the two refusals that can fire under load.
242
+
243
+ // ============================================================================
244
+ // HEALTH THRESHOLDS
245
+ // ============================================================================
246
+
247
+ /**
248
+ * Health status thresholds. consecutiveWriteErrors crossing the YELLOW
249
+ * threshold elevates status to 'yellow'; crossing the RED threshold flips
250
+ * `connected` to false (and therefore `status` to 'red'). Tuned for "any
251
+ * error is worth flagging" + "sustained errors mean the transport is gone."
252
+ */
253
+ const HEALTH_ERROR_YELLOW_THRESHOLD = 1;
254
+ const HEALTH_ERROR_RED_THRESHOLD = 5;
255
+
256
+ /**
257
+ * Pressure threshold above which `status` elevates to at least 'yellow'.
258
+ * The value matches the pressure-aware-yield design (ADR-020, still a
259
+ * Draft). That design proposes 0.66 as the pressure level where the
260
+ * flow would start yielding to let sinks drain; the number still needs
261
+ * benchmark confirmation. Today the yield trigger is time-only (ADR-024), so
262
+ * this alignment is forward-looking, not a description of current yield
263
+ * behaviour. Numeric — the constant exists once here rather than scattered
264
+ * as a magic number.
265
+ */
266
+ const HEALTH_PRESSURE_YELLOW_THRESHOLD = 0.66;
267
+
268
+ /**
269
+ * Pressure at which the adapter is at capacity: the next write is shed.
270
+ * ADR-018 §8 makes this red, whether or not the transport is known to
271
+ * be down.
272
+ */
273
+ const HEALTH_PRESSURE_RED_THRESHOLD = 1;
274
+
275
+ /**
276
+ * Lost flushes of an episode that print in full when no handler is
277
+ * given. They stand beside the degraded and red edge lines; after them
278
+ * the losses are counted into summaries.
279
+ */
280
+ const FULL_LOSS_LINES_PER_EPISODE = 2;
281
+
282
+ /**
283
+ * How often a summary line prints while a streak of lost flushes goes
284
+ * on with no handler. A server that answers an error for hours would
285
+ * otherwise print one line per interval for the whole night.
286
+ */
287
+ const FAILURE_SUMMARY_INTERVAL_MS = 60000;
288
+
289
+ // ============================================================================
290
+ // THE ENGINE
291
+ // ============================================================================
292
+
293
+ /**
294
+ * Builds the flush engine over a connected sender and its persist
295
+ * plans. Called once by the factory, after setup succeeded. Returns
296
+ * the handle methods the adapter contract requires (ADR-018 §6).
297
+ *
298
+ * @param {Object} parts - What the engine runs on
299
+ * @param {Object} parts.sender - The connected ILP sender
300
+ * @param {Object} parts.persistPlans - Persist plan per insight type
301
+ * @param {Object} parts.settings - Resolved settings (`flushRows`, `flushIntervalMs`, `bufferCeilingRows`, deadline inputs)
302
+ * @param {function} [parts.onDeliveryFailure] - The caller's handler, already validated by the plan builder
303
+ * @param {Object} parts.probe - The ADR-030 probe bound to `ilpUrl`: `{ run, describe }` (see `delivery-gate.js`)
304
+ * @param {function} parts.closeTransport - `() => Promise<void>`: closes the sender and destroys the agent the factory owns
305
+ * @returns {{write: function, flush: function, shutdown: function, getPressure: function, getHealth: function}}
306
+ */
307
+ const createFlushEngine = function ( { sender, persistPlans, settings, onDeliveryFailure, probe, closeTransport } ) {
308
+ const { flushRows, flushIntervalMs, bufferCeilingRows } = settings;
309
+
310
+ // Arm the delivery-failure callback for this module's report sites:
311
+ // the engine flushes and the mid-row recovery flush. All fire inside
312
+ // promise chains, where a broken handler used to become an
313
+ // unhandled rejection. The guard classifies the fault instead
314
+ // (ADR-018) and its cost stays inside the handler. The plan builder
315
+ // validated the raw value before this call, so wrapping here is
316
+ // after validation (ADR-027). Absent stays null, so the no-handler
317
+ // console fallback keeps its meaning.
318
+ const safeOnDeliveryFailure = wrapCallback( onDeliveryFailure, {
319
+ name: 'onDeliveryFailure', severity: 'red', report: reportCallbackFault
320
+ } );
321
+
322
+ // ------------------------------------------------------------------
323
+ // Engine state
324
+ // ------------------------------------------------------------------
325
+
326
+ // Rows accepted and waiting for the next flush. The client copies
327
+ // its rows out of the buffer the moment a flush starts. So a flush
328
+ // start moves this count into the ledger at once.
329
+ let bufferedRows = 0;
330
+
331
+ // One engine flush at a time (ADR-029). A send against an
332
+ // unreachable server can hang; without this guard every timer tick
333
+ // and every threshold crossing would start another stuck request.
334
+ // While the guard is up, rows collect in the buffer up to the
335
+ // ceiling, and the condition shows as rising pressure. Only the
336
+ // row and timer triggers respect the guard. After a failure the
337
+ // gate holds the guard until the probe has answered.
338
+ let flushInFlight = false;
339
+
340
+ // Shutdown outcome, latched on the first call. A lossy shutdown's
341
+ // failed flush already emptied the buffer via copy-out. A re-run
342
+ // would find nothing to flush and resolve clean, contradicting the
343
+ // recorded loss. Every caller gets the first call's promise instead.
344
+ let shutdownPromise = null;
345
+
346
+ // Health state. shuttingDown flips true at the start of shutdown()
347
+ // and never resets. consecutiveWriteErrors increments on every
348
+ // write() catch and resets to 0 on every successful persist plan
349
+ // call. A shed row touches neither: it is a capacity refusal, not
350
+ // a sender error.
351
+ let shuttingDown = false;
352
+ let consecutiveWriteErrors = 0;
353
+
354
+ // The shedding episode. It is up from the first row refused at the
355
+ // ceiling until the tick finds room again. The count feeds the
356
+ // "ended" line. The refusal branch raises it, and that branch runs
357
+ // only while the adapter is already shedding. The tick lowers it.
358
+ // So the accept branch never looks at it.
359
+ let shedding = false;
360
+ let shedRows = 0;
361
+
362
+ // The loss lines during a streak with no handler. An episode is a
363
+ // run of reported losses with no landing between them. The engine
364
+ // counts its own reports per episode. The ledger's failure count
365
+ // cannot serve, because it also counts explicit flush() failures,
366
+ // which their caller owns and nothing reports here. Deciding the
367
+ // line by that count let two owned failures push the first reported
368
+ // loss straight into a summary, measured from a stamp no line had
369
+ // set. A landing is detected by the ledger's last-landed stamp.
370
+ let reportedInEpisode = 0;
371
+ let landedAtLastReport = null;
372
+ let summarySince = 0;
373
+ let summaryFlushes = 0;
374
+ let summaryRows = 0;
375
+
376
+ // The ledger of flushes in flight (exact counts, deadlines) and the
377
+ // gate that pauses delivery while the endpoint is unreachable. Both
378
+ // read the engine's shutdown state through one small function. The
379
+ // gate reports its pause and resume to the tracker through two
380
+ // hooks, and neither calls back into the engine.
381
+ const isShuttingDown = function () {
382
+ return shuttingDown;
383
+ }; // isShuttingDown()
384
+ const { ledger, track, recordPause, recordResume } = createFlushTracker( settings, isShuttingDown );
385
+ const gate = createDeliveryGate( {
386
+ probe,
387
+ heldRows: function () {
388
+ return bufferedRows;
389
+ },
390
+ isShuttingDown,
391
+ onPause: recordPause,
392
+ onResume: recordResume
393
+ } );
394
+
395
+ // ------------------------------------------------------------------
396
+ // Flushes
397
+ // ------------------------------------------------------------------
398
+
399
+ /**
400
+ * Reports one lost flush. The rows are gone with the failed copy,
401
+ * so this is a statement of loss, not a retry hint. The caller owns
402
+ * the response when it asked to; otherwise one classified line,
403
+ * ending with the probe finding when a probe ran. The process keeps
404
+ * running either way (see the file header).
405
+ *
406
+ * @param {Error} err - The client's rejection, or the abandonment error
407
+ * @param {string} trigger - 'rows', 'timer' or 'recovery'
408
+ * @param {number} rows - Rows the flush carried
409
+ * @param {boolean} abandoned - Whether the deadline, not the client, ended it
410
+ * @param {{outcome: Object, finding: string}|null} probed - The probe result, or null when none ran
411
+ */
412
+ const reportFlushLoss = function ( err, trigger, rows, abandoned, probed ) {
413
+ if ( safeOnDeliveryFailure ) {
414
+ safeOnDeliveryFailure( err, {
415
+ trigger, rowsLost: rows, abandoned, probe: ( probed === null ) ? null : probed.outcome
416
+ } );
417
+ return;
418
+ }
419
+ // The lines are bounded during a streak. A server that answers
420
+ // an error for hours never pauses delivery, because the probe
421
+ // passes, so a flush fails every interval. The first reported
422
+ // losses of an episode print in full, beside the ladder's edge
423
+ // lines. The rest are counted, and one summary line prints per
424
+ // interval of streak. The restored edge line closes the episode
425
+ // with its totals. The ledger already counts this loss, and
426
+ // single flight means no other flush settles before this report.
427
+ // The cadence reads the stopwatch, so a step in the wall clock
428
+ // cannot print a summary early or late (ADR-018).
429
+ const finding = ( probed === null ) ? '' : `; probe: ${probed.finding}`;
430
+ const now = monotonicNow();
431
+ if ( ledger.lastFlushAt !== landedAtLastReport ) {
432
+ // A flush landed since the last report: a new episode.
433
+ landedAtLastReport = ledger.lastFlushAt;
434
+ reportedInEpisode = 0;
435
+ }
436
+ reportedInEpisode += 1;
437
+ if ( reportedInEpisode <= FULL_LOSS_LINES_PER_EPISODE ) {
438
+ const line = abandoned ?
439
+ err.message :
440
+ `winkComposer/questdb: flush failed, ${rows} row(s) lost [DELIVERY_FAILED]: ${err.message}`;
441
+ logger.error( `${line}${finding}` );
442
+ summarySince = now;
443
+ summaryFlushes = 0;
444
+ summaryRows = 0;
445
+ return;
446
+ }
447
+ summaryFlushes += 1;
448
+ summaryRows += rows;
449
+ if ( ( now - summarySince ) < FAILURE_SUMMARY_INTERVAL_MS ) {
450
+ return;
451
+ }
452
+ const seconds = Math.round( ( now - summarySince ) / 1000 );
453
+ logger.error(
454
+ `winkComposer/questdb: delivery still failing, ${summaryFlushes} flush(es) and ${summaryRows} row(s) ` +
455
+ `lost in the last ${seconds} s [DELIVERY_FAILED]: ${err.message}${finding}`
456
+ );
457
+ summarySince = now;
458
+ summaryFlushes = 0;
459
+ summaryRows = 0;
460
+ }; // reportFlushLoss()
461
+
462
+ /** Lowers the single-flight guard. */
463
+ const releaseFlight = function () {
464
+ flushInFlight = false;
465
+ }; // releaseFlight()
466
+
467
+ /**
468
+ * Hands a failed or abandoned engine flush to the gate, which
469
+ * probes, reports through `reportFlushLoss`, and releases the guard
470
+ * when the engine may flush again. One closure per failed flush;
471
+ * the failure path is not the hot path.
472
+ *
473
+ * @param {Error} err - The client's rejection, or the abandonment error
474
+ * @param {string} trigger - 'rows', 'timer' or 'recovery'
475
+ * @param {number} rows - Rows the flush carried
476
+ * @param {boolean} abandoned - Whether the deadline ended it
477
+ * @param {function} release - `releaseFlight` for an engine flush, `NOOP` for the recovery flush
478
+ */
479
+ const handleFlushFailure = function ( err, trigger, rows, abandoned, release ) {
480
+ gate.afterFailure( function ( probed ) {
481
+ reportFlushLoss( err, trigger, rows, abandoned, probed );
482
+ }, release );
483
+ }; // handleFlushFailure()
484
+
485
+ /**
486
+ * Starts one engine flush for everything buffered. The caller has
487
+ * checked the guard, the gate, and that the buffer is not empty.
488
+ * The client's flush() is async, so it never throws here (a 4.2.0
489
+ * fact recorded in the header); its outcome arrives through the
490
+ * tracker.
491
+ *
492
+ * @param {string} trigger - 'rows' or 'timer', for the loss report
493
+ */
494
+ const startFlush = function ( trigger ) {
495
+ flushInFlight = true;
496
+ const rows = bufferedRows;
497
+ bufferedRows = 0;
498
+ track( sender.flush(), rows, function ( err, abandoned ) {
499
+ if ( err ) {
500
+ handleFlushFailure( err, trigger, rows, abandoned, releaseFlight );
501
+ return;
502
+ }
503
+ releaseFlight();
504
+ } );
505
+ }; // startFlush()
506
+
507
+ /**
508
+ * After a tick probe: when the gate resumed delivery, one flush
509
+ * carries everything held. The guard may still belong to an engine
510
+ * flush from before the pause; then that flush's settle lets the
511
+ * next tick carry the held rows.
512
+ *
513
+ * @param {boolean} resumed - Whether this tick resumed delivery
514
+ */
515
+ const startAfterResume = function ( resumed ) {
516
+ if ( resumed && ( bufferedRows > 0 ) && !flushInFlight ) {
517
+ startFlush( 'timer' );
518
+ }
519
+ }; // startAfterResume()
520
+
521
+ /**
522
+ * The timer tick: flush whatever is buffered, unless a flush is
523
+ * already in flight or delivery is paused. While paused, the gate
524
+ * probes instead. Synchronous and O(1) on the flush path; nothing
525
+ * here can throw.
526
+ */
527
+ const checkFlush = function () {
528
+ if ( shedding && ( ( bufferedRows + ledger.inFlightRows ) < bufferCeilingRows ) ) {
529
+ shedding = false;
530
+ logger.warn( `winkComposer/questdb: shedding ended, ${shedRows} row(s) refused [STORAGE_FULL]: the buffer has room again` );
531
+ }
532
+ if ( gate.isPaused() ) {
533
+ gate.tick().then( startAfterResume );
534
+ return;
535
+ }
536
+ if ( flushInFlight || ( bufferedRows === 0 ) ) {
537
+ return;
538
+ }
539
+ startFlush( 'timer' );
540
+ }; // checkFlush()
541
+
542
+ // The one interval timer (ADR-018 §12): created here, cleared at
543
+ // shutdown. It does not hold the process open. The client's own
544
+ // interval trigger would not have served. It checks elapsed time
545
+ // only when a new row arrives. A stream that stops would leave its
546
+ // last rows in the buffer for good.
547
+ const flushTimer = setInterval( checkFlush, flushIntervalMs );
548
+ flushTimer.unref();
549
+
550
+ // ------------------------------------------------------------------
551
+ // The handle
552
+ // ------------------------------------------------------------------
553
+
554
+ /**
555
+ * Cancels a half-written ILP row after a mid-row throw, so the NEXT write
556
+ * starts clean (see "Mid-row recovery" in the file header for the full
557
+ * account and the upstream issue link).
558
+ *
559
+ * When completed rows are buffered, flush() copies them out of the
560
+ * buffer synchronously and sends them in the background; the
561
+ * unfinished row stays behind. reset() then clears the buffer — only
562
+ * the broken stub remains at that point — and lowers the client's
563
+ * row-in-progress flags. Net effect: the broken row vanishes, every
564
+ * good row is on its way, the sender accepts the next write.
565
+ *
566
+ * When nothing is buffered, reset() alone is the recovery. A flush of
567
+ * an empty buffer sends nothing and resolves at once (the client
568
+ * returns false), so tracking it would record a delivery that never
569
+ * happened: the ledger would read a fresh success while the endpoint
570
+ * is still dead, and health would climb the ladder on no evidence.
571
+ *
572
+ * The early flush carries real data. If its send fails, that loss is
573
+ * reported like any other (see `handleFlushFailure`). Never silent.
574
+ */
575
+ const recoverSender = function () {
576
+ try {
577
+ const rows = bufferedRows;
578
+ if ( rows > 0 ) {
579
+ // flush() is an async function: it can never throw
580
+ // synchronously, and the rows it sends live in its own copy
581
+ // of the buffer. Tracked like every flush, so shutdown
582
+ // settles it and its rows stay visible as pressure until it
583
+ // settles. It does not own the single-flight guard, so its
584
+ // failure releases nothing.
585
+ track( sender.flush(), rows, function ( err, abandoned ) {
586
+ if ( err ) {
587
+ handleFlushFailure( err, 'recovery', rows, abandoned, NOOP );
588
+ }
589
+ } );
590
+ }
591
+ sender.reset();
592
+ bufferedRows = 0;
593
+ } catch ( recoveryErr ) {
594
+ // Defensive: with the 4.2.0 client neither call can throw here
595
+ // (flush is async, reset is trivial buffer bookkeeping). A
596
+ // future client could change that and leave the sender
597
+ // wedged. Even then write() must return its classified result
598
+ // rather than throw (ADR-018: the hot path never throws), and
599
+ // the failure must be visible.
600
+ logger.error( `winkComposer/questdb: sender recovery failed: ${recoveryErr.message}` );
601
+ }
602
+ }; // recoverSender()
603
+
604
+ /**
605
+ * Write a message to QuestDB for a given insightType.
606
+ *
607
+ * Sync hot-path return per ADR-018 / ADR-013:
608
+ * - `RESULT_OK` (singleton) on successful enqueue.
609
+ * - `RESULT_SHUTTING_DOWN` (singleton) after shutdown() was called.
610
+ * - `{ ok: false, error: { code: 'INVALID_INSIGHT_TYPE', message } }`
611
+ * when the insightType has no persist plan (config error).
612
+ * - `RESULT_STORAGE_FULL` (singleton) when the rows buffered plus the
613
+ * rows in flight have reached `bufferCeilingRows`.
614
+ * - `{ ok: false, error: { code: 'SEND_FAILED', message } }` when the
615
+ * persist plan throws (sender method failure: type coercion
616
+ * failure, a client throw).
617
+ *
618
+ * Never throws. A row that brings the buffer to `flushRows` starts a
619
+ * flush before this returns, unless one is already in flight or
620
+ * delivery is paused. The flush is not awaited.
621
+ *
622
+ * @param {string} insightType - SignalType name (must exist in assetClass)
623
+ * @param {Object} message - Message with column values
624
+ * @param {string} partitionId - Partition identifier (stored as SYMBOL)
625
+ * @returns {{ok: true} | {ok: false, error: {code: string, message: string}}}
626
+ */
627
+ const write = function ( insightType, message, partitionId ) {
628
+ if ( shuttingDown ) {
629
+ return RESULT_SHUTTING_DOWN;
630
+ }
631
+
632
+ const persistPlan = persistPlans[ insightType ];
633
+
634
+ if ( !persistPlan ) {
635
+ return {
636
+ ok: false,
637
+ error: {
638
+ code: 'INVALID_INSIGHT_TYPE',
639
+ message: `No persist plan for insightType '${insightType}'`
640
+ }
641
+ };
642
+ }
643
+
644
+ // The ceiling counts rows in flight too: a hung flush holds real
645
+ // memory, and the row it would make room for has not landed.
646
+ if ( ( bufferedRows + ledger.inFlightRows ) >= bufferCeilingRows ) {
647
+ // The first refusal of an episode prints once; the rest
648
+ // only count. This branch runs only while shedding, so the
649
+ // check costs the accept path nothing.
650
+ if ( !shedding ) {
651
+ shedding = true;
652
+ shedRows = 0;
653
+ logger.warn(
654
+ `winkComposer/questdb: shedding began at the ceiling of ${bufferCeilingRows} rows [STORAGE_FULL]: new rows are refused until the endpoint takes them`
655
+ );
656
+ }
657
+ shedRows += 1;
658
+ return RESULT_STORAGE_FULL;
659
+ }
660
+
661
+ try {
662
+ // The plan reports whether it actually opened a row. A row
663
+ // skipped in phase 1 (bad designated timestamp) never touched
664
+ // the sender and must not count as buffered. It would inflate
665
+ // pressure and shutdown's dropped count.
666
+ const written = persistPlan( sender, message, partitionId );
667
+ consecutiveWriteErrors = 0; // recovery — health flips back to green
668
+ if ( written ) {
669
+ bufferedRows += 1;
670
+ // The gate is asked only on the threshold crossing, once
671
+ // per batch, never per row.
672
+ if ( ( bufferedRows >= flushRows ) && !flushInFlight && !gate.isPaused() ) {
673
+ startFlush( 'rows' );
674
+ }
675
+ }
676
+ return RESULT_OK;
677
+ } catch ( err ) {
678
+ consecutiveWriteErrors += 1; // health degradation signal
679
+ // The throw may have left a half-written row in the sender.
680
+ // Cancel it, so this failure costs one row and not the rest
681
+ // of the run (ADR-018). Safe to call even when no row was
682
+ // open: flushing early is harmless, and reset() on a
683
+ // consistent buffer is a no-op.
684
+ recoverSender();
685
+ return {
686
+ ok: false,
687
+ error: {
688
+ code: 'SEND_FAILED',
689
+ message: err.message
690
+ }
691
+ };
692
+ }
693
+ }; // write()
694
+
695
+ /**
696
+ * Flush pending rows to QuestDB now. The caller's own decision: it
697
+ * starts a flush even while an engine flush is in flight or delivery
698
+ * is paused. The rows move to the in-flight tally at the call
699
+ * (copy-out). The promise resolves when the server confirmed the
700
+ * rows, and rejects when the client failed or the deadline passed.
701
+ * The caller owns that error; nothing is reported elsewhere. Nothing
702
+ * buffered: resolves at once.
703
+ *
704
+ * @returns {Promise<void>}
705
+ */
706
+ const flush = function () {
707
+ if ( bufferedRows === 0 ) {
708
+ return Promise.resolve();
709
+ }
710
+ const rows = bufferedRows;
711
+ bufferedRows = 0;
712
+ return new Promise( function ( resolve, reject ) {
713
+ track( sender.flush(), rows, function ( err ) {
714
+ if ( err ) {
715
+ reject( err );
716
+ return;
717
+ }
718
+ resolve();
719
+ } );
720
+ } );
721
+ }; // flush()
722
+
723
+ /**
724
+ * Backpressure metric for the partition manager. Returns the buffer fill
725
+ * ratio in [0, 1] per ADR-018 (sync, O(1), allocation-free): the rows
726
+ * buffered plus the rows in flight, over `bufferCeilingRows`. It reads
727
+ * 1 exactly when the next write would be shed. Exact, because the
728
+ * tracker sees every flush start and settle.
729
+ *
730
+ * Rows inside unsettled flush copies count as pressure: a hung flush
731
+ * is undelivered data, and hiding it is what let shutdown report
732
+ * clean over it. They leave the tally when their flush settles —
733
+ * delivered, reported lost, or abandoned.
734
+ *
735
+ * @returns {number} Pressure value in [0, 1]
736
+ */
737
+ const getPressure = function () {
738
+ return Math.min( 1, ( bufferedRows + ledger.inFlightRows ) / bufferCeilingRows );
739
+ }; // getPressure()
740
+
741
+ /**
742
+ * Health snapshot for operator monitoring (uniform across sinks).
743
+ * Returns the ADR-018 health floor `{status, connected, pressure}`,
744
+ * plus the adapter's own diagnostic counters.
745
+ *
746
+ * Status derivation (kept in code, not config — operator mental model is
747
+ * load-bearing institutional knowledge):
748
+ * - `red` if `!connected` (shutting down, delivery paused,
749
+ * sustained write failure, or delivery red: two failed
750
+ * flushes in a row, or one abandoned flush)
751
+ * or `pressure >= HEALTH_PRESSURE_RED_THRESHOLD` (at capacity)
752
+ * - `yellow` if `pressure >= HEALTH_PRESSURE_YELLOW_THRESHOLD` OR
753
+ * `consecutiveWriteErrors >= HEALTH_ERROR_YELLOW_THRESHOLD` OR
754
+ * one flush has failed since the last delivered one
755
+ * - `green` otherwise
756
+ *
757
+ * `connected` here is *derived* — QuestDB's ILP sender is fire-and-forget
758
+ * with no observable socket state, so we infer transport health from
759
+ * recent write success, from the ledger's flush outcomes, and from
760
+ * the probe that paused delivery.
761
+ *
762
+ * @returns {{status: 'green'|'yellow'|'red', connected: boolean, pressure: number, consecutiveWriteErrors: number, bufferedRows: number, inFlightRows: number, abandonedFlushes: number, pausedSince: number|null, consecutiveFlushFailures: number, lastFlushAt: number|null, lastFlushError: {message: string, abandoned: boolean, at: number}|null}}
763
+ */
764
+ const getHealth = function () {
765
+ const pressure = getPressure();
766
+ // The ladder comes from the tracker's one function, the same
767
+ // one that prints the edge lines, so the two never disagree.
768
+ const delivery = deliveryStateOf( ledger );
769
+ // A paused delivery reads red through the ladder: the ledger
770
+ // carries the gate's pause instant.
771
+ const connected = !shuttingDown &&
772
+ ( consecutiveWriteErrors < HEALTH_ERROR_RED_THRESHOLD ) && ( delivery !== 'red' );
773
+
774
+ let status;
775
+ if ( !connected || ( pressure >= HEALTH_PRESSURE_RED_THRESHOLD ) ) {
776
+ status = 'red';
777
+ } else if ( ( pressure >= HEALTH_PRESSURE_YELLOW_THRESHOLD ) ||
778
+ ( consecutiveWriteErrors >= HEALTH_ERROR_YELLOW_THRESHOLD ) ||
779
+ ( delivery === 'yellow' ) ) {
780
+ status = 'yellow';
781
+ } else {
782
+ status = 'green';
783
+ }
784
+
785
+ return {
786
+ // Required health floor (ADR-018)
787
+ status,
788
+ connected,
789
+ pressure,
790
+ // Adapter-specific diagnostics
791
+ consecutiveWriteErrors,
792
+ bufferedRows,
793
+ inFlightRows: ledger.inFlightRows,
794
+ abandonedFlushes: ledger.abandonedFlushes,
795
+ pausedSince: ledger.pausedSince,
796
+ consecutiveFlushFailures: ledger.consecutiveFlushFailures,
797
+ lastFlushAt: ledger.lastFlushAt,
798
+ lastFlushError: ledger.lastFlushError
799
+ };
800
+ }; // getHealth()
801
+
802
+ // The drain (see `shutdown-drain.js`). It takes the buffered count
803
+ // through a function. So the copy-out and the zeroing happen in the
804
+ // same breath, the way every flush start does here. The transport
805
+ // close is the factory's, because the factory owns the agent.
806
+ const drain = createShutdownDrain( {
807
+ sender,
808
+ ledger,
809
+ track,
810
+ takeBufferedRows: function () {
811
+ const rows = bufferedRows;
812
+ bufferedRows = 0;
813
+ return rows;
814
+ },
815
+ closeTransport
816
+ } );
817
+
818
+ /**
819
+ * Shutdown the storage adapter gracefully: drain, then close
820
+ * (ADR-018). Called by wire-storages.shutdown() during pipeline
821
+ * shutdown and from the process signal handlers.
822
+ *
823
+ * The outcome is latched: the first call runs the drain, every
824
+ * later call returns the same promise. A lossy shutdown's failed
825
+ * flush already emptied the buffer (copy-out), so a re-run would
826
+ * find nothing to flush and resolve clean, contradicting the
827
+ * recorded loss. One consequence: the first caller's `{ timeout }`
828
+ * governs; a later caller's is ignored.
829
+ *
830
+ * @param {{timeout?: number}} [options]
831
+ * @returns {Promise<void>} Resolves clean, or rejects DELIVERY_FAILED or SHUTDOWN_TIMEOUT
832
+ */
833
+ const shutdown = function ( { timeout = 0 } = {} ) {
834
+ if ( !shutdownPromise ) {
835
+ // Flip the flag first, so a concurrent getHealth() reads
836
+ // red and write() refuses new rows (SHUTTING_DOWN). Then stop
837
+ // the timer: the drain's final flush is the last one.
838
+ shuttingDown = true;
839
+ clearInterval( flushTimer );
840
+ shutdownPromise = drain( timeout );
841
+ }
842
+ return shutdownPromise;
843
+ }; // shutdown()
844
+
845
+ return { write, flush, shutdown, getPressure, getHealth };
846
+ }; // createFlushEngine()
847
+
848
+ // ============================================================================
849
+ // EXPORTS
850
+ // ============================================================================
851
+
852
+ export { createFlushEngine };