@winkjs/composer 0.5.0 → 0.5.1
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/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@winkjs/composer",
|
|
3
|
-
"version": "0.5.
|
|
3
|
+
"version": "0.5.1",
|
|
4
4
|
"description": "Composable Streaming Intelligence — turn streaming data into actionable insights, from edge to cloud",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"streaming",
|
|
@@ -27,11 +27,10 @@
|
|
|
27
27
|
},
|
|
28
28
|
"scripts": {
|
|
29
29
|
"pretest": "npm run lint",
|
|
30
|
-
"test": "mocha --recursive
|
|
31
|
-
"test:hardening": "node --expose-gc node_modules
|
|
32
|
-
"test:coverage": "c8 --reporter=html mocha --recursive
|
|
33
|
-
"lint": "eslint
|
|
34
|
-
"docs-serve": "docsify serve ./docs"
|
|
30
|
+
"test": "mocha --recursive \"src/**/*.specs.js\" --ignore \"src/**/slow-*.specs.js\"",
|
|
31
|
+
"test:hardening": "node --expose-gc node_modules/mocha/bin/mocha.js --recursive \"src/**/slow-*.specs.js\" --timeout 300000 --exit",
|
|
32
|
+
"test:coverage": "c8 --reporter=html mocha --recursive \"src/**/*.specs.js\" --ignore \"src/**/slow-*.specs.js\"",
|
|
33
|
+
"lint": "eslint \"src/**/*.js\""
|
|
35
34
|
},
|
|
36
35
|
"repository": {
|
|
37
36
|
"type": "git",
|
|
@@ -51,7 +50,8 @@
|
|
|
51
50
|
"src/tools/**/*.js",
|
|
52
51
|
"src/windowing/**/*.js",
|
|
53
52
|
"!src/**/*.specs.js",
|
|
54
|
-
"!src/**/test/"
|
|
53
|
+
"!src/**/test/",
|
|
54
|
+
"!src/core/emitter-manager/mqtt/mqtt-store.js"
|
|
55
55
|
],
|
|
56
56
|
"bugs": {
|
|
57
57
|
"url": "https://github.com/winkjs/composer/issues"
|
|
@@ -63,6 +63,7 @@
|
|
|
63
63
|
"devDependencies": {
|
|
64
64
|
"c8": "^10.1.3",
|
|
65
65
|
"chai": "^5.2.0",
|
|
66
|
+
"classic-level": "^3.0.0",
|
|
66
67
|
"eslint": "^9.21.0",
|
|
67
68
|
"eslint-plugin-security": "^4.0.0",
|
|
68
69
|
"mocha": "^11.1.0",
|
|
@@ -70,7 +71,6 @@
|
|
|
70
71
|
},
|
|
71
72
|
"dependencies": {
|
|
72
73
|
"@questdb/nodejs-client": "^4.2.0",
|
|
73
|
-
"classic-level": "^3.0.0",
|
|
74
74
|
"glob": "^13.0.6",
|
|
75
75
|
"mqtt": "^5.15.1",
|
|
76
76
|
"msgpackr": "^1.11.10",
|
|
@@ -41,7 +41,8 @@
|
|
|
41
41
|
* ├── index.js # Module surface: id, schema, factory
|
|
42
42
|
* ├── emitter.js # The emitter implementation
|
|
43
43
|
* ├── mqtt-store.js # DORMANT LevelDB store (ADR-021; kept
|
|
44
|
-
* │ # for the WAL successor, not wired
|
|
44
|
+
* │ # for the WAL successor, not wired,
|
|
45
|
+
* │ # not shipped in the npm package)
|
|
45
46
|
* ├── constants.js # Shared configuration
|
|
46
47
|
* └── test/ # Spec files
|
|
47
48
|
*
|
|
@@ -1,538 +0,0 @@
|
|
|
1
|
-
// core/emitter-manager/mqtt/mqtt-store.js
|
|
2
|
-
|
|
3
|
-
/**
|
|
4
|
-
* @fileoverview MQTT.js-Compatible Store with Pressure Monitoring
|
|
5
|
-
*
|
|
6
|
-
* DORMANT (ADR-021, 2026-07-09): not wired into the emitter. mqtt.js
|
|
7
|
-
* loses QoS-1 messages on every connection acceptance when its outgoing
|
|
8
|
-
* store is asynchronous (the erase-then-rebuild gap — ADR-021
|
|
9
|
-
* has the diagnosis and the measured loss-vs-latency curve), so the
|
|
10
|
-
* emitter runs the client's default synchronous memory store instead.
|
|
11
|
-
* This module and its specs are kept intact as the starting point for
|
|
12
|
-
* the composer-owned WAL successor that restores crash durability
|
|
13
|
-
* truthfully — outside mqtt.js, where the connack race cannot reach it.
|
|
14
|
-
*
|
|
15
|
-
* DESIGN DECISIONS:
|
|
16
|
-
*
|
|
17
|
-
* 1. DUAL KEY STORAGE
|
|
18
|
-
* - Metadata (JSON) and payload (Buffer) stored separately
|
|
19
|
-
* - Prevents Buffer corruption through JSON serialization
|
|
20
|
-
* - Enables efficient metrics calculation without loading payloads
|
|
21
|
-
*
|
|
22
|
-
* 2. CIRCUIT BREAKER PATTERN
|
|
23
|
-
* - Opens after 5 consecutive failures
|
|
24
|
-
* - Auto-recovery attempt after 60 seconds
|
|
25
|
-
* - Prevents cascade failures from corrupted storage
|
|
26
|
-
*
|
|
27
|
-
* 3. PRESSURE MONITORING
|
|
28
|
-
* - Two limits: message count (10,000) and bytes (50MB)
|
|
29
|
-
* - Configurable per deployment environment
|
|
30
|
-
* - Triggers backpressure at 80% for early warning
|
|
31
|
-
*
|
|
32
|
-
* 4. EVENTUALLY CONSISTENT METRICS
|
|
33
|
-
* - Metrics lag behind actual state by milliseconds
|
|
34
|
-
* - Acceptable for monitoring, NOT for control decisions
|
|
35
|
-
* - Trade-off: Simpler async model over perfect accuracy
|
|
36
|
-
*
|
|
37
|
-
* 5. ITERATOR SAFETY
|
|
38
|
-
* - Always close iterators in finally blocks
|
|
39
|
-
* - Prevents LevelDB handle leaks
|
|
40
|
-
* - Critical for long-running edge deployments
|
|
41
|
-
*
|
|
42
|
-
* 6. DRIVER-AGNOSTIC MISSING-KEY HANDLING
|
|
43
|
-
* - `abstract-level`'s contract for missing keys has shifted across
|
|
44
|
-
* versions: some throw `LEVEL_NOT_FOUND`, some resolve with
|
|
45
|
-
* `undefined`. Both are valid per the spec; a single store
|
|
46
|
-
* implementation must handle either.
|
|
47
|
-
* - Each `db.get()` call site normalises **both** forms inline —
|
|
48
|
-
* `undefined` from the resolve path AND `LEVEL_NOT_FOUND` from
|
|
49
|
-
* the catch path are treated as "not present". Inline (rather
|
|
50
|
-
* than helper-wrapped) to avoid an extra Promise allocation per
|
|
51
|
-
* call on the hot path (every PUBACK fires `del()`; ~10 k/s on
|
|
52
|
-
* RPi-class hardware would otherwise add ~30 KB/s of Promise
|
|
53
|
-
* churn for no functional benefit).
|
|
54
|
-
* - Without this normalisation, a missing-key `get()` would
|
|
55
|
-
* TypeError inside the `.then()` handler (`undefined.payload =
|
|
56
|
-
* ...`), trip the circuit breaker after 5 misses, and surface as
|
|
57
|
-
* a `TypeError` to the caller instead of the documented
|
|
58
|
-
* `(null, null)`.
|
|
59
|
-
*
|
|
60
|
-
* LIMITATIONS:
|
|
61
|
-
* - Metrics are not real-time (fire-and-forget consequence)
|
|
62
|
-
* - No automatic eviction when full (by design - preserve data)
|
|
63
|
-
*
|
|
64
|
-
* @module mqtt-store
|
|
65
|
-
*/
|
|
66
|
-
|
|
67
|
-
import { ClassicLevel } from 'classic-level';
|
|
68
|
-
import { Readable } from 'stream';
|
|
69
|
-
import {
|
|
70
|
-
STORE_CONFIG,
|
|
71
|
-
DEFAULT_MAX_QUEUE_SIZE,
|
|
72
|
-
DEFAULT_MAX_QUEUE_BYTES,
|
|
73
|
-
QUEUE_CRITICAL_THRESHOLD,
|
|
74
|
-
MQTT_INFLIGHT_ID_LIMIT
|
|
75
|
-
} from './constants.js';
|
|
76
|
-
|
|
77
|
-
/**
|
|
78
|
-
* `LEVEL_NOT_FOUND` err.code as a module-level constant — referenced from
|
|
79
|
-
* each `db.get()` call site's catch handler when normalising the driver's
|
|
80
|
-
* missing-key shape. See file-header design decision #6.
|
|
81
|
-
*/
|
|
82
|
-
const ERR_LEVEL_NOT_FOUND = 'LEVEL_NOT_FOUND';
|
|
83
|
-
|
|
84
|
-
/**
|
|
85
|
-
* Singleton empty payload reused on every orphan-meta replay (createStream
|
|
86
|
-
* fallback when the PAYL row is missing). Avoids `Buffer.alloc(0)` per
|
|
87
|
-
* push on the replay path. Buffers from `Buffer.alloc(0)` are immutable
|
|
88
|
-
* for practical purposes (zero length); safe to share.
|
|
89
|
-
*/
|
|
90
|
-
const EMPTY_PAYLOAD = Buffer.alloc( 0 );
|
|
91
|
-
|
|
92
|
-
/**
|
|
93
|
-
* Create MQTT.js-compatible store with metrics
|
|
94
|
-
*
|
|
95
|
-
* @param {string} path - Storage directory path
|
|
96
|
-
* @param {Object} [options] - Optional configuration
|
|
97
|
-
* @returns {Object} Store interface for MQTT.js
|
|
98
|
-
*/
|
|
99
|
-
export const createMQTTStore = function ( path, options = {} ) {
|
|
100
|
-
// Configurable limits with defaults. maxQueueSize is clamped to the
|
|
101
|
-
// MQTT id-space ceiling — see MQTT_INFLIGHT_ID_LIMIT in constants.js
|
|
102
|
-
// for the full rationale. Clamped loudly, not silently: the operator
|
|
103
|
-
// asked for capacity the protocol cannot provide.
|
|
104
|
-
const requestedQueueSize = options.maxQueueSize || DEFAULT_MAX_QUEUE_SIZE;
|
|
105
|
-
if ( requestedQueueSize > MQTT_INFLIGHT_ID_LIMIT ) {
|
|
106
|
-
console.warn(
|
|
107
|
-
`WinkComposer/mqtt-store: maxQueueSize ${requestedQueueSize} exceeds the MQTT ` +
|
|
108
|
-
`16-bit packet-id space; clamped to ${MQTT_INFLIGHT_ID_LIMIT}`
|
|
109
|
-
);
|
|
110
|
-
}
|
|
111
|
-
const limits = {
|
|
112
|
-
maxQueueSize: Math.min( requestedQueueSize, MQTT_INFLIGHT_ID_LIMIT ),
|
|
113
|
-
maxQueueBytes: options.maxQueueBytes || DEFAULT_MAX_QUEUE_BYTES
|
|
114
|
-
};
|
|
115
|
-
|
|
116
|
-
// Open without global encoding
|
|
117
|
-
const db = new ClassicLevel( path, {
|
|
118
|
-
compression: STORE_CONFIG.compression,
|
|
119
|
-
cacheSize: STORE_CONFIG.cacheSize
|
|
120
|
-
} );
|
|
121
|
-
|
|
122
|
-
// Key prefixes for metadata and payloads
|
|
123
|
-
const META = 'pkt:m:'; // JSON metadata
|
|
124
|
-
const PAYL = 'pkt:p:'; // Buffer payload
|
|
125
|
-
|
|
126
|
-
// Closed-once latch — see `close()` for rationale.
|
|
127
|
-
let closed = false;
|
|
128
|
-
|
|
129
|
-
// Metrics for pressure monitoring
|
|
130
|
-
const metrics = {
|
|
131
|
-
messageCount: 0,
|
|
132
|
-
totalBytes: 0,
|
|
133
|
-
errors: 0,
|
|
134
|
-
lastError: null
|
|
135
|
-
};
|
|
136
|
-
|
|
137
|
-
// Circuit breaker for store failures
|
|
138
|
-
const circuitBreaker = {
|
|
139
|
-
failures: 0,
|
|
140
|
-
lastFailure: 0,
|
|
141
|
-
state: 'CLOSED', // CLOSED, OPEN, HALF_OPEN
|
|
142
|
-
|
|
143
|
-
recordSuccess: function () {
|
|
144
|
-
this.failures = 0;
|
|
145
|
-
this.state = 'CLOSED';
|
|
146
|
-
},
|
|
147
|
-
|
|
148
|
-
recordFailure: function () {
|
|
149
|
-
this.failures += 1;
|
|
150
|
-
this.lastFailure = Date.now();
|
|
151
|
-
|
|
152
|
-
if ( this.failures >= 5 ) {
|
|
153
|
-
this.state = 'OPEN';
|
|
154
|
-
// Use unref() so this timer doesn't keep the process alive
|
|
155
|
-
setTimeout( () => {
|
|
156
|
-
this.state = 'HALF_OPEN';
|
|
157
|
-
}, 60000 ).unref(); // Try again after 1 minute
|
|
158
|
-
}
|
|
159
|
-
},
|
|
160
|
-
|
|
161
|
-
canAttempt: function () {
|
|
162
|
-
return this.state !== 'OPEN';
|
|
163
|
-
}
|
|
164
|
-
};
|
|
165
|
-
|
|
166
|
-
/**
|
|
167
|
-
* Initialize metrics from existing store contents.
|
|
168
|
-
*
|
|
169
|
-
* Race-aware: the put()/del() paths now do **optimistic increment**
|
|
170
|
-
* (so `getPressure()` is accurate at the call site for backpressure).
|
|
171
|
-
* If a put() fires during this scan, our snapshot is stale by the
|
|
172
|
-
* time it lands. Detect that by comparing `metrics.messageCount`
|
|
173
|
-
* before and after the iteration: if it changed, the optimistic
|
|
174
|
-
* counter has already been moved by concurrent ops, so we ADD what
|
|
175
|
-
* we found rather than overwrite.
|
|
176
|
-
*/
|
|
177
|
-
const initMetrics = async function () {
|
|
178
|
-
let count = 0;
|
|
179
|
-
let bytes = 0;
|
|
180
|
-
let iter = null;
|
|
181
|
-
|
|
182
|
-
const beforeCount = metrics.messageCount;
|
|
183
|
-
const beforeBytes = metrics.totalBytes;
|
|
184
|
-
|
|
185
|
-
try {
|
|
186
|
-
iter = db.iterator( {
|
|
187
|
-
gte: META,
|
|
188
|
-
lt: META + '\xFF',
|
|
189
|
-
valueEncoding: 'json'
|
|
190
|
-
} );
|
|
191
|
-
|
|
192
|
-
for await ( const [ key, meta ] of iter ) { // eslint-disable-line no-unused-vars
|
|
193
|
-
count += 1;
|
|
194
|
-
bytes += meta.size || 0;
|
|
195
|
-
}
|
|
196
|
-
|
|
197
|
-
// Concurrent put detection — if the optimistic counter
|
|
198
|
-
// moved while we scanned, accumulate rather than overwrite.
|
|
199
|
-
// Slight overestimate (concurrent puts may also be visible
|
|
200
|
-
// in the scan and double-counted), but that biases toward
|
|
201
|
-
// earlier pre-flight reject — safer than under-counting.
|
|
202
|
-
if ( metrics.messageCount === beforeCount && metrics.totalBytes === beforeBytes ) {
|
|
203
|
-
metrics.messageCount = count;
|
|
204
|
-
metrics.totalBytes = bytes;
|
|
205
|
-
} else {
|
|
206
|
-
metrics.messageCount += count;
|
|
207
|
-
metrics.totalBytes += bytes;
|
|
208
|
-
}
|
|
209
|
-
|
|
210
|
-
if ( options.debug && count > 0 ) {
|
|
211
|
-
console.log( `Store initialized with ${count} messages (${bytes} bytes)` );
|
|
212
|
-
}
|
|
213
|
-
} catch ( err ) {
|
|
214
|
-
if ( err.code !== 'LEVEL_NOT_FOUND' && options.debug ) {
|
|
215
|
-
console.error( 'Failed to initialize metrics:', err.message );
|
|
216
|
-
}
|
|
217
|
-
} finally {
|
|
218
|
-
if ( iter ) {
|
|
219
|
-
await iter.close();
|
|
220
|
-
}
|
|
221
|
-
}
|
|
222
|
-
};
|
|
223
|
-
|
|
224
|
-
// Initialize metrics on startup
|
|
225
|
-
initMetrics().catch( () => { /* Noop */ } );
|
|
226
|
-
|
|
227
|
-
/**
|
|
228
|
-
* MQTT.js store interface
|
|
229
|
-
*/
|
|
230
|
-
const store = {
|
|
231
|
-
put: ( packet, cb ) => {
|
|
232
|
-
if ( !packet || packet.qos === 0 ) {
|
|
233
|
-
setImmediate( cb );
|
|
234
|
-
return;
|
|
235
|
-
}
|
|
236
|
-
|
|
237
|
-
// Check circuit breaker
|
|
238
|
-
if ( !circuitBreaker.canAttempt() ) {
|
|
239
|
-
const err = new Error( 'Store circuit breaker OPEN' );
|
|
240
|
-
err.code = 'CIRCUIT_OPEN';
|
|
241
|
-
setImmediate( cb, err );
|
|
242
|
-
return;
|
|
243
|
-
}
|
|
244
|
-
|
|
245
|
-
const mid = packet.messageId;
|
|
246
|
-
if ( !mid ) {
|
|
247
|
-
setImmediate( cb, new Error( 'Missing messageId' ) );
|
|
248
|
-
return;
|
|
249
|
-
}
|
|
250
|
-
|
|
251
|
-
// Check message count pressure
|
|
252
|
-
if ( metrics.messageCount >= limits.maxQueueSize ) {
|
|
253
|
-
const err = new Error( 'QUEUE_FULL' );
|
|
254
|
-
err.code = 'QUEUE_FULL';
|
|
255
|
-
err.pressure = 1.0;
|
|
256
|
-
metrics.errors += 1;
|
|
257
|
-
metrics.lastError = err.code;
|
|
258
|
-
setImmediate( cb, err );
|
|
259
|
-
return;
|
|
260
|
-
}
|
|
261
|
-
|
|
262
|
-
// Ensure payload is a Buffer
|
|
263
|
-
const payloadBuf = Buffer.isBuffer( packet.payload ) ?
|
|
264
|
-
packet.payload :
|
|
265
|
-
Buffer.from( packet.payload || [] );
|
|
266
|
-
|
|
267
|
-
// Check byte pressure
|
|
268
|
-
if ( metrics.totalBytes + payloadBuf.length > limits.maxQueueBytes ) {
|
|
269
|
-
const err = new Error( 'STORAGE_FULL' );
|
|
270
|
-
err.code = 'STORAGE_FULL';
|
|
271
|
-
err.pressure = 1.0;
|
|
272
|
-
metrics.errors += 1;
|
|
273
|
-
metrics.lastError = err.code;
|
|
274
|
-
setImmediate( cb, err );
|
|
275
|
-
return;
|
|
276
|
-
}
|
|
277
|
-
|
|
278
|
-
// Separate metadata from payload
|
|
279
|
-
const meta = {
|
|
280
|
-
cmd: packet.cmd || 'publish',
|
|
281
|
-
messageId: mid,
|
|
282
|
-
topic: packet.topic,
|
|
283
|
-
qos: packet.qos,
|
|
284
|
-
retain: !!packet.retain,
|
|
285
|
-
dup: !!packet.dup,
|
|
286
|
-
properties: packet.properties || {},
|
|
287
|
-
size: payloadBuf.length,
|
|
288
|
-
ts: Date.now()
|
|
289
|
-
};
|
|
290
|
-
|
|
291
|
-
// **Optimistic increment** — increment metrics BEFORE the
|
|
292
|
-
// async db.batch so `getPressure()` is accurate at the
|
|
293
|
-
// call site. Without this, pressure lags the real
|
|
294
|
-
// in-flight count by one db.batch cycle, and `publishNow`'s
|
|
295
|
-
// pre-flight reject (`pressure >= 0.9`) can't keep mqtt.js
|
|
296
|
-
// from overshooting the store cap. Empirically that lag
|
|
297
|
-
// caused ~14 k of 50 k messages to be lost in sustained
|
|
298
|
-
// burst tests. Rollback in the .catch path keeps
|
|
299
|
-
// metrics consistent on failure.
|
|
300
|
-
metrics.messageCount += 1;
|
|
301
|
-
metrics.totalBytes += payloadBuf.length;
|
|
302
|
-
|
|
303
|
-
// Atomic batch write
|
|
304
|
-
db.batch( [
|
|
305
|
-
{ type: 'put', key: META + mid, value: meta, valueEncoding: 'json' },
|
|
306
|
-
{ type: 'put', key: PAYL + mid, value: payloadBuf, valueEncoding: 'buffer' }
|
|
307
|
-
] )
|
|
308
|
-
.then( () => {
|
|
309
|
-
circuitBreaker.recordSuccess();
|
|
310
|
-
setImmediate( cb );
|
|
311
|
-
} )
|
|
312
|
-
.catch( ( err ) => {
|
|
313
|
-
// Rollback the optimistic increment — the message
|
|
314
|
-
// never landed in the store.
|
|
315
|
-
metrics.messageCount -= 1;
|
|
316
|
-
metrics.totalBytes -= payloadBuf.length;
|
|
317
|
-
metrics.errors += 1;
|
|
318
|
-
metrics.lastError = err.message;
|
|
319
|
-
circuitBreaker.recordFailure();
|
|
320
|
-
setImmediate( cb, err );
|
|
321
|
-
} );
|
|
322
|
-
},
|
|
323
|
-
|
|
324
|
-
get: ( packet, cb ) => {
|
|
325
|
-
const mid = packet && packet.messageId;
|
|
326
|
-
if ( !mid ) {
|
|
327
|
-
setImmediate( cb, null, null );
|
|
328
|
-
return;
|
|
329
|
-
}
|
|
330
|
-
|
|
331
|
-
if ( !circuitBreaker.canAttempt() ) {
|
|
332
|
-
setImmediate( cb, new Error( 'Store circuit breaker OPEN' ) );
|
|
333
|
-
return;
|
|
334
|
-
}
|
|
335
|
-
|
|
336
|
-
// Normalise driver missing-key shape inline (header design
|
|
337
|
-
// decision 6):
|
|
338
|
-
// `undefined` from the resolve path AND `LEVEL_NOT_FOUND`
|
|
339
|
-
// from the catch path are both treated as "not present".
|
|
340
|
-
// Inline rather than helper-wrapped to avoid an extra Promise
|
|
341
|
-
// allocation per call on the hot path.
|
|
342
|
-
Promise.all( [
|
|
343
|
-
db.get( META + mid, { valueEncoding: 'json' } ),
|
|
344
|
-
db.get( PAYL + mid, { valueEncoding: 'buffer' } )
|
|
345
|
-
] )
|
|
346
|
-
.then( ( [ meta, payload ] ) => {
|
|
347
|
-
if ( meta === undefined ) {
|
|
348
|
-
circuitBreaker.recordSuccess();
|
|
349
|
-
setImmediate( cb, null, null );
|
|
350
|
-
return;
|
|
351
|
-
}
|
|
352
|
-
// Payload may be `undefined` for orphaned meta — leave
|
|
353
|
-
// the field unset; mqtt.js tolerates it.
|
|
354
|
-
if ( payload !== undefined ) meta.payload = payload;
|
|
355
|
-
circuitBreaker.recordSuccess();
|
|
356
|
-
setImmediate( cb, null, meta );
|
|
357
|
-
} )
|
|
358
|
-
.catch( ( err ) => {
|
|
359
|
-
if ( err && err.code === ERR_LEVEL_NOT_FOUND ) {
|
|
360
|
-
circuitBreaker.recordSuccess();
|
|
361
|
-
setImmediate( cb, null, null );
|
|
362
|
-
return;
|
|
363
|
-
}
|
|
364
|
-
circuitBreaker.recordFailure();
|
|
365
|
-
setImmediate( cb, err );
|
|
366
|
-
} );
|
|
367
|
-
},
|
|
368
|
-
|
|
369
|
-
del: ( packet, cb ) => {
|
|
370
|
-
const mid = packet && packet.messageId;
|
|
371
|
-
if ( !mid ) {
|
|
372
|
-
setImmediate( cb );
|
|
373
|
-
return;
|
|
374
|
-
}
|
|
375
|
-
|
|
376
|
-
// Read meta first (for size accounting), then batch-delete
|
|
377
|
-
// both keys. Missing meta ⇒ idempotent no-op. Driver shape
|
|
378
|
-
// normalised inline (header design decision 6) — `undefined`
|
|
379
|
-
// resolve and
|
|
380
|
-
// `LEVEL_NOT_FOUND` throw are both treated as "not present".
|
|
381
|
-
//
|
|
382
|
-
// **Optimistic decrement** — symmetric with put()'s
|
|
383
|
-
// optimistic increment. Updates metrics BEFORE the async
|
|
384
|
-
// db.batch so `getPressure()` reflects the impending
|
|
385
|
-
// del() call site. Rollback in the .catch path keeps
|
|
386
|
-
// metrics consistent on failure.
|
|
387
|
-
db.get( META + mid, { valueEncoding: 'json' } )
|
|
388
|
-
.then( ( meta ) => {
|
|
389
|
-
if ( meta === undefined ) {
|
|
390
|
-
circuitBreaker.recordSuccess();
|
|
391
|
-
setImmediate( cb );
|
|
392
|
-
return undefined;
|
|
393
|
-
}
|
|
394
|
-
const decBytes = meta.size || 0;
|
|
395
|
-
metrics.messageCount = Math.max( 0, metrics.messageCount - 1 );
|
|
396
|
-
metrics.totalBytes = Math.max( 0, metrics.totalBytes - decBytes );
|
|
397
|
-
return db.batch( [
|
|
398
|
-
{ type: 'del', key: META + mid },
|
|
399
|
-
{ type: 'del', key: PAYL + mid }
|
|
400
|
-
] ).then( () => {
|
|
401
|
-
circuitBreaker.recordSuccess();
|
|
402
|
-
setImmediate( cb );
|
|
403
|
-
} ).catch( ( err ) => {
|
|
404
|
-
// Rollback the optimistic decrement — the
|
|
405
|
-
// delete didn't actually land.
|
|
406
|
-
metrics.messageCount += 1;
|
|
407
|
-
metrics.totalBytes += decBytes;
|
|
408
|
-
throw err;
|
|
409
|
-
} );
|
|
410
|
-
} )
|
|
411
|
-
.catch( ( err ) => {
|
|
412
|
-
if ( err && err.code === ERR_LEVEL_NOT_FOUND ) {
|
|
413
|
-
circuitBreaker.recordSuccess();
|
|
414
|
-
setImmediate( cb );
|
|
415
|
-
return;
|
|
416
|
-
}
|
|
417
|
-
circuitBreaker.recordFailure();
|
|
418
|
-
setImmediate( cb, err );
|
|
419
|
-
} );
|
|
420
|
-
},
|
|
421
|
-
|
|
422
|
-
createStream: () => {
|
|
423
|
-
let iterator = null;
|
|
424
|
-
|
|
425
|
-
const stream = new Readable( {
|
|
426
|
-
objectMode: true,
|
|
427
|
-
|
|
428
|
-
async read () {
|
|
429
|
-
try {
|
|
430
|
-
if ( !iterator ) {
|
|
431
|
-
iterator = db.iterator( {
|
|
432
|
-
gte: META,
|
|
433
|
-
lt: META + '\xFF',
|
|
434
|
-
valueEncoding: 'json'
|
|
435
|
-
} );
|
|
436
|
-
}
|
|
437
|
-
|
|
438
|
-
const result = await iterator.next();
|
|
439
|
-
|
|
440
|
-
if ( result === undefined ) {
|
|
441
|
-
this.push( null );
|
|
442
|
-
await iterator.close();
|
|
443
|
-
iterator = null;
|
|
444
|
-
return;
|
|
445
|
-
}
|
|
446
|
-
|
|
447
|
-
const [ key, meta ] = result;
|
|
448
|
-
const mid = key.slice( META.length );
|
|
449
|
-
|
|
450
|
-
// Driver missing-key shape normalised inline
|
|
451
|
-
// (header design decision 6) — `undefined`
|
|
452
|
-
// resolve and `LEVEL_NOT_FOUND` throw both fall
|
|
453
|
-
// back to an empty payload.
|
|
454
|
-
let payload;
|
|
455
|
-
try {
|
|
456
|
-
payload = await db.get( PAYL + mid, { valueEncoding: 'buffer' } );
|
|
457
|
-
} catch ( err ) {
|
|
458
|
-
if ( err && err.code !== ERR_LEVEL_NOT_FOUND ) {
|
|
459
|
-
this.destroy( err );
|
|
460
|
-
return;
|
|
461
|
-
}
|
|
462
|
-
payload = undefined;
|
|
463
|
-
}
|
|
464
|
-
meta.payload = payload === undefined ? EMPTY_PAYLOAD : payload;
|
|
465
|
-
meta.dup = true;
|
|
466
|
-
this.push( meta );
|
|
467
|
-
} catch ( err ) {
|
|
468
|
-
this.destroy( err );
|
|
469
|
-
}
|
|
470
|
-
},
|
|
471
|
-
|
|
472
|
-
async destroy ( err, callback ) {
|
|
473
|
-
if ( iterator ) {
|
|
474
|
-
try {
|
|
475
|
-
await iterator.close();
|
|
476
|
-
} catch ( closeErr ) { // eslint-disable-line no-unused-vars
|
|
477
|
-
// Ignore close errors
|
|
478
|
-
}
|
|
479
|
-
}
|
|
480
|
-
callback( err );
|
|
481
|
-
}
|
|
482
|
-
} );
|
|
483
|
-
|
|
484
|
-
return stream;
|
|
485
|
-
},
|
|
486
|
-
|
|
487
|
-
close: ( cb ) => {
|
|
488
|
-
// Idempotent: mqtt.js's `closeStores` calls us during
|
|
489
|
-
// `client.end()`, AND the emitter's shutdown path may call
|
|
490
|
-
// us again. Double-closing a LevelDB triggers "Database is
|
|
491
|
-
// not open" errors that surface as phantom DELIVERY_FAILED
|
|
492
|
-
// on every in-flight publish. Short-circuit subsequent calls.
|
|
493
|
-
if ( closed ) {
|
|
494
|
-
if ( cb ) setImmediate( cb );
|
|
495
|
-
return;
|
|
496
|
-
}
|
|
497
|
-
closed = true;
|
|
498
|
-
db.close()
|
|
499
|
-
.then( () => cb && cb() )
|
|
500
|
-
.catch( ( err ) => cb && cb( err ) );
|
|
501
|
-
}
|
|
502
|
-
};
|
|
503
|
-
|
|
504
|
-
/**
|
|
505
|
-
* Get current pressure (0.0 to 1.0)
|
|
506
|
-
*/
|
|
507
|
-
const getPressure = function () {
|
|
508
|
-
const countPressure = Math.min( 1.0, metrics.messageCount / limits.maxQueueSize );
|
|
509
|
-
const bytePressure = Math.min( 1.0, metrics.totalBytes / limits.maxQueueBytes );
|
|
510
|
-
return Math.max( countPressure, bytePressure );
|
|
511
|
-
};
|
|
512
|
-
|
|
513
|
-
/**
|
|
514
|
-
* Get health status
|
|
515
|
-
*/
|
|
516
|
-
const getHealthStatus = function () {
|
|
517
|
-
if ( circuitBreaker.state === 'OPEN' ) {
|
|
518
|
-
return 'CRITICAL';
|
|
519
|
-
}
|
|
520
|
-
|
|
521
|
-
const pressure = getPressure();
|
|
522
|
-
if ( pressure > QUEUE_CRITICAL_THRESHOLD ) {
|
|
523
|
-
return 'RED';
|
|
524
|
-
} else if ( pressure > 0.5 ) {
|
|
525
|
-
return 'YELLOW';
|
|
526
|
-
}
|
|
527
|
-
return 'GREEN';
|
|
528
|
-
};
|
|
529
|
-
|
|
530
|
-
return {
|
|
531
|
-
store,
|
|
532
|
-
getPressure,
|
|
533
|
-
getHealthStatus,
|
|
534
|
-
getMetrics: () => ( { ...metrics } ),
|
|
535
|
-
getCircuitState: () => circuitBreaker.state,
|
|
536
|
-
db
|
|
537
|
-
};
|
|
538
|
-
}; // createMQTTStore()
|