@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,266 @@
1
+ // core/storage-manager/questdb/resolve-options.js
2
+
3
+ /**
4
+ * @fileoverview Turns the QuestDB storage options into the settings the
5
+ * adapter runs on.
6
+ *
7
+ * The adapter accepts options from two places: the `.storage()` config
8
+ * in the flow, and the `QUESTDB_*` environment variables read by
9
+ * `env-vars.js`. This module merges the two, fills in the defaults, and
10
+ * derives the buffer ceiling from the flush threshold. It also computes
11
+ * the deadline of one flush from the rows that flush carries. It is a
12
+ * pure function of its inputs, so it can be tested without a flow, a
13
+ * client, or a live environment. The adapter calls `resolveOptions`
14
+ * once, at setup, and `flushDeadlineFor` once per flush. Nothing here
15
+ * runs on the per-row path.
16
+ *
17
+ * Why the derived values live here and not in `env-vars.js`. The buffer
18
+ * ceiling defaults to ten times the flush threshold. A fixed ceiling in
19
+ * the environment would fail setup whenever a user raised the threshold
20
+ * in config without also raising the ceiling. So the environment carries
21
+ * a value only when the operator set one, and this module derives the
22
+ * rest from what actually won.
23
+ *
24
+ * Why the deadline depends on the row count. The client gives a bigger
25
+ * request more time: its request timeout is an inactivity timeout that
26
+ * grows with the bytes sent, at a planning speed of 100 KiB per second
27
+ * (`request_min_throughput`, a 4.2.0 fact). A fixed deadline would
28
+ * abandon a large but healthy send over a slow link and report rows
29
+ * lost that then land. So the deadline is the client's worst case for
30
+ * that batch, plus a margin. One attempt is the request timeout plus
31
+ * the transfer time at a planning size of 512 bytes a row. The client
32
+ * checks its retry window only when an attempt ends, so its last
33
+ * attempt can run in full past the window. The deadline is therefore
34
+ * the retry window, two attempts, the longest backoff, and 5 seconds.
35
+ * One row gets about 36 seconds; 5000 rows get 86 seconds; a full
36
+ * 50000-row catch-up send gets about 9 minutes. When the operator
37
+ * sets `flushDeadlineMs`, that fixed value is used for every flush.
38
+ *
39
+ * Precedence, highest first (ADR-018 §10): the key in the config
40
+ * (`flushRows`), then the environment variable (`QUESTDB_FLUSH_ROWS`),
41
+ * then the default.
42
+ *
43
+ * The five legacy keys of 0.7.0 (`flushMode`, `idleFlushAfterMs`,
44
+ * `idleFlushCheckMs`, `autoFlushRows`, `autoFlushIntervalMs`) are gone
45
+ * since 0.8.0 (ADR-029 item 10). The adapter's schema refuses each one
46
+ * as an unknown key, and `env-vars.js` refuses their `QUESTDB_*`
47
+ * variables at import. Nothing here looks for them.
48
+ *
49
+ * The one relation enforced here: the ceiling is never below twice
50
+ * the threshold. Rows in flight count against the ceiling, so a
51
+ * ceiling equal to the threshold would refuse every row that arrives
52
+ * during a row-triggered flush. Twice the threshold holds one batch
53
+ * in flight and one batch buffering. A smaller ceiling fails setup
54
+ * with `INVALID_CONFIG`.
55
+ *
56
+ * The client constants used in the deadline are `@questdb/nodejs-client`
57
+ * 4.2.0 facts, cited by line in the client's `dist/es/index.mjs`. The
58
+ * defaults sit at lines 230 to 234: `request_min_throughput` 102400
59
+ * bytes a second, `request_timeout` 10 s, `retry_timeout` 10 s. The
60
+ * throughput option is documented at line 404 and read at line 275.
61
+ * The request timeout grows with the bytes sent at line 1142.
62
+ *
63
+ * The retry clock starts when the first attempt ends, at line 1183.
64
+ * The window is checked when a later attempt ends, at lines 1186 to
65
+ * 1188. The backoff between attempts doubles and stops at 1000 ms,
66
+ * with a jitter of up to 5 ms, at lines 1192 to 1194. Re-verify these
67
+ * lines on a client upgrade. A configured `requestTimeout` or
68
+ * `retryTimeout` replaces its default in the sum, so an operator who
69
+ * lengthens a timeout lengthens the deadline with it.
70
+ *
71
+ * The transport setting has two vocabularies. In config, `stdlibHttp`
72
+ * is a boolean. In the environment, `QUESTDB_STDLIB_HTTP` takes the
73
+ * client's own words `on` and `off`. This module maps the words to the
74
+ * boolean, and the default is the standard-library transport
75
+ * (`DEFAULT_STDLIB_HTTP` explains why).
76
+ *
77
+ * @see ADR-029
78
+ */
79
+
80
+ // ============================================================================
81
+ // DEFAULTS
82
+ // ============================================================================
83
+
84
+ /**
85
+ * Rows that trigger a flush from inside `write()`. At the measured 130
86
+ * to 290 bytes a row this is 0.65 to 1.5 MB per request, sized for an
87
+ * edge device and a slow plant link. Larger deployments raise it by
88
+ * config.
89
+ * @type {number}
90
+ */
91
+ const DEFAULT_FLUSH_ROWS = 5000;
92
+
93
+ /**
94
+ * Period of the flush timer. Rows land within about this long after
95
+ * they were written, busy stream or idle stream alike, and a power cut
96
+ * costs at most about this much data.
97
+ * @type {number}
98
+ */
99
+ const DEFAULT_FLUSH_INTERVAL_MS = 1000;
100
+
101
+ /**
102
+ * The ceiling is this many flush thresholds. Past it, `write()` sheds
103
+ * new rows with `STORAGE_FULL`. With flushes held while the endpoint is
104
+ * unreachable (ADR-029), the ceiling is the outage the adapter rides
105
+ * through without loss: ten batches hold 50 seconds at 1000 rows a
106
+ * second and hours at plant rate, in 6.5 to 15 MB of memory.
107
+ * @type {number}
108
+ */
109
+ const DEFAULT_CEILING_MULTIPLIER = 10;
110
+
111
+ /**
112
+ * The client's standard-library HTTP transport is selected unless the
113
+ * operator opts out. It is the transport whose requests end: a refused
114
+ * connection rejects at once, and a stalled request ends within the
115
+ * retry window plus one request timeout. The client's own default,
116
+ * undici, retries a refused connection without end, and its abort
117
+ * cannot end that retry (ADR-029).
118
+ * @type {boolean}
119
+ */
120
+ const DEFAULT_STDLIB_HTTP = true;
121
+
122
+ /**
123
+ * The client's default `request_timeout` (4.2.0 fact).
124
+ * @type {number}
125
+ */
126
+ const CLIENT_DEFAULT_REQUEST_TIMEOUT_MS = 10000;
127
+
128
+ /**
129
+ * The client's default `retry_timeout` (4.2.0 fact).
130
+ * @type {number}
131
+ */
132
+ const CLIENT_DEFAULT_RETRY_TIMEOUT_MS = 10000;
133
+
134
+ /**
135
+ * The client's default `request_min_throughput`, in bytes per second
136
+ * (4.2.0 fact). The client extends its request timeout by the time the
137
+ * request would take at this speed.
138
+ * @type {number}
139
+ */
140
+ const CLIENT_DEFAULT_MIN_THROUGHPUT_BPS = 102400;
141
+
142
+ /**
143
+ * Planning size of one row for the deadline, above the widest row
144
+ * measured (291 bytes for 16 float columns). A row wider than this over
145
+ * a link at the planning speed needs an explicit `flushDeadlineMs`.
146
+ * @type {number}
147
+ */
148
+ const ROW_BYTES_PLANNING = 512;
149
+
150
+ /**
151
+ * The longest pause the client takes between two attempts (4.2.0 fact:
152
+ * the backoff doubles from 10 ms and stops at 1000 ms).
153
+ * @type {number}
154
+ */
155
+ const CLIENT_MAX_RETRY_BACKOFF_MS = 1000;
156
+
157
+ /**
158
+ * Added on top of the client's own bound, so the client gets to reject
159
+ * first with a real error and the deadline stays a backstop for a hang
160
+ * the client cannot see.
161
+ * @type {number}
162
+ */
163
+ const FLUSH_DEADLINE_MARGIN_MS = 5000;
164
+
165
+ // ============================================================================
166
+ // RESOLUTION
167
+ // ============================================================================
168
+
169
+ /**
170
+ * Maps the words of `QUESTDB_STDLIB_HTTP` to a boolean. The words are
171
+ * the client's own for `stdlib_http`, `on` and `off`, so an operator
172
+ * reads one vocabulary in the variable and in the config string.
173
+ * `env-vars.js` has already refused any other word.
174
+ *
175
+ * @param {string|undefined} word - `'on'`, `'off'`, or undefined when unset
176
+ * @returns {boolean|undefined} The boolean, or undefined when unset
177
+ */
178
+ const onOffToBoolean = function ( word ) {
179
+ if ( word === undefined ) {
180
+ return undefined;
181
+ }
182
+ return word === 'on';
183
+ }; // onOffToBoolean()
184
+
185
+ /**
186
+ * Resolves the storage options into the adapter's settings.
187
+ * `flushDeadlineMs` is undefined unless the operator set it; the engine
188
+ * then derives each flush's deadline with `flushDeadlineFor`.
189
+ *
190
+ * @param {Object} options - The raw storage options from `.storage()` config
191
+ * @param {Object} envVars - The `ENV_VARS` object (injected, for purity)
192
+ * @returns {Object} The resolved settings
193
+ * @throws {Error} INVALID_CONFIG when the ceiling is below twice the threshold
194
+ */
195
+ const resolveOptions = function ( options, envVars ) {
196
+ const flushRows = options.flushRows ?? envVars.questdbFlushRows ?? DEFAULT_FLUSH_ROWS;
197
+ const flushIntervalMs = options.flushIntervalMs ?? envVars.questdbFlushIntervalMs ?? DEFAULT_FLUSH_INTERVAL_MS;
198
+ const bufferCeilingRows = options.bufferCeilingRows ??
199
+ envVars.questdbBufferCeilingRows ??
200
+ ( flushRows * DEFAULT_CEILING_MULTIPLIER );
201
+
202
+ // Rows in flight count against the ceiling. A ceiling equal to the
203
+ // threshold would refuse every row that arrives during a
204
+ // row-triggered flush, because the batch in flight fills it. Twice
205
+ // the threshold is the least that holds one batch in flight and
206
+ // one batch buffering.
207
+ if ( bufferCeilingRows < ( 2 * flushRows ) ) {
208
+ const err = new Error(
209
+ `winkComposer/questdb: bufferCeilingRows ${bufferCeilingRows} is below twice flushRows ${flushRows} [INVALID_CONFIG]: ` +
210
+ 'the ceiling must hold one batch in flight and one batch buffering; raise bufferCeilingRows or lower flushRows'
211
+ );
212
+ err.code = 'INVALID_CONFIG';
213
+ throw err;
214
+ }
215
+
216
+ return {
217
+ ilpUrl: options.ilpUrl ?? envVars.questdbIlpUrl,
218
+ pgUrl: options.pgUrl ?? envVars.questdbPgUrl,
219
+ flushRows,
220
+ flushIntervalMs,
221
+ bufferCeilingRows,
222
+ flushDeadlineMs: options.flushDeadlineMs ?? envVars.questdbFlushDeadlineMs,
223
+ stdlibHttp: options.stdlibHttp ?? onOffToBoolean( envVars.questdbStdlibHttp ) ?? DEFAULT_STDLIB_HTTP,
224
+ requestTimeout: options.requestTimeout ?? envVars.questdbRequestTimeout,
225
+ retryTimeout: options.retryTimeout ?? envVars.questdbRetryTimeout,
226
+ initBufSize: options.initBufSize ?? envVars.questdbInitBufSize,
227
+ maxBufSize: options.maxBufSize ?? envVars.questdbMaxBufSize,
228
+ partitionBy: options.partitionBy ?? 'DAY',
229
+ onWarning: options.onWarning,
230
+ onDeliveryFailure: options.onDeliveryFailure
231
+ };
232
+ }; // resolveOptions()
233
+
234
+ /**
235
+ * The deadline of one flush, in milliseconds. A fixed `flushDeadlineMs`
236
+ * wins. Otherwise it is the client's worst case for a batch of this
237
+ * many rows plus a margin. One attempt lasts the request timeout plus
238
+ * the transfer time at the planning speed and row size (see the
239
+ * header). The client checks its retry window only when an attempt
240
+ * ends, and it starts that clock when the first attempt ends. So the
241
+ * last attempt can begin just inside the window and run in full. The
242
+ * worst case is two attempts around the window, plus the longest
243
+ * backoff between attempts. A configured `retryTimeout` or
244
+ * `requestTimeout` replaces the client default in that sum. One
245
+ * multiply per flush, nothing per row.
246
+ *
247
+ * @param {number} rows - Rows the flush carries
248
+ * @param {Object} settings - The resolved settings
249
+ * @returns {number} The deadline in milliseconds
250
+ */
251
+ const flushDeadlineFor = function ( rows, settings ) {
252
+ if ( settings.flushDeadlineMs !== undefined ) {
253
+ return settings.flushDeadlineMs;
254
+ }
255
+ const retryTimeout = settings.retryTimeout ?? CLIENT_DEFAULT_RETRY_TIMEOUT_MS;
256
+ const requestTimeout = settings.requestTimeout ?? CLIENT_DEFAULT_REQUEST_TIMEOUT_MS;
257
+ const transferMs = Math.ceil( ( rows * ROW_BYTES_PLANNING * 1000 ) / CLIENT_DEFAULT_MIN_THROUGHPUT_BPS );
258
+ const attemptMs = requestTimeout + transferMs;
259
+ return retryTimeout + ( 2 * attemptMs ) + CLIENT_MAX_RETRY_BACKOFF_MS + FLUSH_DEADLINE_MARGIN_MS;
260
+ }; // flushDeadlineFor()
261
+
262
+ // ============================================================================
263
+ // EXPORTS
264
+ // ============================================================================
265
+
266
+ export { resolveOptions, flushDeadlineFor, CLIENT_MAX_RETRY_BACKOFF_MS };
@@ -0,0 +1,181 @@
1
+ // core/storage-manager/questdb/shutdown-drain.js
2
+
3
+ /**
4
+ * @fileoverview The drain-then-close shutdown of the QuestDB adapter
5
+ * (ADR-018 §7, ADR-029).
6
+ *
7
+ * A clean shutdown resolve is a delivery statement: everything
8
+ * buffered or in flight was delivered. The drain therefore settles
9
+ * every flush still in the ledger (engine, recovery, explicit) plus one
10
+ * final flush for whatever is still buffered. It never fires a blind
11
+ * flush at a buffer an earlier copy-out emptied. That blind flush is
12
+ * what once let shutdown report clean over a hung flush.
13
+ *
14
+ * The caller's `{ timeout }` bounds the whole drain. Without one, the
15
+ * deadline on each flush (`flush-tracker.js`) bounds it instead, so a
16
+ * shutdown with no budget still ends. On loss the drain rejects with a
17
+ * classified error carrying `dropped: { count }`, exact:
18
+ * - `DELIVERY_FAILED` when any awaited flush failed or was abandoned.
19
+ * The first error is on `cause`; the count is the rows on the
20
+ * flushes that failed.
21
+ * - `SHUTDOWN_TIMEOUT` when the drain did not settle within the budget.
22
+ * The count is the rows not confirmed delivered.
23
+ *
24
+ * `dropped` is a statement about this session: those rows were not
25
+ * confirmed delivered before close. An abandoned flush may still land
26
+ * its rows if the server recovers, so the count is a floor on
27
+ * uncertainty, not a proof of loss.
28
+ *
29
+ * How the tally works. Each entry in the ledger has an `onDone`
30
+ * handler owned by whoever started the flush. The drain wraps that
31
+ * handler: the owner's handler still runs, and the drain adds the
32
+ * entry's rows to delivered or failed. The wrapped promises never
33
+ * reject, so the only rejection the race can surface is the timeout.
34
+ *
35
+ * The transport close runs on every path, clean or lossy. The client's
36
+ * own `close()` is an empty function on its HTTP transports (a 4.2.0
37
+ * fact). What ends a request still on the wire is the agent the
38
+ * factory owns: `closeTransport` closes the sender and then destroys
39
+ * that agent (ADR-029). In a flow, the shutdown manager's forced exit
40
+ * is the final backstop. A close failure on a loss path is logged,
41
+ * because the loss report matters more than the close.
42
+ *
43
+ * @see ADR-018
44
+ * @see ADR-029
45
+ */
46
+
47
+ import { logger } from '../../logger/index.js';
48
+
49
+ /** The final flush has no owner of its own: the drain tallies it. */
50
+ const NOOP = function () {
51
+ // Intentionally empty.
52
+ }; // NOOP()
53
+
54
+ /**
55
+ * Races the drain against the caller's time budget. No budget (0 or
56
+ * undefined) means no race. The timer is `unref`'d and cleared however
57
+ * the race ends, so a finished drain leaves nothing behind. The losing
58
+ * side is left as it is: the drain never rejects, and a cleared timer
59
+ * never fires.
60
+ *
61
+ * @param {Promise} drain - The drain; it settles but never rejects
62
+ * @param {number} timeoutMs - Budget in ms; 0 or absent disables the race
63
+ * @returns {Promise} Settles with the drain, or rejects SHUTDOWN_TIMEOUT
64
+ */
65
+ const raceTimeout = function ( drain, timeoutMs ) {
66
+ if ( !( timeoutMs > 0 ) ) {
67
+ return drain;
68
+ }
69
+ let timer = null;
70
+ const timeout = new Promise( function ( resolve, reject ) {
71
+ timer = setTimeout( function () {
72
+ const err = new Error( `final flush did not settle within ${timeoutMs} ms` );
73
+ err.code = 'SHUTDOWN_TIMEOUT';
74
+ reject( err );
75
+ }, timeoutMs );
76
+ timer.unref();
77
+ } );
78
+ return Promise.race( [ drain, timeout ] ).finally( function () {
79
+ clearTimeout( timer );
80
+ } );
81
+ }; // raceTimeout()
82
+
83
+ /**
84
+ * Builds the drain over the engine's sender and ledger.
85
+ *
86
+ * @param {Object} parts - What the drain runs on
87
+ * @param {Object} parts.sender - The connected ILP sender
88
+ * @param {{entries: Set}} parts.ledger - The tracker's ledger of flushes in flight
89
+ * @param {function} parts.track - The tracker's `track`, for the final flush
90
+ * @param {function} parts.takeBufferedRows - `() => number`: returns the buffered count and zeroes it
91
+ * @param {function} parts.closeTransport - `() => Promise<void>`: closes the sender, then destroys the factory's agent
92
+ * @returns {function} `drain( timeoutMs ) => Promise<void>`
93
+ */
94
+ const createShutdownDrain = function ( { sender, ledger, track, takeBufferedRows, closeTransport } ) {
95
+
96
+ /**
97
+ * Best-effort transport close on the lossy path.
98
+ *
99
+ * @returns {Promise<void>} Resolves whether or not the close worked
100
+ */
101
+ const closeQuietly = function () {
102
+ return closeTransport().catch( function ( closeErr ) {
103
+ logger.error( `winkComposer/questdb: transport close failed during lossy shutdown: ${closeErr.message}` );
104
+ } );
105
+ }; // closeQuietly()
106
+
107
+ /**
108
+ * Runs the drain once. The engine latches the returned promise so
109
+ * every caller receives this one outcome.
110
+ *
111
+ * @param {number} timeoutMs - The caller's budget; 0 means none
112
+ * @returns {Promise<void>} Resolves clean, or rejects classified
113
+ */
114
+ const drain = async function ( timeoutMs ) {
115
+ let totalRows = 0;
116
+ let deliveredRows = 0;
117
+ let failedRows = 0;
118
+ let firstFailure = null;
119
+ const waits = [];
120
+
121
+ const awaitDelivery = function ( entry ) {
122
+ totalRows += entry.rows;
123
+ const ownerDone = entry.onDone;
124
+ waits.push( new Promise( function ( resolve ) {
125
+ entry.onDone = function ( err, abandoned ) {
126
+ ownerDone( err, abandoned );
127
+ if ( err ) {
128
+ failedRows += entry.rows;
129
+ if ( !firstFailure ) {
130
+ firstFailure = err;
131
+ }
132
+ } else {
133
+ deliveredRows += entry.rows;
134
+ }
135
+ resolve();
136
+ };
137
+ } ) );
138
+ }; // awaitDelivery()
139
+
140
+ // Everything delivery still owes: flushes already in flight
141
+ // (their rows left the buffer at their call) plus one final
142
+ // flush for whatever is still buffered.
143
+ ledger.entries.forEach( awaitDelivery );
144
+ const buffered = takeBufferedRows();
145
+ if ( buffered > 0 ) {
146
+ awaitDelivery( track( sender.flush(), buffered, NOOP ) );
147
+ }
148
+
149
+ if ( waits.length > 0 ) {
150
+ try {
151
+ await raceTimeout( Promise.all( waits ), timeoutMs );
152
+ } catch ( err ) {
153
+ await closeQuietly();
154
+ const dropped = totalRows - deliveredRows;
155
+ const timedOut = new Error(
156
+ `winkComposer/questdb: ${err.message}; ${dropped} buffered row(s) dropped`
157
+ );
158
+ timedOut.code = 'SHUTDOWN_TIMEOUT';
159
+ timedOut.dropped = { count: dropped };
160
+ throw timedOut;
161
+ }
162
+
163
+ if ( failedRows > 0 ) {
164
+ await closeQuietly();
165
+ const failure = new Error(
166
+ `winkComposer/questdb: flush failed during shutdown: ${firstFailure.message}; ${failedRows} buffered row(s) dropped`
167
+ );
168
+ failure.code = 'DELIVERY_FAILED';
169
+ failure.dropped = { count: failedRows };
170
+ failure.cause = firstFailure;
171
+ throw failure;
172
+ }
173
+ }
174
+
175
+ await closeTransport();
176
+ }; // drain()
177
+
178
+ return drain;
179
+ }; // createShutdownDrain()
180
+
181
+ export { createShutdownDrain };
@@ -0,0 +1,180 @@
1
+ // core/storage-manager/questdb/skip-warnings.js
2
+
3
+ /**
4
+ * @fileoverview How a skipped value is reported.
5
+ *
6
+ * The persist plan checks every value before a row opens. A value that
7
+ * fails its check is skipped, and the skip is reported through
8
+ * `onWarning`: a column skip lands the column as NULL, a bad designated
9
+ * timestamp skips the whole row. This module builds the functions the
10
+ * plan calls at those two sites. It owns the wording of the lines, the
11
+ * default handler, and the bound on that default.
12
+ *
13
+ * Why the default is bounded (ADR-029, the same rule as the flush loss
14
+ * line). A dead sensor publishes NaN in one column on every row. That
15
+ * is one warning line per row for as long as the sensor is dead: one
16
+ * line a second for a whole night, in a log nobody can read. So the
17
+ * default handler is bounded per column. The first two skips of an
18
+ * episode print in full. Later skips are counted, and one summary
19
+ * line prints per minute with the count, the latest reason, and the
20
+ * latest asset. A quiet minute on that column ends the episode. Rows
21
+ * skipped for a bad designated timestamp share one bound per insight
22
+ * type.
23
+ *
24
+ * The bound is on the default only. A user `onWarning` hears every
25
+ * skip with the full line, because the flow chose to listen. A user
26
+ * handler that throws is strict mode (ADR-027): the throw is the
27
+ * instruction that rejects the row, so it must reach the plan
28
+ * unchanged. Neither is bounded.
29
+ *
30
+ * Nothing here runs on the happy path. The plan calls a warning only
31
+ * when a value failed its check, and a counted skip allocates nothing:
32
+ * the line is rendered only when it prints.
33
+ *
34
+ * @see ADR-027
35
+ * @see ADR-029
36
+ */
37
+
38
+ import { createLineBound } from '../../utils/line-rate/index.js';
39
+ import { logger } from '../../logger/index.js';
40
+
41
+ /**
42
+ * The bound on the default warning (see the file header). The values
43
+ * match the flush loss line in flush-engine.js, so an operator reads
44
+ * one rhythm.
45
+ */
46
+ const FULL_WARNING_LINES_PER_EPISODE = 2;
47
+ const WARNING_SUMMARY_INTERVAL_MS = 60000;
48
+
49
+ /**
50
+ * Default warning handler for invalid column values: one `logger.warn`
51
+ * line in winkComposer format. The skip sites reach it through the
52
+ * per-column line bound (see the file header), so under a streak it
53
+ * prints the first two skips in full and then one summary per minute.
54
+ *
55
+ * Validation behavior:
56
+ * - null/undefined columns: skip column only (QuestDB stores NULL)
57
+ * - NaN/Infinity in numeric columns: skip column only (QuestDB stores NULL)
58
+ * - Wrong-typed column values (e.g. a number where the column is string-typed):
59
+ * skip column only, never coerce — the warning names the expected and
60
+ * received types
61
+ * - Invalid designatedTimestamp: skip entire row
62
+ *
63
+ * For strict mode (throw on any invalid), provide:
64
+ * { onWarning: (msg) => { throw new Error(msg); } }
65
+ *
66
+ * Future extension: return value may control skip-row vs skip-column behavior.
67
+ *
68
+ * @param {string} message - Warning message describing the issue
69
+ */
70
+ const defaultOnWarning = function ( message ) {
71
+ logger.warn( `winkComposer/questdb: ${message}` );
72
+ }; // defaultOnWarning()
73
+
74
+ /**
75
+ * Builds the reason text for a value that failed its phase-1 acceptance
76
+ * check. Called only on the rare skip path, so its allocations are
77
+ * acceptable (same budget as the error returns in index.js).
78
+ *
79
+ * @param {*} rawValue - the rejected value
80
+ * @param {boolean} isNumeric - whether the column type is numeric
81
+ * @param {string} expectedType - declared column type, named in the message
82
+ * @returns {string} plain reason text for the skip warning
83
+ */
84
+ const skipReason = function ( rawValue, isNumeric, expectedType ) {
85
+ if ( rawValue === null ) return 'null';
86
+ if ( rawValue === undefined ) return 'undefined';
87
+ if ( isNumeric && typeof rawValue === 'number' ) {
88
+ if ( Number.isNaN( rawValue ) ) return 'NaN';
89
+ // A finite number can only be rejected by the integer-required
90
+ // types (int64/timestamp/designated timestamp) — float64 accepts
91
+ // every finite number.
92
+ return Number.isFinite( rawValue ) ? 'non-integer' : 'non-finite';
93
+ }
94
+ return `wrong-typed (expected ${expectedType}, received ${typeof rawValue})`;
95
+ }; // skipReason()
96
+
97
+ /**
98
+ * Builds the warning function for one skip site. With a user
99
+ * `onWarning`, every skip reaches it with the full line. With the
100
+ * default, the line bound applies. Both renderers run only when a line
101
+ * prints, so a counted skip allocates nothing.
102
+ *
103
+ * @param {Function|undefined} providedOnWarning - The user's handler, or undefined for the default
104
+ * @param {Function} fullLine - `( rawValue, partitionId ) => string`, without the brand prefix
105
+ * @param {Function} summaryLine - `( count, seconds, rawValue, partitionId ) => string`, without the brand prefix
106
+ * @returns {Function} `( rawValue, partitionId ) => void`
107
+ */
108
+ const makeSkipWarning = function ( providedOnWarning, fullLine, summaryLine ) {
109
+ if ( providedOnWarning !== undefined ) {
110
+ return function ( rawValue, partitionId ) {
111
+ providedOnWarning( fullLine( rawValue, partitionId ) );
112
+ };
113
+ }
114
+ return createLineBound( {
115
+ fullLines: FULL_WARNING_LINES_PER_EPISODE,
116
+ intervalMs: WARNING_SUMMARY_INTERVAL_MS,
117
+ printFull: function ( rawValue, partitionId ) {
118
+ defaultOnWarning( fullLine( rawValue, partitionId ) );
119
+ },
120
+ printSummary: function ( count, seconds, rawValue, partitionId ) {
121
+ defaultOnWarning( summaryLine( count, seconds, rawValue, partitionId ) );
122
+ }
123
+ } );
124
+ }; // makeSkipWarning()
125
+
126
+ /**
127
+ * Builds the skip warning for one column of one insight type.
128
+ *
129
+ * @param {Function|undefined} providedOnWarning - The user's handler, or undefined for the default
130
+ * @param {string} insightTypeName - Named in every line
131
+ * @param {string} columnName - The column the skip is about
132
+ * @param {boolean} isNumeric - Whether the column type is numeric, for the reason text
133
+ * @param {string} columnType - The declared column type, for the reason text
134
+ * @returns {Function} `( rawValue, partitionId ) => void`
135
+ */
136
+ const makeColumnWarning = function ( providedOnWarning, insightTypeName, columnName, isNumeric, columnType ) {
137
+ return makeSkipWarning(
138
+ providedOnWarning,
139
+ ( rawValue, partitionId ) => (
140
+ `column '${columnName}' is ${skipReason( rawValue, isNumeric, columnType )} ` +
141
+ `in insightType '${insightTypeName}' (asset: ${partitionId}) — column skipped`
142
+ ),
143
+ ( count, seconds, rawValue, partitionId ) => (
144
+ `column '${columnName}' skipped in ${count} more row(s) of insightType '${insightTypeName}' ` +
145
+ `in the last ${seconds} s (latest: ${skipReason( rawValue, isNumeric, columnType )}, asset: ${partitionId})`
146
+ )
147
+ );
148
+ }; // makeColumnWarning()
149
+
150
+ /**
151
+ * Builds the row-skip warning of one insight type, for a designated
152
+ * timestamp that is missing or not an integer.
153
+ *
154
+ * @param {Function|undefined} providedOnWarning - The user's handler, or undefined for the default
155
+ * @param {string} insightTypeName - Named in every line
156
+ * @param {string} designatedTimestamp - The timestamp column, named in every line
157
+ * @returns {Function} `( tsValue, partitionId ) => void`
158
+ */
159
+ const makeRowWarning = function ( providedOnWarning, insightTypeName, designatedTimestamp ) {
160
+ return makeSkipWarning(
161
+ providedOnWarning,
162
+ ( tsValue, partitionId ) => (
163
+ `designatedTimestamp '${designatedTimestamp}' is ${skipReason( tsValue, true, 'timestamp' )} ` +
164
+ `in insightType '${insightTypeName}' (asset: ${partitionId}) - row skipped`
165
+ ),
166
+ ( count, seconds, tsValue, partitionId ) => (
167
+ `${count} more row(s) of insightType '${insightTypeName}' skipped for designatedTimestamp ` +
168
+ `'${designatedTimestamp}' in the last ${seconds} s (latest: ${skipReason( tsValue, true, 'timestamp' )}, ` +
169
+ `asset: ${partitionId})`
170
+ )
171
+ );
172
+ }; // makeRowWarning()
173
+
174
+ export {
175
+ defaultOnWarning,
176
+ makeColumnWarning,
177
+ makeRowWarning,
178
+ FULL_WARNING_LINES_PER_EPISODE,
179
+ WARNING_SUMMARY_INTERVAL_MS
180
+ };