@winkjs/composer 0.5.1 → 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 +3 -3
- package/src/composer.js +1 -1
- package/src/core/batch/aoo-encoder.js +4 -4
- package/src/core/batch/sliding-window.js +2 -2
- package/src/core/codec/index.js +4 -4
- 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 +108 -633
- 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/assert-columns.js +1 -1
- package/src/core/emitter-manager/terminal/index.js +20 -5
- package/src/core/env-vars.js +143 -16
- package/src/core/logger/index.js +174 -0
- package/src/core/mqtt-protocol.js +2 -2
- package/src/core/partition-manager/get-stats.js +31 -0
- package/src/core/partition-manager/index.js +1 -0
- package/src/core/partition-manager/init.js +15 -0
- package/src/core/partition-manager/update.js +143 -87
- package/src/core/partition-manager/validate-flow.js +1 -1
- package/src/core/semantics/loader.js +1 -1
- package/src/core/semantics/warnings.js +5 -3
- package/src/core/shutdown-manager/index.js +71 -21
- package/src/core/source-manager/csv/start.js +109 -35
- package/src/core/source-manager/mqtt/client.js +276 -83
- package/src/core/source-manager/mqtt/dedup.js +19 -14
- package/src/core/source-manager/mqtt/index.js +33 -6
- package/src/core/source-manager/mqtt/status.js +211 -46
- package/src/core/source-manager/record-shape.js +55 -0
- package/src/core/source-manager/test-harness/field-generator.js +1 -1
- package/src/core/source-manager/test-harness/start.js +52 -10
- package/src/core/source-manager/test-harness/validate.js +1 -1
- package/src/core/storage-manager/questdb/address-policy.js +264 -0
- package/src/core/storage-manager/questdb/assert-columns.js +2 -2
- package/src/core/storage-manager/questdb/delivery-gate.js +218 -0
- package/src/core/storage-manager/questdb/ensure-tables.js +1 -1
- 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 +592 -827
- package/src/core/storage-manager/questdb/persist-plan.js +147 -162
- 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/tunable/helpers.js +1 -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 +176 -0
- package/src/core/utils/clock/index.js +34 -0
- package/src/core/utils/half-life/index.js +4 -4
- 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/utils/validate/composer-validators.js +2 -2
- package/src/core/utils/validate/index.js +1 -1
- package/src/core/utils/validate/validate.js +1 -1
- package/src/core/wiring/assert-handle.js +5 -5
- package/src/core/wiring/assert-module.js +2 -2
- package/src/core/wiring/wire-emitters.js +7 -4
- package/src/core/wiring/wire-linear-graph.js +1 -1
- package/src/core/wiring/wire-node.js +6 -6
- package/src/core/wiring/wire-semantics.js +1 -1
- package/src/core/wiring/wire-storages.js +14 -5
- package/src/flow/driver.js +31 -6
- package/src/flow/expand-single-input-array.js +4 -4
- package/src/flow/flow-api-schemas.js +4 -4
- package/src/flow/flow.js +62 -41
- package/src/flow/for-each.js +12 -11
- package/src/flow/get-target-array.js +2 -2
- package/src/flow/load-node-module.js +1 -1
- package/src/flow/make-node-method.js +4 -4
- package/src/flow/resolve-pick-by-field.js +1 -1
- package/src/flow/run.js +200 -40
- package/src/flow/serialize.js +1 -1
- package/src/flow/spec-builder.js +1 -1
- package/src/flow/validate.js +1 -1
- package/src/nodes/butterworth-filter/init.js +5 -4
- package/src/nodes/categorize/update.js +3 -1
- package/src/nodes/controller/update.js +2 -1
- package/src/nodes/dwell-time-tracker/update.js +2 -1
- package/src/nodes/emit-if/helpers.js +57 -11
- package/src/nodes/emit-if/init.js +1 -1
- package/src/nodes/emit-if/update.js +8 -11
- package/src/nodes/page-hinkley/update.js +2 -1
- package/src/nodes/pass-if/update.js +3 -1
- package/src/nodes/persist-if/helpers.js +60 -4
- package/src/nodes/persist-if/init.js +1 -1
- package/src/nodes/persist-if/update.js +8 -16
- package/src/nodes/persistence-check/update.js +2 -1
- package/src/nodes/sanitize/update.js +4 -2
- package/src/nodes/swing-watch/update.js +3 -2
- package/src/nodes/threshold/update.js +2 -1
- package/src/nodes/trend/compute-confidence.js +4 -4
- package/src/nodes/trend/update.js +2 -1
- package/src/nodes/winnow/update.js +3 -1
- package/src/tools/training/features.js +13 -13
- package/src/tools/training/logistic.js +1 -1
- package/src/tools/training/metrics.js +2 -2
- package/src/tools/training/scale.js +2 -2
- package/src/tools/training/validate.js +7 -7
- package/src/core/utils/console-formatter/index.js +0 -207
- package/src/nodes/archive/csv/index.js +0 -6
- package/src/nodes/archive/csv/init.js +0 -36
- package/src/nodes/archive/csv/load.js +0 -70
- package/src/nodes/archive/csv/reset.js +0 -14
- package/src/nodes/archive/csv/simple-parser.js +0 -216
- package/src/nodes/archive/csv/test.js +0 -173
- package/src/nodes/archive/csv/update.js +0 -63
- package/src/nodes/sse-emitter/index.js +0 -7
- package/src/nodes/sse-emitter/init.js +0 -105
- package/src/nodes/sse-emitter/introspect.js +0 -134
- package/src/nodes/sse-emitter/publish-to.js +0 -6
- package/src/nodes/sse-emitter/recompute.js +0 -16
- package/src/nodes/sse-emitter/reset.js +0 -19
- package/src/nodes/sse-emitter/update.js +0 -69
- package/src/nodes/sse-emitter/utils/broadcast-heartbeat.js +0 -36
- package/src/nodes/sse-emitter/utils/build-message.js +0 -31
- package/src/nodes/sse-emitter/utils/connection-manager.js +0 -45
- package/src/nodes/sse-emitter/utils/endpoint-initializer.js +0 -20
- package/src/nodes/sse-emitter/utils/health-monitor.js +0 -38
- package/src/nodes/sse-emitter/utils/qos-handler.js +0 -41
|
@@ -38,19 +38,28 @@
|
|
|
38
38
|
* strict mode (an `onWarning` that throws) rejects a bad row with the sender
|
|
39
39
|
* untouched.
|
|
40
40
|
*
|
|
41
|
-
*
|
|
42
|
-
* - @questdb/nodejs-client v4 declares sender.at() as async
|
|
43
|
-
*
|
|
44
|
-
*
|
|
45
|
-
*
|
|
46
|
-
*
|
|
41
|
+
* Row append rejections — the no-silent-failures contract:
|
|
42
|
+
* - @questdb/nodejs-client v4 declares sender.at() as async. The append
|
|
43
|
+
* runs first and synchronously; the trailing `await this.tryFlush()`
|
|
44
|
+
* does nothing, because the adapter turns the client's own flush
|
|
45
|
+
* trigger off (ADR-029). So the promise rejects only when the append
|
|
46
|
+
* itself threw, which for a validated row means the client's byte
|
|
47
|
+
* ceiling (`max_buf_size`). The row never completed. Per composer's
|
|
48
|
+
* "no silent failures" contract, that loss MUST surface.
|
|
47
49
|
*
|
|
48
|
-
*
|
|
49
|
-
* a
|
|
50
|
-
*
|
|
51
|
-
*
|
|
52
|
-
*
|
|
53
|
-
*
|
|
50
|
+
* The rejection is routed through the `onDeliveryFailure` callback,
|
|
51
|
+
* with a context of the same shape as a flush report:
|
|
52
|
+
* `{ trigger: 'append', rowsLost: 1, abandoned: false, probe: null,
|
|
53
|
+
* tableName }`. The context is one object per insight type, shared
|
|
54
|
+
* by every rejection, so a handler must not keep it.
|
|
55
|
+
* When a caller provides one, they own the response (log, alert,
|
|
56
|
+
* stop the flow). When none is provided, one classified
|
|
57
|
+
* `DELIVERY_FAILED` console line reports it and the process keeps
|
|
58
|
+
* running: an unattended deployment must report a lost row, not
|
|
59
|
+
* stop on it. One handler exists per insight type and is attached
|
|
60
|
+
* to each row's promise. The derived promise that `.catch()` creates
|
|
61
|
+
* is the one per-row allocation this file cannot avoid while `at()`
|
|
62
|
+
* is async.
|
|
54
63
|
*
|
|
55
64
|
* This separates two concerns:
|
|
56
65
|
* - `onWarning` — soft, per-row data quality (NaN in float column,
|
|
@@ -62,8 +71,13 @@
|
|
|
62
71
|
* configuration, not in the data, so every row would repeat it.
|
|
63
72
|
* The field is ignored either way; the column stores the
|
|
64
73
|
* partition id.
|
|
65
|
-
*
|
|
66
|
-
*
|
|
74
|
+
* The wording of the lines, the default handler, and the bound
|
|
75
|
+
* on that default live in `skip-warnings.js`. The default is
|
|
76
|
+
* bounded per column (ADR-029): two full lines per episode,
|
|
77
|
+
* then one summary a minute. A user `onWarning` hears every
|
|
78
|
+
* skip, and a throwing one is strict mode (ADR-027).
|
|
79
|
+
* - `onDeliveryFailure` — hard data loss: the row the client
|
|
80
|
+
* refused. Default behaviour: one DELIVERY_FAILED console line.
|
|
67
81
|
*
|
|
68
82
|
* `err.code` (setup-time throws per ADR-018 fail-fast setup):
|
|
69
83
|
* - `INVALID_CONFIG` — buildPersistPlans called with non-function
|
|
@@ -72,9 +86,9 @@
|
|
|
72
86
|
* name through a throwaway client buffer — a name is not a value, so the
|
|
73
87
|
* per-message phase 1 below cannot catch it).
|
|
74
88
|
*
|
|
75
|
-
*
|
|
76
|
-
* - `DELIVERY_FAILED` — sender.at()
|
|
77
|
-
*
|
|
89
|
+
* Runtime console classification:
|
|
90
|
+
* - `DELIVERY_FAILED` — sender.at() rejected and no `onDeliveryFailure`
|
|
91
|
+
* callback was provided. One `logger.error` line naming the table.
|
|
78
92
|
*
|
|
79
93
|
* @see docs/architecture/storage-layer.md
|
|
80
94
|
* @see ADR-018
|
|
@@ -83,33 +97,20 @@
|
|
|
83
97
|
import { SenderBufferV1 } from '@questdb/nodejs-client';
|
|
84
98
|
|
|
85
99
|
import { QUEST_WRITERS, writeAsString, createFloat64Writer } from './writers.js';
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
// ============================================================================
|
|
100
|
+
import { defaultOnWarning, makeColumnWarning, makeRowWarning } from './skip-warnings.js';
|
|
101
|
+
import { wrapCallback } from '../../utils/callback-guard/index.js';
|
|
102
|
+
import { logger } from '../../logger/index.js';
|
|
90
103
|
|
|
91
104
|
/**
|
|
92
|
-
*
|
|
93
|
-
*
|
|
94
|
-
*
|
|
95
|
-
* Validation behavior:
|
|
96
|
-
* - null/undefined columns: skip column only (QuestDB stores NULL)
|
|
97
|
-
* - NaN/Infinity in numeric columns: skip column only (QuestDB stores NULL)
|
|
98
|
-
* - Wrong-typed column values (e.g. a number where the column is string-typed):
|
|
99
|
-
* skip column only, never coerce — the warning names the expected and
|
|
100
|
-
* received types
|
|
101
|
-
* - Invalid designatedTimestamp: skip entire row
|
|
102
|
-
*
|
|
103
|
-
* For strict mode (throw on any invalid), provide:
|
|
104
|
-
* { onWarning: (msg) => { throw new Error(msg); } }
|
|
105
|
-
*
|
|
106
|
-
* Future extension: return value may control skip-row vs skip-column behavior.
|
|
107
|
-
*
|
|
108
|
-
* @param {string} message - Warning message describing the issue
|
|
105
|
+
* Console channel for the callback guard: one classified line in this
|
|
106
|
+
* adapter's family. Receives an already-safe detail string, never the
|
|
107
|
+
* raw thrown value.
|
|
109
108
|
*/
|
|
110
|
-
const
|
|
111
|
-
|
|
112
|
-
}
|
|
109
|
+
const reportCallbackFault = function ( severity, name, detail ) {
|
|
110
|
+
logger.error(
|
|
111
|
+
`winkComposer/questdb: user callback ${name} failed [CALLBACK_FAILED]: ${detail}`
|
|
112
|
+
);
|
|
113
|
+
}; // reportCallbackFault()
|
|
113
114
|
|
|
114
115
|
/**
|
|
115
116
|
* Check if column type requires numeric validation (NaN/Infinity check).
|
|
@@ -153,29 +154,6 @@ ACCEPTS.bool = ( value ) => typeof value === 'boolean';
|
|
|
153
154
|
*/
|
|
154
155
|
const acceptAny = () => true;
|
|
155
156
|
|
|
156
|
-
/**
|
|
157
|
-
* Builds the reason text for a value that failed its phase-1 acceptance
|
|
158
|
-
* check. Called only on the rare skip path, so its allocations are
|
|
159
|
-
* acceptable (same budget as the error returns in index.js).
|
|
160
|
-
*
|
|
161
|
-
* @param {*} rawValue - the rejected value
|
|
162
|
-
* @param {boolean} isNumeric - whether the column type is numeric
|
|
163
|
-
* @param {string} expectedType - declared column type, named in the message
|
|
164
|
-
* @returns {string} plain reason text for the skip warning
|
|
165
|
-
*/
|
|
166
|
-
const skipReason = function ( rawValue, isNumeric, expectedType ) {
|
|
167
|
-
if ( rawValue === null ) return 'null';
|
|
168
|
-
if ( rawValue === undefined ) return 'undefined';
|
|
169
|
-
if ( isNumeric && typeof rawValue === 'number' ) {
|
|
170
|
-
if ( Number.isNaN( rawValue ) ) return 'NaN';
|
|
171
|
-
// A finite number can only be rejected by the integer-required
|
|
172
|
-
// types (int64/timestamp/designated timestamp) — float64 accepts
|
|
173
|
-
// every finite number.
|
|
174
|
-
return Number.isFinite( rawValue ) ? 'non-integer' : 'non-finite';
|
|
175
|
-
}
|
|
176
|
-
return `wrong-typed (expected ${expectedType}, received ${typeof rawValue})`;
|
|
177
|
-
}; // skipReason()
|
|
178
|
-
|
|
179
157
|
/**
|
|
180
158
|
* Asserts every ILP name a plan will write — the table name and each column
|
|
181
159
|
* name — by driving them once through a throwaway client buffer, so the
|
|
@@ -213,7 +191,7 @@ const assertIlpNames = function ( tableName, columnNames ) {
|
|
|
213
191
|
}
|
|
214
192
|
} catch ( err ) {
|
|
215
193
|
const wrapped = new Error(
|
|
216
|
-
`
|
|
194
|
+
`winkComposer/questdb: invalid ILP ${kind} '${current}' — ${err.message}`
|
|
217
195
|
);
|
|
218
196
|
wrapped.code = 'INVALID_CONFIG';
|
|
219
197
|
wrapped.cause = err;
|
|
@@ -251,11 +229,11 @@ const assertIlpNames = function ( tableName, columnNames ) {
|
|
|
251
229
|
* @param {string} tablePrefix - Prefix for table names (typically assetClass.name)
|
|
252
230
|
* @param {Object} [options] - Optional configuration
|
|
253
231
|
* @param {function} [options.onWarning] - Soft per-row warning callback for
|
|
254
|
-
* invalid values (NaN, null, invalid timestamp). Default:
|
|
255
|
-
*
|
|
256
|
-
*
|
|
257
|
-
*
|
|
258
|
-
*
|
|
232
|
+
* invalid values (NaN, null, invalid timestamp). Default: one
|
|
233
|
+
* `logger.warn` line per skip, bounded per column (see the file header).
|
|
234
|
+
* @param {function} [options.onDeliveryFailure] - Called as
|
|
235
|
+
* `( err, { tableName } )` when `sender.at()` rejects (the client's
|
|
236
|
+
* byte ceiling). Default: one classified DELIVERY_FAILED console line.
|
|
259
237
|
* @returns {Object.<string, function>} Map of insightType to persistRow function
|
|
260
238
|
*/
|
|
261
239
|
const buildPersistPlans = function ( assetClass, tablePrefix, options ) {
|
|
@@ -264,18 +242,32 @@ const buildPersistPlans = function ( assetClass, tablePrefix, options ) {
|
|
|
264
242
|
|
|
265
243
|
if ( providedOnWarning !== undefined && typeof providedOnWarning !== 'function' ) {
|
|
266
244
|
// Per ADR-018, setup-time throws carry classified err.code.
|
|
267
|
-
const err = new Error( '
|
|
245
|
+
const err = new Error( 'winkComposer/questdb: onWarning must be a function' );
|
|
268
246
|
err.code = 'INVALID_CONFIG';
|
|
269
247
|
throw err;
|
|
270
248
|
}
|
|
271
249
|
if ( providedOnDeliveryFailure !== undefined && typeof providedOnDeliveryFailure !== 'function' ) {
|
|
272
|
-
const err = new Error( '
|
|
250
|
+
const err = new Error( 'winkComposer/questdb: onDeliveryFailure must be a function' );
|
|
273
251
|
err.code = 'INVALID_CONFIG';
|
|
274
252
|
throw err;
|
|
275
253
|
}
|
|
276
254
|
|
|
255
|
+
// onWarning stays raw on purpose. A strict-mode onWarning throws,
|
|
256
|
+
// and that throw is the instruction that rejects the row. ADR-027
|
|
257
|
+
// keeps such callbacks — ones whose throw or return the adapter
|
|
258
|
+
// acts on — out of the guard's scope. The skip sites reach it
|
|
259
|
+
// through the pre-built warnings (`skip-warnings.js`); this raw
|
|
260
|
+
// handler serves the once-per-type mismatch warning below.
|
|
277
261
|
const onWarning = providedOnWarning || defaultOnWarning;
|
|
278
|
-
|
|
262
|
+
//
|
|
263
|
+
// onDeliveryFailure only notifies, so the shared guard arms it. It
|
|
264
|
+
// was validated raw above and is wrapped once here (ADR-018: a
|
|
265
|
+
// broken handler costs its own output, never the flush chain).
|
|
266
|
+
// Absent stays null, so the no-handler DELIVERY_FAILED escape
|
|
267
|
+
// hatch below keeps its exact meaning.
|
|
268
|
+
const onDeliveryFailure = wrapCallback( providedOnDeliveryFailure || null, {
|
|
269
|
+
name: 'onDeliveryFailure', severity: 'red', report: reportCallbackFault
|
|
270
|
+
} );
|
|
279
271
|
const plansByInsightType = Object.create( null );
|
|
280
272
|
const insightTypes = assetClass.insightTypes || Object.create( null );
|
|
281
273
|
const columns = assetClass.columns;
|
|
@@ -292,13 +284,15 @@ const buildPersistPlans = function ( assetClass, tablePrefix, options ) {
|
|
|
292
284
|
// column itself, from the partition id. A persisted column with
|
|
293
285
|
// the same name would make the CREATE TABLE ask for 'assetId'
|
|
294
286
|
// twice. That happens whether the name appears in the columns
|
|
295
|
-
// list or as the designated timestamp.
|
|
296
|
-
//
|
|
297
|
-
//
|
|
298
|
-
//
|
|
287
|
+
// list or as the designated timestamp.
|
|
288
|
+
//
|
|
289
|
+
// Fail fast here and name the fix. Otherwise QuestDB answers at
|
|
290
|
+
// table creation with its raw "Duplicate column" error. A
|
|
291
|
+
// dictionary column named 'assetId' that no insightType
|
|
292
|
+
// persists stays legal.
|
|
299
293
|
if ( persistedColumnNames.includes( 'assetId' ) || designatedTimestamp === 'assetId' ) {
|
|
300
294
|
const err = new Error(
|
|
301
|
-
`
|
|
295
|
+
`winkComposer/questdb: insightType '${insightTypeName}' uses reserved column name 'assetId' — ` +
|
|
302
296
|
'composer writes this column automatically from the flow\'s .assetId() field (the partition id); ' +
|
|
303
297
|
'rename the semantics column or remove it from this insightType'
|
|
304
298
|
);
|
|
@@ -306,31 +300,34 @@ const buildPersistPlans = function ( assetClass, tablePrefix, options ) {
|
|
|
306
300
|
throw err;
|
|
307
301
|
}
|
|
308
302
|
|
|
309
|
-
// Pre-compile writer steps
|
|
310
|
-
//
|
|
311
|
-
//
|
|
303
|
+
// Pre-compile the writer steps. The designated timestamp is
|
|
304
|
+
// handled separately, via sender.at(). Every other column uses
|
|
305
|
+
// its type-specific writer.
|
|
312
306
|
const stepNames = [];
|
|
313
307
|
const stepWriters = [];
|
|
314
|
-
const stepIsNumeric = [];
|
|
315
|
-
const stepTypes = [];
|
|
316
308
|
const stepAccepts = [];
|
|
309
|
+
// One skip warning per column, pre-built here so the per-row
|
|
310
|
+
// path only calls it (see the file header for the bound).
|
|
311
|
+
const stepWarnings = [];
|
|
317
312
|
|
|
318
313
|
for ( let j = 0; j < persistedColumnNames.length; j += 1 ) {
|
|
319
314
|
const columnName = persistedColumnNames[ j ];
|
|
320
315
|
|
|
321
|
-
// Skip
|
|
316
|
+
// Skip the designated timestamp. It is handled separately, via
|
|
317
|
+
// sender.at().
|
|
322
318
|
if ( columnName !== designatedTimestamp ) {
|
|
323
319
|
const columnSpec = columns[ columnName ];
|
|
324
320
|
const columnType = columnSpec ? columnSpec.type : 'string';
|
|
325
321
|
|
|
326
322
|
stepNames.push( columnName );
|
|
327
|
-
stepIsNumeric.push( isNumericType( columnType ) );
|
|
328
|
-
stepTypes.push( columnType );
|
|
329
323
|
stepAccepts.push( ACCEPTS[ columnType ] || acceptAny );
|
|
324
|
+
stepWarnings.push( makeColumnWarning(
|
|
325
|
+
providedOnWarning, insightTypeName, columnName, isNumericType( columnType ), columnType
|
|
326
|
+
) );
|
|
330
327
|
|
|
331
|
-
// float64 columns use resolution-aware writer factory
|
|
332
|
-
//
|
|
333
|
-
//
|
|
328
|
+
// float64 columns use the resolution-aware writer factory.
|
|
329
|
+
// columnSpec exists here: columnType is 'float64' only when
|
|
330
|
+
// columnSpec.type is 'float64'.
|
|
334
331
|
if ( columnType === 'float64' ) {
|
|
335
332
|
stepWriters.push( createFloat64Writer( columnSpec.resolution ) );
|
|
336
333
|
} else {
|
|
@@ -341,6 +338,28 @@ const buildPersistPlans = function ( assetClass, tablePrefix, options ) {
|
|
|
341
338
|
|
|
342
339
|
const stepCount = stepNames.length;
|
|
343
340
|
const tableName = tablePrefix + '_' + insightTypeName;
|
|
341
|
+
const warnRowSkipped = makeRowWarning( providedOnWarning, insightTypeName, designatedTimestamp );
|
|
342
|
+
|
|
343
|
+
// One rejection handler and one shared context per insight
|
|
344
|
+
// type, built here so the per-row path allocates nothing. See
|
|
345
|
+
// the file header for the context's shape and what a rejection
|
|
346
|
+
// means.
|
|
347
|
+
const appendContext = {
|
|
348
|
+
trigger: 'append',
|
|
349
|
+
rowsLost: 1,
|
|
350
|
+
abandoned: false,
|
|
351
|
+
probe: null,
|
|
352
|
+
tableName
|
|
353
|
+
};
|
|
354
|
+
const onAppendRejected = function ( err ) {
|
|
355
|
+
if ( onDeliveryFailure ) {
|
|
356
|
+
onDeliveryFailure( err, appendContext );
|
|
357
|
+
return;
|
|
358
|
+
}
|
|
359
|
+
logger.error(
|
|
360
|
+
`winkComposer/questdb: row append failed for table '${tableName}' [DELIVERY_FAILED]: ${err.message}`
|
|
361
|
+
);
|
|
362
|
+
}; // onAppendRejected()
|
|
344
363
|
// Fail-fast at startup: a bad table or column name would otherwise
|
|
345
364
|
// throw inside the client mid-row at write time.
|
|
346
365
|
assertIlpNames( tableName, stepNames );
|
|
@@ -354,36 +373,31 @@ const buildPersistPlans = function ( assetClass, tablePrefix, options ) {
|
|
|
354
373
|
// One report carries all the information.
|
|
355
374
|
let assetIdMismatchWarned = false;
|
|
356
375
|
|
|
357
|
-
// Create closure that captures pre-resolved references
|
|
358
|
-
// Interface: persistRow(sender, message, partitionId) -> boolean
|
|
359
|
-
//
|
|
360
|
-
// false when phase 1 skipped the whole row
|
|
361
|
-
//
|
|
362
|
-
//
|
|
363
|
-
//
|
|
376
|
+
// Create the closure that captures the pre-resolved references.
|
|
377
|
+
// Interface: persistRow(sender, message, partitionId) -> boolean.
|
|
378
|
+
// It returns true when a row was opened and completed on the
|
|
379
|
+
// sender. It returns false when phase 1 skipped the whole row,
|
|
380
|
+
// so the sender was never touched.
|
|
381
|
+
//
|
|
382
|
+
// The caller's buffered-row accounting keys off that return. A
|
|
383
|
+
// skipped row must not count as buffered. Note that partitionId
|
|
384
|
+
// is the internal name. It is written as the 'assetId' column
|
|
385
|
+
// in QuestDB.
|
|
364
386
|
plansByInsightType[ insightTypeName ] = function ( sender, message, partitionId ) {
|
|
365
387
|
// ---- Phase 1: validate. No sender calls — nothing irreversible
|
|
366
388
|
// happens until every value has been checked (see file header).
|
|
367
389
|
|
|
368
|
-
//
|
|
390
|
+
// The designated timestamp first. When it is missing or not
|
|
391
|
+
// an integer, skip the entire row. Integer or bigint matches
|
|
392
|
+
// the client's own .at() validation. Probe-verified: at(
|
|
393
|
+
// ...000.5, 'ms' ) throws "Designated timestamp must be an
|
|
394
|
+
// integer or BigInt". It throws AFTER the whole row is
|
|
395
|
+
// written, so catching it here is the only place the row
|
|
396
|
+
// survives intact. A null or undefined value fails the same
|
|
397
|
+
// check, and the warning names which it was.
|
|
369
398
|
const tsValue = message[ designatedTimestamp ];
|
|
370
|
-
if ( tsValue === undefined || tsValue === null ) {
|
|
371
|
-
onWarning(
|
|
372
|
-
`designatedTimestamp '${designatedTimestamp}' is ${tsValue === null ? 'null' : 'undefined'} ` +
|
|
373
|
-
`in insightType '${insightTypeName}' (asset: ${partitionId}) - row skipped`
|
|
374
|
-
);
|
|
375
|
-
return false;
|
|
376
|
-
}
|
|
377
|
-
// Integer-or-bigint, matching the client's own .at() validation
|
|
378
|
-
// (probe-verified: at( ...000.5, 'ms' ) throws "Designated
|
|
379
|
-
// timestamp must be an integer or BigInt" — and it throws AFTER
|
|
380
|
-
// the whole row is written, so catching it here is the only
|
|
381
|
-
// place the row survives intact).
|
|
382
399
|
if ( !Number.isInteger( tsValue ) && typeof tsValue !== 'bigint' ) {
|
|
383
|
-
|
|
384
|
-
`designatedTimestamp '${designatedTimestamp}' is ${skipReason( tsValue, true, 'timestamp' )} ` +
|
|
385
|
-
`in insightType '${insightTypeName}' (asset: ${partitionId}) - row skipped`
|
|
386
|
-
);
|
|
400
|
+
warnRowSkipped( tsValue, partitionId );
|
|
387
401
|
return false;
|
|
388
402
|
}
|
|
389
403
|
|
|
@@ -391,9 +405,10 @@ const buildPersistPlans = function ( assetClass, tablePrefix, options ) {
|
|
|
391
405
|
// That column always stores the partition id. A record value
|
|
392
406
|
// that differs is almost always one mistake: trying to relabel
|
|
393
407
|
// identity in the record. It is reported once per insightType,
|
|
394
|
-
// never silently dropped (ADR-018 no-silent-failures).
|
|
395
|
-
//
|
|
396
|
-
//
|
|
408
|
+
// never silently dropped (ADR-018 no-silent-failures).
|
|
409
|
+
//
|
|
410
|
+
// The flag advances only after onWarning returns. So under
|
|
411
|
+
// strict mode, where onWarning throws, every mismatched row is
|
|
397
412
|
// rejected here with the sender untouched.
|
|
398
413
|
if ( !assetIdMismatchWarned && message.assetId !== undefined && message.assetId !== partitionId ) {
|
|
399
414
|
onWarning(
|
|
@@ -405,22 +420,20 @@ const buildPersistPlans = function ( assetClass, tablePrefix, options ) {
|
|
|
405
420
|
}
|
|
406
421
|
|
|
407
422
|
// Check every column value against its declared type. A failed
|
|
408
|
-
// check marks the column for a silent skip in phase 2
|
|
409
|
-
// stores NULL
|
|
410
|
-
//
|
|
411
|
-
// (documented strict mode) rejects the row with the
|
|
412
|
-
// untouched instead of wedging it mid-row.
|
|
413
|
-
//
|
|
414
|
-
//
|
|
415
|
-
//
|
|
423
|
+
// check marks the column for a silent skip in phase 2, where
|
|
424
|
+
// QuestDB stores NULL. That is the treatment null already gets.
|
|
425
|
+
// The warning fires HERE, before the row opens. So an onWarning
|
|
426
|
+
// that throws (documented strict mode) rejects the row with the
|
|
427
|
+
// sender untouched instead of wedging it mid-row.
|
|
428
|
+
//
|
|
429
|
+
// Composer's NaN propagation ends here exactly as before. A
|
|
430
|
+
// NaN in a numeric column fails its acceptance check and lands
|
|
431
|
+
// as a NULL column while the row survives.
|
|
416
432
|
for ( let k = 0; k < stepCount; k += 1 ) {
|
|
417
433
|
const rawValue = message[ stepNames[ k ] ];
|
|
418
434
|
stepValueOk[ k ] = ( rawValue !== null ) && ( rawValue !== undefined ) && stepAccepts[ k ]( rawValue );
|
|
419
435
|
if ( !stepValueOk[ k ] ) {
|
|
420
|
-
|
|
421
|
-
`column '${stepNames[ k ]}' is ${skipReason( rawValue, stepIsNumeric[ k ], stepTypes[ k ] )} ` +
|
|
422
|
-
`in insightType '${insightTypeName}' (asset: ${partitionId}) — column skipped`
|
|
423
|
-
);
|
|
436
|
+
stepWarnings[ k ]( rawValue, partitionId );
|
|
424
437
|
}
|
|
425
438
|
}
|
|
426
439
|
|
|
@@ -437,41 +450,13 @@ const buildPersistPlans = function ( assetClass, tablePrefix, options ) {
|
|
|
437
450
|
// about in phase 1 — before the row opened.
|
|
438
451
|
}
|
|
439
452
|
|
|
440
|
-
// Designated timestamp (ends the row in ILP).
|
|
441
|
-
//
|
|
442
|
-
//
|
|
443
|
-
//
|
|
444
|
-
// sender.at() is async — the buffer mutation is sync but the
|
|
445
|
-
// trailing `await this.tryFlush()` may fire a network flush.
|
|
446
|
-
// When that flush fails (HTTP timeout, buffer overflow at the
|
|
447
|
-
// client's max_buf_size, QDB unreachable), the rows in that
|
|
448
|
-
// batch are dropped. Composer must NOT silently lose data.
|
|
449
|
-
//
|
|
450
|
-
// Routing rules:
|
|
451
|
-
// - If a caller provided `onDeliveryFailure`, route the
|
|
452
|
-
// failure to it. The caller owns the response (log,
|
|
453
|
-
// retry-budget, alert, stop the flow).
|
|
454
|
-
// - Otherwise, throw a classified `DELIVERY_FAILED` error.
|
|
455
|
-
// Inside a .catch() handler this surfaces as an
|
|
456
|
-
// unhandled rejection — Node logs it loudly and (15+)
|
|
457
|
-
// terminates the process. Loud failure beats silent
|
|
458
|
-
// loss every time.
|
|
453
|
+
// Designated timestamp (ends the row in ILP). sender.at() is
|
|
454
|
+
// async; its promise rejects when the append itself threw
|
|
455
|
+
// (see the file header). The prebuilt handler routes the
|
|
456
|
+
// rejection; a mock at() that returns no promise is skipped.
|
|
459
457
|
const atResult = sender.at( tsValue, 'ms' );
|
|
460
458
|
if ( atResult && typeof atResult.catch === 'function' ) {
|
|
461
|
-
atResult.catch(
|
|
462
|
-
if ( onDeliveryFailure ) {
|
|
463
|
-
onDeliveryFailure( err, { tableName } );
|
|
464
|
-
return;
|
|
465
|
-
}
|
|
466
|
-
const failure = new Error(
|
|
467
|
-
`WinkComposer/questdb: silent data loss — sender.at() async flush failed for table '${tableName}': ${err.message}. ` +
|
|
468
|
-
'Rows in this batch were dropped. Per the no-silent-failures contract this is a hard failure. ' +
|
|
469
|
-
'Provide an `onDeliveryFailure` callback in the storage config to handle these explicitly.'
|
|
470
|
-
);
|
|
471
|
-
failure.code = 'DELIVERY_FAILED';
|
|
472
|
-
failure.cause = err;
|
|
473
|
-
throw failure;
|
|
474
|
-
} );
|
|
459
|
+
atResult.catch( onAppendRejected );
|
|
475
460
|
}
|
|
476
461
|
|
|
477
462
|
return true;
|
|
@@ -485,4 +470,4 @@ const buildPersistPlans = function ( assetClass, tablePrefix, options ) {
|
|
|
485
470
|
// EXPORTS
|
|
486
471
|
// ============================================================================
|
|
487
472
|
|
|
488
|
-
export { buildPersistPlans
|
|
473
|
+
export { buildPersistPlans };
|