@winkjs/composer 0.6.0 → 0.7.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.
- package/README.md +1 -1
- package/package.json +2 -2
- package/src/composer.js +1 -1
- package/src/core/emitter-manager/mqtt/connection.js +209 -0
- package/src/core/emitter-manager/mqtt/constants.js +43 -29
- package/src/core/emitter-manager/mqtt/emitter.js +103 -658
- package/src/core/emitter-manager/mqtt/health.js +151 -0
- package/src/core/emitter-manager/mqtt/index.js +68 -4
- package/src/core/emitter-manager/mqtt/publish.js +292 -0
- package/src/core/emitter-manager/mqtt/resolve-config.js +312 -0
- package/src/core/emitter-manager/mqtt/shutdown-drain.js +247 -0
- package/src/core/emitter-manager/terminal/index.js +16 -1
- package/src/core/env-vars.js +99 -13
- package/src/core/logger/index.js +1 -1
- package/src/core/source-manager/mqtt/client.js +197 -63
- package/src/core/source-manager/mqtt/dedup.js +16 -11
- package/src/core/source-manager/mqtt/index.js +32 -5
- package/src/core/source-manager/mqtt/status.js +168 -56
- package/src/core/storage-manager/questdb/address-policy.js +264 -0
- package/src/core/storage-manager/questdb/delivery-gate.js +218 -0
- package/src/core/storage-manager/questdb/flush-engine.js +852 -0
- package/src/core/storage-manager/questdb/flush-tracker.js +331 -0
- package/src/core/storage-manager/questdb/index.js +574 -847
- package/src/core/storage-manager/questdb/persist-plan.js +128 -167
- package/src/core/storage-manager/questdb/resolve-options.js +403 -0
- package/src/core/storage-manager/questdb/shutdown-drain.js +181 -0
- package/src/core/storage-manager/questdb/skip-warnings.js +180 -0
- package/src/core/test-utils/tcp-proxy.js +194 -1
- package/src/core/utils/address/index.js +362 -0
- package/src/core/utils/address/probe.js +210 -0
- package/src/core/utils/callback-guard/index.js +23 -2
- package/src/core/utils/clock/index.js +34 -0
- package/src/core/utils/jitter/index.js +51 -0
- package/src/core/utils/line-rate/index.js +104 -0
- package/src/core/utils/quantize/index.js +43 -19
- package/src/core/wiring/wire-emitters.js +3 -1
- package/src/core/wiring/wire-storages.js +10 -2
- package/src/flow/flow.js +24 -3
- 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 };
|