@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,151 @@
|
|
|
1
|
+
// core/emitter-manager/mqtt/health.js
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* @fileoverview Pressure and health of the MQTT emitter (ADR-018 §8,
|
|
5
|
+
* ADR-021).
|
|
6
|
+
*
|
|
7
|
+
* One counter, `state.unacked`, feeds everything here. `getPressure()`
|
|
8
|
+
* is that counter over the cap, and `getHealth()` derives the status
|
|
9
|
+
* from the link and the pressure. The factory builds this once;
|
|
10
|
+
* nothing allocates on the pressure read.
|
|
11
|
+
*
|
|
12
|
+
* Two pressure callbacks live here, with different cadences.
|
|
13
|
+
* `onBackpressure` is a level signal: `checkBackpressure()` calls it
|
|
14
|
+
* after every acknowledgment with the current pressure. `onCritical`
|
|
15
|
+
* is an edge signal: `checkCritical()` calls it once when an accepted
|
|
16
|
+
* publish lifts pressure above the critical threshold, then disarms
|
|
17
|
+
* it. An acknowledgment that brings pressure below the yellow
|
|
18
|
+
* threshold re-arms it. Pressure rises only on accepts, so the
|
|
19
|
+
* crossing is detected on the accept path. During a broker outage no
|
|
20
|
+
* acknowledgment arrives, and an ack-side check would stay silent
|
|
21
|
+
* through the whole climb. The accept-path cost is one boolean read
|
|
22
|
+
* and, while armed, one comparison. A level signal here would repeat
|
|
23
|
+
* the same warning on every acknowledgment while pressure stays high,
|
|
24
|
+
* and the message-level callback guard prints each call it catches.
|
|
25
|
+
*
|
|
26
|
+
* Health-status semantics (uniform across sinks):
|
|
27
|
+
* - `red` if `!state.connected`.
|
|
28
|
+
* - `yellow` if connected AND `pressure >= 0.66` (threshold shared with
|
|
29
|
+
* ADR-020's Draft yield proposal).
|
|
30
|
+
* - `green` otherwise.
|
|
31
|
+
*
|
|
32
|
+
* @see ADR-018
|
|
33
|
+
* @see ADR-021
|
|
34
|
+
*/
|
|
35
|
+
|
|
36
|
+
import { QUEUE_CRITICAL_THRESHOLD } from './constants.js';
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Pressure threshold above which `getHealth().status` elevates to at least
|
|
40
|
+
* 'yellow' (uniform with Terminal/QuestDB). The value matches the
|
|
41
|
+
* pressure-aware-yield design (ADR-020, still a Draft). That design
|
|
42
|
+
* proposes 0.66 as the pressure level where the flow would start yielding
|
|
43
|
+
* to let sinks drain; a benchmark still has to confirm the number. Today
|
|
44
|
+
* the yield trigger is time-only (ADR-024), so this alignment is
|
|
45
|
+
* forward-looking.
|
|
46
|
+
* @type {number}
|
|
47
|
+
*/
|
|
48
|
+
const HEALTH_PRESSURE_YELLOW_THRESHOLD = 0.66;
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Builds the pressure and health readers over the shared state.
|
|
52
|
+
*
|
|
53
|
+
* @param {Object} deps
|
|
54
|
+
* @param {Object} deps.state - The emitter's core state
|
|
55
|
+
* @param {number} deps.maxQueueSize - The unacked cap
|
|
56
|
+
* @param {function|null} deps.onCritical - Guard-wrapped high-pressure callback, or null
|
|
57
|
+
* @param {function|null} deps.onBackpressure - Guard-wrapped pressure callback, or null
|
|
58
|
+
* @returns {{getPressure: function, checkCritical: function, checkBackpressure: function, getHealth: function}}
|
|
59
|
+
*/
|
|
60
|
+
export const createHealth = function ( { state, maxQueueSize, onCritical, onBackpressure } ) {
|
|
61
|
+
// The edge state of `onCritical`. Armed means the next climb past
|
|
62
|
+
// the critical threshold fires the callback. It starts armed.
|
|
63
|
+
let criticalArmed = true;
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Fill ratio of the unacked window: unacked / maxQueueSize, capped
|
|
67
|
+
* at 1. Sync, O(1), allocation-free (ADR-018).
|
|
68
|
+
* @returns {number}
|
|
69
|
+
*/
|
|
70
|
+
const getPressure = function () {
|
|
71
|
+
const ratio = state.unacked / maxQueueSize;
|
|
72
|
+
return ratio > 1 ? 1 : ratio;
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Fires `onCritical` once when an accepted publish lifts pressure
|
|
77
|
+
* above the critical threshold, then disarms it. Runs on the
|
|
78
|
+
* accept path, after the counter rises. The disarm comes before
|
|
79
|
+
* the call, so a callback that throws (contained by the guard)
|
|
80
|
+
* still counts as fired.
|
|
81
|
+
*/
|
|
82
|
+
const checkCritical = function () {
|
|
83
|
+
if ( criticalArmed && onCritical ) {
|
|
84
|
+
const pressure = getPressure();
|
|
85
|
+
if ( pressure > QUEUE_CRITICAL_THRESHOLD ) {
|
|
86
|
+
criticalArmed = false;
|
|
87
|
+
onCritical( 'QUEUE_CRITICAL', pressure );
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Runs after every acknowledgment. Re-arms `onCritical` once
|
|
94
|
+
* pressure is back below the yellow threshold, and reports the
|
|
95
|
+
* current pressure to `onBackpressure`.
|
|
96
|
+
*/
|
|
97
|
+
const checkBackpressure = function () {
|
|
98
|
+
const pressure = getPressure();
|
|
99
|
+
|
|
100
|
+
if ( pressure < HEALTH_PRESSURE_YELLOW_THRESHOLD ) {
|
|
101
|
+
criticalArmed = true;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
if ( onBackpressure ) {
|
|
105
|
+
onBackpressure( pressure );
|
|
106
|
+
}
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Health snapshot for operator monitoring (uniform across sinks).
|
|
111
|
+
*
|
|
112
|
+
* Returns the ADR-018 health floor `{status, connected, pressure}` plus
|
|
113
|
+
* this adapter's own `stats` addition. `stats.unacked` is the live
|
|
114
|
+
* counter — the number of messages
|
|
115
|
+
* accepted but not yet acknowledged by the broker; exactly what a
|
|
116
|
+
* process crash at this instant would cost (ADR-021).
|
|
117
|
+
*
|
|
118
|
+
* Status derivation (uniform with Terminal/QuestDB; see file header):
|
|
119
|
+
* - `red` when `!connected`
|
|
120
|
+
* - `yellow` when connected AND `pressure >= HEALTH_PRESSURE_YELLOW_THRESHOLD`
|
|
121
|
+
* - `green` otherwise
|
|
122
|
+
*
|
|
123
|
+
* The wal-backed design's `storeHealth`, `circuitState`, and `metrics`
|
|
124
|
+
* diagnostics died with the LevelDB store (ADR-021).
|
|
125
|
+
*
|
|
126
|
+
* @returns {Object} Health snapshot — required floor + live counter
|
|
127
|
+
*/
|
|
128
|
+
const getHealth = function () {
|
|
129
|
+
const pressure = getPressure();
|
|
130
|
+
|
|
131
|
+
let status;
|
|
132
|
+
if ( !state.connected ) {
|
|
133
|
+
status = 'red';
|
|
134
|
+
} else if ( pressure >= HEALTH_PRESSURE_YELLOW_THRESHOLD ) {
|
|
135
|
+
status = 'yellow';
|
|
136
|
+
} else {
|
|
137
|
+
status = 'green';
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
return {
|
|
141
|
+
// Required floor (ADR-018)
|
|
142
|
+
status,
|
|
143
|
+
connected: state.connected,
|
|
144
|
+
pressure,
|
|
145
|
+
// This adapter's addition beyond the floor
|
|
146
|
+
stats: { ...state.stats, unacked: state.unacked }
|
|
147
|
+
};
|
|
148
|
+
};
|
|
149
|
+
|
|
150
|
+
return { getPressure, checkCritical, checkBackpressure, getHealth };
|
|
151
|
+
}; // createHealth()
|
|
@@ -36,10 +36,55 @@
|
|
|
36
36
|
* `id`, `configSchema`, `createEmitter`, `durabilityClass`, and the
|
|
37
37
|
* default aggregate referencing the same constants.
|
|
38
38
|
*
|
|
39
|
+
* Shed policy and outage budget. Once the unacknowledged count reaches
|
|
40
|
+
* 90% of `maxQueueSize`, `publishNow` refuses new messages with
|
|
41
|
+
* `STORAGE_FULL`, and the messages already accepted stay safe. With
|
|
42
|
+
* the default cap of 10,000, refusal starts at 9,000 messages. That
|
|
43
|
+
* is 15 minutes of outage at 10 messages a second, and 2.5 hours at
|
|
44
|
+
* one a second. Memory is the count times the payload size: about
|
|
45
|
+
* 10 MB at 1 KB a message.
|
|
46
|
+
*
|
|
47
|
+
* Ordering. While connected, messages reach the broker in publish
|
|
48
|
+
* order over the one connection. After a reconnect the client replays
|
|
49
|
+
* its in-memory store in insertion order, and new publishes wait in
|
|
50
|
+
* the client's processing queue until that replay ends (mqtt.js
|
|
51
|
+
* 5.15.1, `client.js:1230-1262`). Delivery is at-least-once, so a
|
|
52
|
+
* consumer can see a message twice. Every message carries a
|
|
53
|
+
* `winkDedupId`, and the MQTT source drops the repeats (ADR-022).
|
|
54
|
+
*
|
|
55
|
+
* Edge-baseline defaults (every one runs within a 2 GB device):
|
|
56
|
+
*
|
|
57
|
+
* | Setting | Default | Environment variable |
|
|
58
|
+
* |------------------|----------|---------------------------|
|
|
59
|
+
* | `maxQueueSize` | 10000 | `MQTT_MAX_QUEUE_SIZE` |
|
|
60
|
+
* | `connectGraceMs` | 500 | `MQTT_CONNECT_GRACE_MS` |
|
|
61
|
+
* | message expiry | 3600 s | `MQTT_MSG_EXPIRY` |
|
|
62
|
+
* | keepalive | 60 s | `MQTT_KEEPALIVE` |
|
|
63
|
+
* | reconnect period | 5000 ms | `MQTT_RECONNECT_MS` |
|
|
64
|
+
* | connect timeout | 30000 ms | `MQTT_CONNECT_TIMEOUT_MS` |
|
|
65
|
+
*
|
|
66
|
+
* `brokerUrl` falls back to `MQTT_BROKER_URL`. The reconnect period
|
|
67
|
+
* gains a random share of up to 20% per client (`utils/jitter`).
|
|
68
|
+
* The cap is clamped to 60,000, the packet-id ceiling.
|
|
69
|
+
*
|
|
70
|
+
* Related decisions beyond ADR-018 and ADR-021. ADR-022: this adapter
|
|
71
|
+
* writes the `winkDedupId` the source dedups on. ADR-023: `publishNow`
|
|
72
|
+
* reads the message during the call and keeps no reference. ADR-027:
|
|
73
|
+
* the three callbacks run through the shared guard. ADR-028: every
|
|
74
|
+
* line and error follows the message grammar. ADR-026 (Proposed) is
|
|
75
|
+
* the planned TLS surface for `mqtts://`.
|
|
76
|
+
*
|
|
39
77
|
* Directory structure:
|
|
40
78
|
* core/emitter-manager/mqtt/
|
|
41
79
|
* ├── index.js # Module surface: id, schema, factory
|
|
42
|
-
* ├── emitter.js # The
|
|
80
|
+
* ├── emitter.js # The factory: resolve, open, assemble
|
|
81
|
+
* ├── resolve-config.js # Validation in one fixed order; the
|
|
82
|
+
* │ # three callbacks armed by the guard
|
|
83
|
+
* ├── connection.js # Client open, first-connack wait, the
|
|
84
|
+
* │ # connection event handlers
|
|
85
|
+
* ├── health.js # getPressure, getHealth, backpressure
|
|
86
|
+
* ├── publish.js # publishNow, the hot path
|
|
87
|
+
* ├── shutdown-drain.js # The latched drain-then-close shutdown
|
|
43
88
|
* ├── mqtt-store.js # DORMANT LevelDB store (ADR-021; kept
|
|
44
89
|
* │ # for the WAL successor, not wired,
|
|
45
90
|
* │ # not shipped in the npm package)
|
|
@@ -47,7 +92,9 @@
|
|
|
47
92
|
* └── test/ # Spec files
|
|
48
93
|
*
|
|
49
94
|
* @example
|
|
50
|
-
*
|
|
95
|
+
* // The factory is async, as QuestDB's is. A configuration error
|
|
96
|
+
* // rejects the promise with a classified `INVALID_CONFIG` error.
|
|
97
|
+
* const emitter = await createEmitter({
|
|
51
98
|
* brokerUrl: 'mqtt://broker.local:1883',
|
|
52
99
|
* codec: jsonCodec
|
|
53
100
|
* });
|
|
@@ -59,8 +106,24 @@
|
|
|
59
106
|
*/
|
|
60
107
|
|
|
61
108
|
import { validators } from '../../utils/validate/index.js';
|
|
109
|
+
import { classifyAddress } from '../../utils/address/index.js';
|
|
62
110
|
import { createEmitter } from './emitter.js';
|
|
63
111
|
|
|
112
|
+
/**
|
|
113
|
+
* Schema validator for `brokerUrl`: non-empty and never `localhost`
|
|
114
|
+
* (ADR-030). A value the URL grammar cannot read passes; the MQTT
|
|
115
|
+
* library reports its own error for it.
|
|
116
|
+
*
|
|
117
|
+
* @param {*} value - The configured value
|
|
118
|
+
* @returns {boolean} Whether the value is allowed
|
|
119
|
+
*/
|
|
120
|
+
const isAllowedBrokerUrl = function ( value ) {
|
|
121
|
+
if ( !validators.nonEmptyString( value ) ) {
|
|
122
|
+
return false;
|
|
123
|
+
}
|
|
124
|
+
return classifyAddress( value, 'url' ).kind !== 'localhost';
|
|
125
|
+
}; // isAllowedBrokerUrl()
|
|
126
|
+
|
|
64
127
|
/**
|
|
65
128
|
* Emitter identifier - must match target in emitIf specs.
|
|
66
129
|
* @type {string}
|
|
@@ -106,8 +169,9 @@ export const configSchema = {
|
|
|
106
169
|
brokerUrl: {
|
|
107
170
|
type: 'string',
|
|
108
171
|
required: false,
|
|
109
|
-
|
|
110
|
-
error: 'brokerUrl must be a non-empty
|
|
172
|
+
validator: isAllowedBrokerUrl,
|
|
173
|
+
error: 'brokerUrl must be a non-empty broker URL with a literal address or a name, never ' +
|
|
174
|
+
'localhost; e.g., mqtt://127.0.0.1:1883'
|
|
111
175
|
},
|
|
112
176
|
codec: {
|
|
113
177
|
type: 'object',
|
|
@@ -0,0 +1,292 @@
|
|
|
1
|
+
// core/emitter-manager/mqtt/publish.js
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* @fileoverview The MQTT emitter's hot path: `publishNow` (ADR-013,
|
|
5
|
+
* ADR-018, ADR-021).
|
|
6
|
+
*
|
|
7
|
+
* A publish is fire-and-forget with QoS 1. The call encodes the
|
|
8
|
+
* message, checks pressure, raises the unacked counter, and hands the
|
|
9
|
+
* packet to mqtt.js. It returns a synchronous `{ ok }` result and
|
|
10
|
+
* never a promise. The client's callback fires once per packet, on
|
|
11
|
+
* acknowledgment or failure, and `settlePublish` lowers the counter
|
|
12
|
+
* there. So the counter's single exit is that callback, paired with
|
|
13
|
+
* the catch that releases the slot when the client rejects the call
|
|
14
|
+
* before accepting it.
|
|
15
|
+
*
|
|
16
|
+
* Refusals are classified, synchronous, and allocation-free where the
|
|
17
|
+
* result can be shared: `SHUTTING_DOWN` and `STORAGE_FULL` are
|
|
18
|
+
* singletons. `ENCODE_ERROR` and the sync face of `DELIVERY_FAILED`
|
|
19
|
+
* build their result, because they carry the failure's own text and
|
|
20
|
+
* are rare.
|
|
21
|
+
*
|
|
22
|
+
* @see ADR-013
|
|
23
|
+
* @see ADR-018
|
|
24
|
+
* @see ADR-021
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
import crypto from 'crypto';
|
|
28
|
+
import {
|
|
29
|
+
QOS,
|
|
30
|
+
WINK_NAMESPACE,
|
|
31
|
+
MESSAGE_EXPIRY
|
|
32
|
+
} from './constants.js';
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Pre-flight reject threshold on store pressure.
|
|
36
|
+
* 0.9 = 10% headroom for in-flight drain on shutdown, plus a clean band
|
|
37
|
+
* between the yellow-health threshold (0.66, shared with the yield level the
|
|
38
|
+
* ADR-020 Draft proposes) and store exhaustion (1.0). At or above this,
|
|
39
|
+
* `publishNow` returns `STORAGE_FULL` synchronously; the message is not buffered.
|
|
40
|
+
* @type {number}
|
|
41
|
+
*/
|
|
42
|
+
const STORAGE_PRESSURE_LIMIT = 0.9;
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Singleton success result reused on every successful publish. Hot-path zero
|
|
46
|
+
* allocation per ADR-013 / ADR-004. Plain literal — not frozen.
|
|
47
|
+
* @type {{ok: true}}
|
|
48
|
+
*/
|
|
49
|
+
const RESULT_OK = { ok: true };
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Singleton error result for the pre-flight pressure-limit reject. Reused on
|
|
53
|
+
* every occurrence; rare in healthy operation (pressure this close to the
|
|
54
|
+
* limit means delivery has stopped draining), but a defined contract path.
|
|
55
|
+
* @type {{ok: false, error: {code: string, message: string}}}
|
|
56
|
+
*/
|
|
57
|
+
const ERR_STORAGE_FULL = {
|
|
58
|
+
ok: false,
|
|
59
|
+
error: {
|
|
60
|
+
code: 'STORAGE_FULL',
|
|
61
|
+
message: 'Store at or above pressure limit (0.9) — cannot accept message'
|
|
62
|
+
}
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Singleton error result for publishes attempted while shutdown is in progress.
|
|
67
|
+
* @type {{ok: false, error: {code: string, message: string}}}
|
|
68
|
+
*/
|
|
69
|
+
const ERR_SHUTTING_DOWN = {
|
|
70
|
+
ok: false,
|
|
71
|
+
error: {
|
|
72
|
+
code: 'SHUTTING_DOWN',
|
|
73
|
+
message: 'Emitter is shutting down — message dropped'
|
|
74
|
+
}
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* The `ENCODE_ERROR` refusal. Built per event, because it carries the
|
|
79
|
+
* codec's own message; the path is rare.
|
|
80
|
+
*
|
|
81
|
+
* @param {string} topic - The topic of the refused message
|
|
82
|
+
* @param {Error} packErr - The codec's throw
|
|
83
|
+
* @returns {{ok: false, error: {code: string, message: string}}}
|
|
84
|
+
*/
|
|
85
|
+
const encodeRefusal = function ( topic, packErr ) {
|
|
86
|
+
return {
|
|
87
|
+
ok: false,
|
|
88
|
+
error: {
|
|
89
|
+
code: 'ENCODE_ERROR',
|
|
90
|
+
message: `Message could not be encoded (topic=${topic}): ${packErr.message}`
|
|
91
|
+
}
|
|
92
|
+
};
|
|
93
|
+
}; // encodeRefusal()
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* The sync face of `DELIVERY_FAILED`: the client threw before
|
|
97
|
+
* accepting the message, so nothing is in flight.
|
|
98
|
+
*
|
|
99
|
+
* @param {string} topic - The topic of the refused message
|
|
100
|
+
* @param {Error} publishErr - The client's throw
|
|
101
|
+
* @returns {{ok: false, error: {code: string, message: string}}}
|
|
102
|
+
*/
|
|
103
|
+
const publishRefusal = function ( topic, publishErr ) {
|
|
104
|
+
return {
|
|
105
|
+
ok: false,
|
|
106
|
+
error: {
|
|
107
|
+
code: 'DELIVERY_FAILED',
|
|
108
|
+
message: `MQTT publish rejected (topic=${topic}): ${publishErr.message}`
|
|
109
|
+
}
|
|
110
|
+
};
|
|
111
|
+
}; // publishRefusal()
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Builds the MQTT v5 properties of one publish: the expiry, the
|
|
115
|
+
* dedup id, the timestamp, and the content type.
|
|
116
|
+
*
|
|
117
|
+
* Per-message allocations here (the uuid, the properties object) and
|
|
118
|
+
* the callback closure in `publishNow` cannot be pre-allocated and
|
|
119
|
+
* reused: mqtt.js keeps the packet — properties included — until
|
|
120
|
+
* PUBACK so it can retransmit, and a shared mutated object would
|
|
121
|
+
* corrupt every queued retransmission (the unavoidable-residual
|
|
122
|
+
* justification ADR-018 asks for). The options object that carries
|
|
123
|
+
* `properties` into the client is not on this list; the client
|
|
124
|
+
* copies it at once, so one object per emitter serves every publish.
|
|
125
|
+
*
|
|
126
|
+
* @param {Object} codec - The configured codec (contentType, payloadFormatIndicator)
|
|
127
|
+
* @param {number} expiry - The message expiry in seconds
|
|
128
|
+
* @returns {Object} the properties object mqtt.js keeps until PUBACK
|
|
129
|
+
*/
|
|
130
|
+
const buildProperties = function ( codec, expiry ) {
|
|
131
|
+
const properties = {
|
|
132
|
+
messageExpiryInterval: expiry,
|
|
133
|
+
userProperties: {
|
|
134
|
+
[ WINK_NAMESPACE.dedupId ]: crypto.randomUUID(),
|
|
135
|
+
[ WINK_NAMESPACE.timestamp ]: Date.now().toString(),
|
|
136
|
+
[ WINK_NAMESPACE.version ]: '1.0'
|
|
137
|
+
},
|
|
138
|
+
contentType: codec.contentType
|
|
139
|
+
};
|
|
140
|
+
|
|
141
|
+
if ( codec.payloadFormatIndicator === 1 ) {
|
|
142
|
+
properties.payloadFormatIndicator = true;
|
|
143
|
+
}
|
|
144
|
+
return properties;
|
|
145
|
+
}; // buildProperties()
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Builds `publishNow` over the shared state and the armed callbacks.
|
|
149
|
+
*
|
|
150
|
+
* @param {Object} deps
|
|
151
|
+
* @param {Object} deps.client - mqtt.js client
|
|
152
|
+
* @param {Object} deps.state - The emitter's core state
|
|
153
|
+
* @param {Object} deps.codec - The configured codec (pack, contentType)
|
|
154
|
+
* @param {function} deps.getPressure - The pressure reader from health.js
|
|
155
|
+
* @param {function} deps.checkCritical - The accept-path edge check from health.js
|
|
156
|
+
* @param {function} deps.checkBackpressure - The ack-path pressure signaller from health.js
|
|
157
|
+
* @param {function|null} deps.onDeliveryFailure - Guard-wrapped delivery-failure callback, or null
|
|
158
|
+
* @returns {function} publishNow
|
|
159
|
+
*/
|
|
160
|
+
export const createPublishNow = function ( {
|
|
161
|
+
client, state, codec, getPressure, checkCritical, checkBackpressure, onDeliveryFailure
|
|
162
|
+
} ) {
|
|
163
|
+
// One options object per emitter, reused on every publish. mqtt.js
|
|
164
|
+
// copies its options synchronously before any other work and keeps
|
|
165
|
+
// only `properties` in the packet (5.15.1, `client.js:376-381`),
|
|
166
|
+
// so the shared object is never retained. `properties` is assigned
|
|
167
|
+
// per call; it is the object the client keeps until PUBACK.
|
|
168
|
+
const publishOptions = { qos: QOS, properties: null };
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Settles one publish when its callback fires. Outcome of this async
|
|
172
|
+
* callback is observability-only — the publishNow caller has
|
|
173
|
+
* already received a synchronous result. The callback fires exactly
|
|
174
|
+
* once per publish (acknowledgment or failure), so the decrement
|
|
175
|
+
* here is the counter's single exit.
|
|
176
|
+
*
|
|
177
|
+
* @param {Error|null} err - The client's failure, or null on acknowledgment
|
|
178
|
+
* @param {string} topic - The published topic, for the failure report
|
|
179
|
+
*/
|
|
180
|
+
const settlePublish = function ( err, topic ) {
|
|
181
|
+
state.unacked -= 1;
|
|
182
|
+
if ( err ) {
|
|
183
|
+
state.stats.publishErrors += 1;
|
|
184
|
+
const deliveryErr = new Error(
|
|
185
|
+
`winkComposer/mqttEmitter: publish failed (topic=${topic}): ${err.message || err.code || 'unknown'}`
|
|
186
|
+
);
|
|
187
|
+
deliveryErr.code = 'DELIVERY_FAILED';
|
|
188
|
+
deliveryErr.cause = err;
|
|
189
|
+
if ( onDeliveryFailure ) {
|
|
190
|
+
onDeliveryFailure( deliveryErr, { topic } );
|
|
191
|
+
} else {
|
|
192
|
+
// No handler — surface as unhandledRejection so the
|
|
193
|
+
// process logs loudly and (Node 15+) exits. Mirrors
|
|
194
|
+
// QuestDB's persist-plan.js catch-and-throw: both
|
|
195
|
+
// adapters route undeclared async failures
|
|
196
|
+
// through the same Node-level escape hatch.
|
|
197
|
+
Promise.reject( deliveryErr );
|
|
198
|
+
}
|
|
199
|
+
} else {
|
|
200
|
+
state.stats.published += 1;
|
|
201
|
+
}
|
|
202
|
+
checkBackpressure();
|
|
203
|
+
}; // settlePublish()
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* Publish message immediately (fire-and-forget).
|
|
207
|
+
*
|
|
208
|
+
* Sync return per the ADR-018 sink contract:
|
|
209
|
+
* - `{ ok: true }` on successful buffer.
|
|
210
|
+
* - `{ ok: false, error: { code: 'STORAGE_FULL', message } }` if pre-flight
|
|
211
|
+
* pressure check rejects (pressure >= STORAGE_PRESSURE_LIMIT).
|
|
212
|
+
* - `{ ok: false, error: { code: 'SHUTTING_DOWN', message } }` if called
|
|
213
|
+
* while shutdown is in progress.
|
|
214
|
+
*
|
|
215
|
+
* Async store-write or publish failures (after the message was buffered)
|
|
216
|
+
* route through `onDeliveryFailure(err, { topic })` when supplied; without
|
|
217
|
+
* a handler the adapter surfaces the failure via `Promise.reject`,
|
|
218
|
+
* which Node logs as unhandledRejection (and v15+ exits on). Loud failure
|
|
219
|
+
* beats silent loss.
|
|
220
|
+
*
|
|
221
|
+
* @param {string} topic - MQTT topic
|
|
222
|
+
* @param {*} message - Message payload (will be encoded via config.codec)
|
|
223
|
+
* @param {Object} [options] - Per-message options
|
|
224
|
+
* @param {string} [options.type] - Message-type key for MESSAGE_EXPIRY lookup
|
|
225
|
+
* @returns {{ok: true} | {ok: false, error: {code: string, message: string}}}
|
|
226
|
+
*/
|
|
227
|
+
const publishNow = function ( topic, message, options ) {
|
|
228
|
+
if ( state.shuttingDown ) {
|
|
229
|
+
return ERR_SHUTTING_DOWN;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
// Pre-flight pressure check — reject above STORAGE_PRESSURE_LIMIT to
|
|
233
|
+
// leave headroom for in-flight drain and to surface backpressure to
|
|
234
|
+
// producers before the store is literally full.
|
|
235
|
+
if ( getPressure() >= STORAGE_PRESSURE_LIMIT ) {
|
|
236
|
+
return ERR_STORAGE_FULL;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
// Encode BEFORE the counter moves. A message the codec cannot
|
|
240
|
+
// encode was never in flight, so it must not occupy a slot. A
|
|
241
|
+
// leaked slot never drains, pressure only rises, and the
|
|
242
|
+
// emitter ends up refusing everything. The refusal is
|
|
243
|
+
// synchronous and classified. Building it allocates, which is
|
|
244
|
+
// fine on an error path this rare.
|
|
245
|
+
let payload;
|
|
246
|
+
try {
|
|
247
|
+
payload = codec.pack( message );
|
|
248
|
+
} catch ( packErr ) {
|
|
249
|
+
state.stats.encodeErrors += 1;
|
|
250
|
+
return encodeRefusal( topic, packErr );
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
const messageType = ( options && options.type ) || 'default';
|
|
254
|
+
const expiry = MESSAGE_EXPIRY[ messageType ] || MESSAGE_EXPIRY.default;
|
|
255
|
+
publishOptions.properties = buildProperties( codec, expiry );
|
|
256
|
+
|
|
257
|
+
// Accept: the message is now in flight. The counter rises HERE,
|
|
258
|
+
// synchronously with the accept decision, so the very next
|
|
259
|
+
// publishNow call sees the true pressure (the old design's
|
|
260
|
+
// optimistic-increment lesson, kept).
|
|
261
|
+
state.unacked += 1;
|
|
262
|
+
|
|
263
|
+
// Fire-and-forget publish with QoS 1. The callback settles the
|
|
264
|
+
// counter (see settlePublish above), paired with the catch,
|
|
265
|
+
// which releases the slot when the client rejects the call
|
|
266
|
+
// before accepting it.
|
|
267
|
+
try {
|
|
268
|
+
client.publish(
|
|
269
|
+
topic,
|
|
270
|
+
payload,
|
|
271
|
+
publishOptions,
|
|
272
|
+
( err ) => settlePublish( err, topic )
|
|
273
|
+
);
|
|
274
|
+
} catch ( publishErr ) {
|
|
275
|
+
// The client threw before accepting the message; its callback
|
|
276
|
+
// will never fire, so the slot is released here — the sync
|
|
277
|
+
// face of DELIVERY_FAILED (see the header vocabulary).
|
|
278
|
+
state.unacked -= 1;
|
|
279
|
+
state.stats.publishErrors += 1;
|
|
280
|
+
return publishRefusal( topic, publishErr );
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
// The message is accepted and the counter has its new value.
|
|
284
|
+
// Pressure rises only here, so this is where a climb past the
|
|
285
|
+
// critical threshold is seen (see health.js).
|
|
286
|
+
checkCritical();
|
|
287
|
+
|
|
288
|
+
return RESULT_OK;
|
|
289
|
+
}; // publishNow()
|
|
290
|
+
|
|
291
|
+
return publishNow;
|
|
292
|
+
}; // createPublishNow()
|