@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.
- package/README.md +2 -2
- 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 +92 -24
- 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 +542 -862
- package/src/core/storage-manager/questdb/persist-plan.js +128 -167
- package/src/core/storage-manager/questdb/resolve-options.js +266 -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,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
|
+
};
|