@winkjs/composer 0.6.0 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/package.json +2 -2
- package/src/composer.js +1 -1
- package/src/core/emitter-manager/mqtt/connection.js +209 -0
- package/src/core/emitter-manager/mqtt/constants.js +43 -29
- package/src/core/emitter-manager/mqtt/emitter.js +103 -658
- package/src/core/emitter-manager/mqtt/health.js +151 -0
- package/src/core/emitter-manager/mqtt/index.js +68 -4
- package/src/core/emitter-manager/mqtt/publish.js +292 -0
- package/src/core/emitter-manager/mqtt/resolve-config.js +312 -0
- package/src/core/emitter-manager/mqtt/shutdown-drain.js +247 -0
- package/src/core/emitter-manager/terminal/index.js +16 -1
- package/src/core/env-vars.js +99 -13
- package/src/core/logger/index.js +1 -1
- package/src/core/source-manager/mqtt/client.js +197 -63
- package/src/core/source-manager/mqtt/dedup.js +16 -11
- package/src/core/source-manager/mqtt/index.js +32 -5
- package/src/core/source-manager/mqtt/status.js +168 -56
- package/src/core/storage-manager/questdb/address-policy.js +264 -0
- package/src/core/storage-manager/questdb/delivery-gate.js +218 -0
- package/src/core/storage-manager/questdb/flush-engine.js +852 -0
- package/src/core/storage-manager/questdb/flush-tracker.js +331 -0
- package/src/core/storage-manager/questdb/index.js +574 -847
- package/src/core/storage-manager/questdb/persist-plan.js +128 -167
- package/src/core/storage-manager/questdb/resolve-options.js +403 -0
- package/src/core/storage-manager/questdb/shutdown-drain.js +181 -0
- package/src/core/storage-manager/questdb/skip-warnings.js +180 -0
- package/src/core/test-utils/tcp-proxy.js +194 -1
- package/src/core/utils/address/index.js +362 -0
- package/src/core/utils/address/probe.js +210 -0
- package/src/core/utils/callback-guard/index.js +23 -2
- package/src/core/utils/clock/index.js +34 -0
- package/src/core/utils/jitter/index.js +51 -0
- package/src/core/utils/line-rate/index.js +104 -0
- package/src/core/utils/quantize/index.js +43 -19
- package/src/core/wiring/wire-emitters.js +3 -1
- package/src/core/wiring/wire-storages.js +10 -2
- package/src/flow/flow.js +24 -3
- package/src/flow/run.js +24 -2
|
@@ -0,0 +1,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 };
|