@winkjs/composer 0.6.0 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. package/README.md +1 -1
  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 +99 -13
  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 +574 -847
  24. package/src/core/storage-manager/questdb/persist-plan.js +128 -167
  25. package/src/core/storage-manager/questdb/resolve-options.js +403 -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,264 @@
1
+ // core/storage-manager/questdb/address-policy.js
2
+
3
+ /**
4
+ * @fileoverview The setup-time address policy of the QuestDB adapter
5
+ * (ADR-030), and the classification of a connect failure.
6
+ *
7
+ * Every check here runs once, at setup, before the adapter opens a
8
+ * socket. Nothing here runs on the per-row path. The checks exist
9
+ * because the run-5 soak lost its write path for four hours to one
10
+ * name: `localhost` resolved to `::1` while QuestDB listened on
11
+ * `127.0.0.1` only. The policy that came out of that incident:
12
+ *
13
+ * - `localhost` is refused for both endpoints, at the schema (flow
14
+ * definition) and again here (the environment fallback and direct
15
+ * callers). The message names the literal to set.
16
+ * - An IPv6 literal is refused for `ilpUrl`. The client (4.2.0) splits
17
+ * the address on its first colon and cannot read one. `pgUrl`
18
+ * accepts `[::1]:8812`, because the PostgreSQL client takes a bare
19
+ * host.
20
+ * - A name other than `localhost` is allowed, with one `ADDRESS_IS_NAME`
21
+ * warning per field. Only a literal address is immune to a resolver
22
+ * that changes its answer under a running process.
23
+ * - Before any client is built, each endpoint is probed with one TCP
24
+ * connect per address it resolves to. Every address must answer, or
25
+ * setup fails with `TRANSPORT_UNREACHABLE` and a per-address report.
26
+ *
27
+ * The last two functions classify a failure to connect. The ADR-018
28
+ * error vocabulary mandates one split. An endpoint that does not
29
+ * answer is `TRANSPORT_UNREACHABLE`: check the network, the firewall,
30
+ * whether QuestDB is running. Everything else is `INVALID_CONFIG`: fix
31
+ * the supplied config. `NETWORK_ERROR_CODES` is the list of Node
32
+ * syscall codes that mean "did not answer".
33
+ *
34
+ * The adapter's `index.js` header carries the full `err.code`
35
+ * vocabulary and the operator remediation per code.
36
+ *
37
+ * @see ADR-018
38
+ * @see ADR-030
39
+ */
40
+
41
+ import { logger } from '../../logger/index.js';
42
+ import { validators } from '../../utils/validate/index.js';
43
+ import {
44
+ classifyAddress,
45
+ formatAddress,
46
+ suggestLiteral,
47
+ localhostRefusalMessage,
48
+ nameWarningMessage
49
+ } from '../../utils/address/index.js';
50
+ import { describeProbe } from '../../utils/address/probe.js';
51
+
52
+ // ============================================================================
53
+ // ADDRESS POLICY (ADR-030)
54
+ // ============================================================================
55
+
56
+ /**
57
+ * Builds a setup-time INVALID_CONFIG error in this adapter's message
58
+ * family.
59
+ *
60
+ * @param {string} message - The message clause, already in ADR-028 form
61
+ * @returns {Error} The classified error
62
+ */
63
+ const invalidConfig = function ( message ) {
64
+ const err = new Error( `winkComposer/questdb: ${message}` );
65
+ err.code = 'INVALID_CONFIG';
66
+ return err;
67
+ }; // invalidConfig()
68
+
69
+ /**
70
+ * Schema validator for `ilpUrl`: non-empty, never `localhost`, and
71
+ * never an IPv6 literal, because the client (4.2.0) splits the address
72
+ * on its first colon and cannot read one. A value the grammar cannot
73
+ * read passes; the client reports its own error for it.
74
+ *
75
+ * @param {*} value - The configured value
76
+ * @returns {boolean} Whether the value is allowed
77
+ */
78
+ const isAllowedIlpUrl = function ( value ) {
79
+ if ( !validators.nonEmptyString( value ) ) {
80
+ return false;
81
+ }
82
+ const address = classifyAddress( value, 'hostPort' );
83
+ return ( address.kind !== 'localhost' ) && ( address.family !== 6 );
84
+ }; // isAllowedIlpUrl()
85
+
86
+ /**
87
+ * Schema validator for `pgUrl`: non-empty and never `localhost`. An
88
+ * IPv6 literal is fine here; the PostgreSQL client takes a bare host.
89
+ *
90
+ * @param {*} value - The configured value
91
+ * @returns {boolean} Whether the value is allowed
92
+ */
93
+ const isAllowedPgUrl = function ( value ) {
94
+ if ( !validators.nonEmptyString( value ) ) {
95
+ return false;
96
+ }
97
+ return classifyAddress( value, 'hostPort' ).kind !== 'localhost';
98
+ }; // isAllowedPgUrl()
99
+
100
+ /**
101
+ * Classifies one address and refuses `localhost`. The schema already
102
+ * refused it at flow definition; this call covers the environment
103
+ * fallback and direct callers, and carries the classified code.
104
+ *
105
+ * @param {string} field - The config key, for the message
106
+ * @param {string} value - The address as configured
107
+ * @param {string} envVar - The environment variable that also sets it
108
+ * @returns {Object} The classified address
109
+ * @throws {Error} INVALID_CONFIG when the host is `localhost`
110
+ */
111
+ const assertNotLocalhost = function ( field, value, envVar ) {
112
+ const address = classifyAddress( value, 'hostPort' );
113
+ if ( address.kind === 'localhost' ) {
114
+ throw invalidConfig( localhostRefusalMessage( { field, address, envVar } ) );
115
+ }
116
+ return address;
117
+ }; // assertNotLocalhost()
118
+
119
+ /**
120
+ * Refuses an IPv6 literal for `ilpUrl`, naming the client limitation.
121
+ *
122
+ * @param {Object} address - The classified `ilpUrl`
123
+ * @throws {Error} INVALID_CONFIG when the host is an IPv6 literal
124
+ */
125
+ const assertIlpNotIPv6 = function ( address ) {
126
+ if ( address.family === 6 ) {
127
+ throw invalidConfig(
128
+ `ilpUrl '${formatAddress( address )}' is refused [INVALID_CONFIG]: the QuestDB client (4.2.0) ` +
129
+ 'splits the address on its first colon and cannot read an IPv6 literal; use an IPv4 ' +
130
+ `literal such as ${suggestLiteral( address )}`
131
+ );
132
+ }
133
+ }; // assertIlpNotIPv6()
134
+
135
+ /**
136
+ * Prints the one ADDRESS_IS_NAME line for a host that is a name.
137
+ *
138
+ * @param {string} field - The config key, for the message
139
+ * @param {Object} address - The classified address
140
+ */
141
+ const warnIfName = function ( field, address ) {
142
+ if ( address.kind === 'name' ) {
143
+ logger.warn( `winkComposer/questdb: ${nameWarningMessage( { field, host: address.host } )}` );
144
+ }
145
+ }; // warnIfName()
146
+
147
+ /**
148
+ * Runs the setup probe for one endpoint and fails setup unless every
149
+ * resolved address answers (ADR-030 item 4). A value the grammar could
150
+ * not read, or one without a port, is not probed: the client owns
151
+ * that error.
152
+ *
153
+ * @param {string} field - The config key, for the message
154
+ * @param {Object} address - The classified address
155
+ * @param {function} probeFn - The probe (injectable; `probeAddress` in production)
156
+ * @returns {Promise<void>} Resolves when every resolved address answered
157
+ * @throws {Error} TRANSPORT_UNREACHABLE with the per-address detail
158
+ */
159
+ const assertReachable = async function ( field, address, probeFn ) {
160
+ if ( ( address.kind === 'unparsed' ) || ( address.port === undefined ) ) {
161
+ return;
162
+ }
163
+ const outcome = await probeFn( address );
164
+ if ( outcome.ok ) {
165
+ return;
166
+ }
167
+ const err = new Error(
168
+ `winkComposer/questdb: ${field} '${formatAddress( address )}' is unreachable [TRANSPORT_UNREACHABLE]: ` +
169
+ describeProbe( outcome, field, address )
170
+ );
171
+ err.code = 'TRANSPORT_UNREACHABLE';
172
+ throw err;
173
+ }; // assertReachable()
174
+
175
+ /**
176
+ * The host and port handed to the PostgreSQL client. The parsed
177
+ * address is used when the grammar read it with a port, which is what
178
+ * lets `[::1]:8812` through. Otherwise the previous first-colon split
179
+ * stays, so pg reports its own error for a value composer cannot read.
180
+ *
181
+ * @param {string} pgUrl - The address as configured
182
+ * @param {Object} address - Its classification
183
+ * @returns {{host: string, port: number}} The connection target
184
+ */
185
+ const pgConnectionTarget = function ( pgUrl, address ) {
186
+ if ( ( address.kind !== 'unparsed' ) && ( address.port !== undefined ) ) {
187
+ return { host: address.host, port: address.port };
188
+ }
189
+ const [ host, port ] = pgUrl.split( ':' );
190
+ return { host, port: parseInt( port, 10 ) };
191
+ }; // pgConnectionTarget()
192
+
193
+ // ============================================================================
194
+ // CONNECT-FAILURE CLASSIFICATION
195
+ // ============================================================================
196
+
197
+ /**
198
+ * Node syscall codes that mean an endpoint did not answer. Used at
199
+ * setup to classify a connect failure as TRANSPORT_UNREACHABLE (the
200
+ * one split the ADR-018 error vocabulary mandates).
201
+ * Module-level Set: allocated once at load, membership check at setup.
202
+ *
203
+ * @type {Set<string>}
204
+ */
205
+ const NETWORK_ERROR_CODES = new Set( [
206
+ 'ECONNREFUSED',
207
+ 'ENOTFOUND',
208
+ 'ETIMEDOUT',
209
+ 'EHOSTUNREACH',
210
+ 'ENETUNREACH',
211
+ 'ECONNRESET',
212
+ 'EAI_AGAIN',
213
+ 'EPIPE'
214
+ ] );
215
+
216
+ /**
217
+ * Builds the ILP sender and classifies a failure. `fromConfig` may
218
+ * itself reach the endpoint (a `/settings` fetch for protocol
219
+ * negotiation), so a network code becomes `TRANSPORT_UNREACHABLE` and
220
+ * anything else `INVALID_CONFIG`, the same split as the PostgreSQL
221
+ * connect wrap in the factory. The client's error stays on `err.cause`.
222
+ *
223
+ * The agent goes to the client as its `extraOptions.agent` when one is
224
+ * given (the standard-library transport, ADR-029). With `null` the
225
+ * client keeps its own agent, which is the undici case.
226
+ *
227
+ * @param {Object} SenderClass - The client's Sender class
228
+ * @param {string} senderConfig - The sender configuration string
229
+ * @param {string} ilpUrl - The configured `ilpUrl`, for the message
230
+ * @param {Object|null} agent - The `http.Agent` the adapter owns, or null
231
+ * @returns {Promise<Object>} The connected sender
232
+ * @throws {Error} TRANSPORT_UNREACHABLE or INVALID_CONFIG, cause attached
233
+ */
234
+ const buildSender = async function ( SenderClass, senderConfig, ilpUrl, agent ) {
235
+ try {
236
+ return ( agent === null ) ?
237
+ await SenderClass.fromConfig( senderConfig ) :
238
+ await SenderClass.fromConfig( senderConfig, { agent } );
239
+ } catch ( buildErr ) {
240
+ const code = NETWORK_ERROR_CODES.has( buildErr.code ) ? 'TRANSPORT_UNREACHABLE' : 'INVALID_CONFIG';
241
+ const err = new Error(
242
+ `winkComposer/questdb: could not build the ILP sender for ilpUrl '${ilpUrl}' [${code}]: ${buildErr.message}`
243
+ );
244
+ err.code = code;
245
+ err.cause = buildErr;
246
+ throw err;
247
+ }
248
+ }; // buildSender()
249
+
250
+ // ============================================================================
251
+ // EXPORTS
252
+ // ============================================================================
253
+
254
+ export {
255
+ isAllowedIlpUrl,
256
+ isAllowedPgUrl,
257
+ assertNotLocalhost,
258
+ assertIlpNotIPv6,
259
+ warnIfName,
260
+ assertReachable,
261
+ pgConnectionTarget,
262
+ NETWORK_ERROR_CODES,
263
+ buildSender
264
+ };
@@ -0,0 +1,218 @@
1
+ // core/storage-manager/questdb/delivery-gate.js
2
+
3
+ /**
4
+ * @fileoverview Hold and probe: the gate that pauses QuestDB delivery
5
+ * while the endpoint is unreachable (ADR-029, amendment of 2026-09-05).
6
+ *
7
+ * Why it exists. Before this gate, a QuestDB restart of 30 seconds cost
8
+ * every row written during it. Each interval tick started a flush into
9
+ * the dead endpoint, each flush failed or hung, and each failure lost
10
+ * its batch. The gate stops that. After a failed or abandoned engine
11
+ * flush it runs the ADR-030 probe against `ilpUrl`: one TCP connect
12
+ * per resolved address, off the hot path. A failing probe pauses
13
+ * delivery. While paused, the engine starts no flush and rows collect
14
+ * in the client's buffer up to the ceiling. Each interval tick runs one
15
+ * probe. When a probe passes, delivery resumes and the engine sends
16
+ * everything held in one flush. A restart then costs only the batch
17
+ * that was on the wire when the port closed.
18
+ *
19
+ * What the probe finding is for. The loss report carries it, so an
20
+ * operator reads which address refused and which answered, the same
21
+ * text the setup probe prints. A passing probe after a failure changes
22
+ * nothing: the server answered the request and refused it, so the
23
+ * failure is reported and delivery goes on.
24
+ *
25
+ * The three cases after a failure:
26
+ * - Shutdown is running. Shutdown owns the outcome and reports it in
27
+ * its own rejection, so the gate only releases the engine's guard.
28
+ * - Delivery is paused, or a probe is already running. The loss is
29
+ * reported without a finding. The tick, or the running probe,
30
+ * decides what happens next.
31
+ * - Otherwise the probe runs. The engine's guard stays up meanwhile,
32
+ * so no new engine flush starts into an endpoint that may be dead.
33
+ * When the probe answers, the guard is released, the loss is
34
+ * reported with the finding, and a failing probe pauses delivery.
35
+ * The release comes first. The report runs user-facing code, and a
36
+ * report that threw before the release would leave the guard up for
37
+ * good, with no flush able to start again.
38
+ *
39
+ * Console lines (ADR-028 grammar, token `CIRCUIT_OPEN` from ADR-018
40
+ * §9). One `logger.warn` line on pause, with the held row count and
41
+ * the finding. One `logger.warn` line on resume, with the pause
42
+ * length and the held count. The resume line is warn, not info, so a
43
+ * log transport that keeps only warn and above still carries the end
44
+ * of the episode. Nothing per tick. A probe that itself
45
+ * throws or rejects counts as a failed probe: the gate must not depend
46
+ * on the probe's own robustness to decide whether to pause.
47
+ *
48
+ * The gate never calls back into the engine. `tick()` resolves to true
49
+ * when it resumed delivery, and the engine starts the catch-up flush.
50
+ * `isPaused()` feeds the engine's triggers. One probe runs at a time.
51
+ *
52
+ * The gate does tell the flush tracker's ledger about each pause and
53
+ * resume, through `onPause` and `onResume`. The ledger's ladder, the one
54
+ * function behind `getHealth()` and the edge lines, reads red while
55
+ * delivery is paused. So the red edge prints at the pause, before the
56
+ * `CIRCUIT_OPEN` line, and a later failed flush adds no second red line.
57
+ *
58
+ * @see ADR-029
59
+ * @see ADR-030
60
+ */
61
+
62
+ import { logger } from '../../logger/index.js';
63
+ import { monotonicNow } from '../../utils/clock/index.js';
64
+
65
+ /**
66
+ * Builds the gate.
67
+ *
68
+ * @param {Object} parts - What the gate runs on
69
+ * @param {Object} parts.probe - The ADR-030 probe bound to `ilpUrl`
70
+ * @param {function} parts.probe.run - `() => Promise<outcome>`
71
+ * @param {function} parts.probe.describe - `( outcome ) => string`, the finding for an operator
72
+ * @param {function} parts.heldRows - `() => number`, rows the engine holds in the buffer now
73
+ * @param {function} parts.isShuttingDown - `() => boolean`
74
+ * @param {function} parts.onPause - `( pausedSince, finding ) => void`, tells the ledger delivery paused
75
+ * @param {function} parts.onResume - `() => void`, tells the ledger delivery resumed, after the resumed line
76
+ * @returns {{afterFailure: function, tick: function, isPaused: function}}
77
+ */
78
+ const createDeliveryGate = function ( { probe, heldRows, isShuttingDown, onPause, onResume } ) {
79
+ // `paused` stops the engine's row and timer triggers. `pausedSince`
80
+ // is the wall-clock time the pause began, for health, where it
81
+ // leaves the process. `pausedAt` is the same instant on the
82
+ // stopwatch, for the resume line's length, so a step in the wall
83
+ // clock cannot change it (ADR-018). `probing` keeps one probe in
84
+ // flight at a time.
85
+ let paused = false;
86
+ let pausedSince = null;
87
+ let pausedAt = 0;
88
+ let probing = false;
89
+
90
+ /**
91
+ * Describes an outcome and never throws. The gate must not depend
92
+ * on the probe's robustness. A describer that threw would reject
93
+ * the chain in `runProbe`, and then `afterFailure` would never
94
+ * report the loss or release the engine's guard: no flush could
95
+ * start again, and nothing would say so.
96
+ *
97
+ * @param {Object} outcome - What `probe.run` resolved with
98
+ * @returns {string} The operator text, or a fallback naming the throw
99
+ */
100
+ const describeSafely = function ( outcome ) {
101
+ try {
102
+ return probe.describe( outcome );
103
+ } catch ( err ) {
104
+ return `the probe's description failed: ${err.message}`;
105
+ }
106
+ }; // describeSafely()
107
+
108
+ /**
109
+ * Runs the probe once and never rejects.
110
+ *
111
+ * @returns {Promise<{outcome: Object, finding: string}>} The outcome and its operator text
112
+ */
113
+ const runProbe = function () {
114
+ return Promise.resolve().then( probe.run ).then(
115
+ function ( outcome ) {
116
+ return { outcome, finding: describeSafely( outcome ) };
117
+ },
118
+ function ( err ) {
119
+ return { outcome: { ok: false, error: err.message }, finding: `the probe itself failed: ${err.message}` };
120
+ }
121
+ );
122
+ }; // runProbe()
123
+
124
+ /**
125
+ * Pauses delivery after a failing probe. The held count is what the
126
+ * buffer holds now; it grows until the resume flush carries it.
127
+ *
128
+ * @param {string} finding - The probe's operator text
129
+ */
130
+ const pause = function ( finding ) {
131
+ paused = true;
132
+ pausedSince = Date.now();
133
+ pausedAt = monotonicNow();
134
+ // The ledger's ladder turns red on the pause and prints its red
135
+ // edge first, so the log reads red, then paused.
136
+ onPause( pausedSince, finding );
137
+ logger.warn( `winkComposer/questdb: delivery paused, ${heldRows()} row(s) held [CIRCUIT_OPEN]: ${finding}` );
138
+ }; // pause()
139
+
140
+ /**
141
+ * Resumes delivery after a passing tick probe. The ledger hears it
142
+ * after the resumed line, so a restored line, when the ladder lands
143
+ * on green, reads after resumed.
144
+ *
145
+ * @param {string} finding - The probe's operator text
146
+ */
147
+ const resume = function ( finding ) {
148
+ const seconds = Math.round( ( monotonicNow() - pausedAt ) / 1000 );
149
+ paused = false;
150
+ pausedSince = null;
151
+ logger.warn(
152
+ `winkComposer/questdb: delivery resumed after ${seconds} s, ${heldRows()} row(s) held [CIRCUIT_OPEN]: ${finding}`
153
+ );
154
+ onResume();
155
+ }; // resume()
156
+
157
+ /**
158
+ * Routes a failed or abandoned engine flush (the three cases in the
159
+ * file header). `release()` is called exactly once, when the engine
160
+ * may start flushes again, and always before `report( probed )`,
161
+ * which is called exactly once with the probe result or null.
162
+ *
163
+ * @param {function} report - `( probed|null ) => void`, reports the loss
164
+ * @param {function} release - `() => void`, lowers the engine's guard
165
+ */
166
+ const afterFailure = function ( report, release ) {
167
+ if ( isShuttingDown() ) {
168
+ release();
169
+ return;
170
+ }
171
+ if ( paused || probing ) {
172
+ release();
173
+ report( null );
174
+ return;
175
+ }
176
+ probing = true;
177
+ runProbe().then( function ( probed ) {
178
+ probing = false;
179
+ // Release before the report: nothing runs between the two,
180
+ // and a report that throws must not leave the guard up.
181
+ release();
182
+ report( probed );
183
+ if ( !isShuttingDown() && !probed.outcome.ok ) {
184
+ pause( probed.finding );
185
+ }
186
+ } );
187
+ }; // afterFailure()
188
+
189
+ /**
190
+ * The tick while paused: one probe at a time, nothing printed.
191
+ *
192
+ * @returns {Promise<boolean>} True when this tick resumed delivery
193
+ */
194
+ const tick = function () {
195
+ if ( probing ) {
196
+ return Promise.resolve( false );
197
+ }
198
+ probing = true;
199
+ return runProbe().then( function ( probed ) {
200
+ probing = false;
201
+ if ( isShuttingDown() || !probed.outcome.ok ) {
202
+ return false;
203
+ }
204
+ resume( probed.finding );
205
+ return true;
206
+ } );
207
+ }; // tick()
208
+
209
+ return {
210
+ afterFailure,
211
+ tick,
212
+ isPaused: function () {
213
+ return paused;
214
+ }
215
+ };
216
+ }; // createDeliveryGate()
217
+
218
+ export { createDeliveryGate };