procmesh-js 0.1.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/LICENSE +21 -0
- package/README.md +331 -0
- package/package.json +46 -0
- package/src/broker-bin.js +41 -0
- package/src/broker.js +676 -0
- package/src/cli.js +146 -0
- package/src/client.js +512 -0
- package/src/codec.js +54 -0
- package/src/errors.js +43 -0
- package/src/hashring.js +27 -0
- package/src/index.js +49 -0
- package/src/locks.js +129 -0
- package/src/persistence.js +327 -0
- package/src/protocol.js +169 -0
- package/src/sharded-client.js +338 -0
- package/src/store.js +155 -0
- package/src/transport.js +32 -0
package/src/protocol.js
ADDED
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const EventEmitter = require('events');
|
|
4
|
+
|
|
5
|
+
const PROTOCOL_VERSION = 2;
|
|
6
|
+
|
|
7
|
+
/** Message type tags. Kept short to minimize JSON overhead. */
|
|
8
|
+
const TYPES = {
|
|
9
|
+
// connection / control
|
|
10
|
+
HELLO: 'hello',
|
|
11
|
+
WELCOME: 'welcome',
|
|
12
|
+
PING: 'ping',
|
|
13
|
+
PONG: 'pong',
|
|
14
|
+
OK: 'ok',
|
|
15
|
+
ERR: 'err',
|
|
16
|
+
SHUTDOWN: 'shutdown',
|
|
17
|
+
STATS: 'stats',
|
|
18
|
+
// cache
|
|
19
|
+
GET: 'get',
|
|
20
|
+
SET: 'set',
|
|
21
|
+
DEL: 'del',
|
|
22
|
+
HAS: 'has',
|
|
23
|
+
KEYS: 'keys',
|
|
24
|
+
CLEAR: 'clear',
|
|
25
|
+
MGET: 'mget',
|
|
26
|
+
MSET: 'mset',
|
|
27
|
+
// atomic
|
|
28
|
+
INCR: 'incr',
|
|
29
|
+
DECR: 'decr',
|
|
30
|
+
CAS: 'cas',
|
|
31
|
+
// locks
|
|
32
|
+
LOCK: 'lock',
|
|
33
|
+
UNLOCK: 'unlock',
|
|
34
|
+
// fenced mutations (guarded by a lock's fencing token)
|
|
35
|
+
FSET: 'fset',
|
|
36
|
+
FCAS: 'fcas',
|
|
37
|
+
FDEL: 'fdel',
|
|
38
|
+
// pub/sub
|
|
39
|
+
SUBSCRIBE: 'sub',
|
|
40
|
+
UNSUBSCRIBE: 'unsub',
|
|
41
|
+
PUBLISH: 'pub',
|
|
42
|
+
MESSAGE: 'msg',
|
|
43
|
+
// rpc
|
|
44
|
+
REGISTER: 'reg',
|
|
45
|
+
UNREGISTER: 'unreg',
|
|
46
|
+
CALL: 'call',
|
|
47
|
+
INVOKE: 'invoke',
|
|
48
|
+
RESULT: 'result',
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
const DEFAULT_MAX_FRAME = 64 * 1024 * 1024; // 64 MiB
|
|
52
|
+
const DEFAULT_SEND_HWM = 16 * 1024 * 1024; // 16 MiB: soft cap, droppable frames dropped beyond this
|
|
53
|
+
const DEFAULT_SEND_HARD_LIMIT = 64 * 1024 * 1024; // 64 MiB: hard cap, slow consumer is disconnected
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Topic match. A subscription ending in `*` matches by prefix (everything before
|
|
57
|
+
* the `*`); otherwise it must match the channel exactly. Predictable and cheap —
|
|
58
|
+
* no regex. e.g. `matchTopic('orders.*', 'orders.created') === true`.
|
|
59
|
+
*/
|
|
60
|
+
function matchTopic(pattern, channel) {
|
|
61
|
+
if (pattern === channel) return true;
|
|
62
|
+
if (pattern.endsWith('*')) return channel.startsWith(pattern.slice(0, -1));
|
|
63
|
+
return false;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Whether a subscription string is a wildcard pattern rather than an exact channel. */
|
|
67
|
+
function isPattern(sub) {
|
|
68
|
+
return typeof sub === 'string' && sub.endsWith('*');
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Encode an object as a length-prefixed frame: [uint32 BE length][payload]. */
|
|
72
|
+
function encodeFrame(codec, obj) {
|
|
73
|
+
const payload = codec.encode(obj);
|
|
74
|
+
const frame = Buffer.allocUnsafe(4 + payload.length);
|
|
75
|
+
frame.writeUInt32BE(payload.length, 0);
|
|
76
|
+
payload.copy(frame, 4);
|
|
77
|
+
return frame;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Incremental decoder that buffers partial reads and yields complete frames. */
|
|
81
|
+
class FrameDecoder {
|
|
82
|
+
constructor(codec, { maxFrameSize = DEFAULT_MAX_FRAME } = {}) {
|
|
83
|
+
this.codec = codec;
|
|
84
|
+
this.maxFrameSize = maxFrameSize;
|
|
85
|
+
this.buffer = Buffer.alloc(0);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
push(chunk, onMessage) {
|
|
89
|
+
this.buffer = this.buffer.length ? Buffer.concat([this.buffer, chunk]) : chunk;
|
|
90
|
+
for (;;) {
|
|
91
|
+
if (this.buffer.length < 4) return;
|
|
92
|
+
const len = this.buffer.readUInt32BE(0);
|
|
93
|
+
if (len > this.maxFrameSize) {
|
|
94
|
+
throw new Error(`frame too large: ${len} > ${this.maxFrameSize}`);
|
|
95
|
+
}
|
|
96
|
+
if (this.buffer.length < 4 + len) return;
|
|
97
|
+
const payload = this.buffer.subarray(4, 4 + len);
|
|
98
|
+
const obj = this.codec.decode(payload);
|
|
99
|
+
this.buffer = this.buffer.subarray(4 + len);
|
|
100
|
+
onMessage(obj);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Wraps a duplex socket with framed message send/receive. Both the client and
|
|
107
|
+
* the broker use this so framing lives in exactly one place.
|
|
108
|
+
*
|
|
109
|
+
* Emits: 'message' (obj), 'close', 'error' (err).
|
|
110
|
+
*/
|
|
111
|
+
class Peer extends EventEmitter {
|
|
112
|
+
constructor(socket, codec, opts = {}) {
|
|
113
|
+
super();
|
|
114
|
+
this.socket = socket;
|
|
115
|
+
this.codec = codec;
|
|
116
|
+
this.decoder = new FrameDecoder(codec, opts);
|
|
117
|
+
this.sendHighWaterMark = opts.sendHighWaterMark || DEFAULT_SEND_HWM;
|
|
118
|
+
this.sendHardLimit = opts.sendHardLimit || DEFAULT_SEND_HARD_LIMIT;
|
|
119
|
+
socket.on('data', (chunk) => {
|
|
120
|
+
try {
|
|
121
|
+
this.decoder.push(chunk, (msg) => this.emit('message', msg));
|
|
122
|
+
} catch (err) {
|
|
123
|
+
this.emit('error', err);
|
|
124
|
+
socket.destroy(err);
|
|
125
|
+
}
|
|
126
|
+
});
|
|
127
|
+
socket.on('error', (err) => this.emit('error', err));
|
|
128
|
+
socket.on('close', () => this.emit('close'));
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Send a framed message, applying High-Water-Mark backpressure.
|
|
133
|
+
*
|
|
134
|
+
* - `droppable: true` (e.g. pub/sub fan-out): if the socket's outbound buffer
|
|
135
|
+
* already exceeds the soft HWM, the frame is DROPPED (returns 'dropped') so a
|
|
136
|
+
* slow consumer can't make us buffer without bound. Favors liveness.
|
|
137
|
+
* - otherwise (replies/RPC): if the buffer exceeds the hard limit, the slow
|
|
138
|
+
* consumer is DISCONNECTED ('overflow') to protect the broker; below that it
|
|
139
|
+
* writes normally and returns socket.write()'s drain boolean.
|
|
140
|
+
*
|
|
141
|
+
* @returns {boolean|'dropped'|'overflow'}
|
|
142
|
+
*/
|
|
143
|
+
send(obj, { droppable = false } = {}) {
|
|
144
|
+
if (this.socket.destroyed) return false;
|
|
145
|
+
const queued = this.socket.writableLength;
|
|
146
|
+
if (droppable && queued > this.sendHighWaterMark) {
|
|
147
|
+
return 'dropped';
|
|
148
|
+
}
|
|
149
|
+
if (!droppable && queued > this.sendHardLimit) {
|
|
150
|
+
this.socket.destroy(new Error('send buffer overflow (slow consumer)'));
|
|
151
|
+
return 'overflow';
|
|
152
|
+
}
|
|
153
|
+
return this.socket.write(encodeFrame(this.codec, obj));
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
destroy() {
|
|
157
|
+
this.socket.destroy();
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
module.exports = {
|
|
162
|
+
PROTOCOL_VERSION,
|
|
163
|
+
TYPES,
|
|
164
|
+
encodeFrame,
|
|
165
|
+
FrameDecoder,
|
|
166
|
+
Peer,
|
|
167
|
+
matchTopic,
|
|
168
|
+
isPattern,
|
|
169
|
+
};
|
|
@@ -0,0 +1,338 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const EventEmitter = require('events');
|
|
4
|
+
const Client = require('./client');
|
|
5
|
+
const { resolveAddress } = require('./transport');
|
|
6
|
+
const { isPattern } = require('./protocol');
|
|
7
|
+
const { shardIndex } = require('./hashring');
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* A client handle that spreads work across N broker processes (N cores), exposing the
|
|
11
|
+
* exact same API as {@link Client} so callers never branch. It composes N plain Clients
|
|
12
|
+
* — one per shard broker — and routes each operation to the shard that owns it:
|
|
13
|
+
*
|
|
14
|
+
* - cache / atomic (get/set/incr/cas/…) → hash(key)
|
|
15
|
+
* - locks + fenced mutations → hash(lockKey)
|
|
16
|
+
* - RPC (register/call) → hash(procName)
|
|
17
|
+
* - pub/sub publish + exact subscribe → hash(channel)
|
|
18
|
+
* - pub/sub wildcard subscribe → ALL shards (so it catches a publish on any shard)
|
|
19
|
+
*
|
|
20
|
+
* Because each key/name/channel hashes to exactly one broker, atomic ops and locks stay
|
|
21
|
+
* correct by construction — the same way a single broker is. All reconnect, replay, and
|
|
22
|
+
* delivery logic lives in the child Clients; this class is a thin router + event aggregator.
|
|
23
|
+
*
|
|
24
|
+
* INVARIANT: every process joining the same logical mesh MUST use the same shard count and
|
|
25
|
+
* the same per-shard naming/addresses, or a given key will hash to different brokers across
|
|
26
|
+
* processes (writer/reader split-brain).
|
|
27
|
+
*
|
|
28
|
+
* Emits: 'connect' (all shards up), 'disconnect' (all shards down), 'error' (err.shard = i),
|
|
29
|
+
* and the additive per-shard events 'shard-reconnect' (i) / 'shard-disconnect' (i).
|
|
30
|
+
*/
|
|
31
|
+
class ShardedClient extends EventEmitter {
|
|
32
|
+
constructor(opts = {}) {
|
|
33
|
+
super();
|
|
34
|
+
this.opts = opts;
|
|
35
|
+
this.name = opts.name || 'default';
|
|
36
|
+
this.closed = false;
|
|
37
|
+
this._allUp = false;
|
|
38
|
+
|
|
39
|
+
const specs = resolveShardSpecs(opts, this.name);
|
|
40
|
+
this.n = specs.length;
|
|
41
|
+
if (this.n < 1) throw new TypeError('shards must resolve to >= 1 shard');
|
|
42
|
+
|
|
43
|
+
// resolveAddress short-circuits to PROCMESH_SOCKET for every name, which would collapse
|
|
44
|
+
// all shards onto one socket. Distinct addresses are required for sharding to mean anything.
|
|
45
|
+
const addrs = specs.map((s) => s.address);
|
|
46
|
+
if (new Set(addrs).size !== this.n) {
|
|
47
|
+
throw new Error('sharded client requires a distinct address per shard (is PROCMESH_SOCKET set?)');
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
this.clients = specs.map((spec, i) => {
|
|
51
|
+
const child = new Client({ ...opts, shards: undefined, name: spec.name, address: spec.address });
|
|
52
|
+
this._wireChild(child, i);
|
|
53
|
+
return child;
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// ----------------------------------------------------------------- routing helpers
|
|
58
|
+
|
|
59
|
+
_clientForKey(key) {
|
|
60
|
+
return this.clients[shardIndex(key, this.n)];
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
_clientForChannel(channel) {
|
|
64
|
+
return this.clients[shardIndex(channel, this.n)];
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
_clientForProc(name) {
|
|
68
|
+
return this.clients[shardIndex(name, this.n)];
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
_fanOut(fn) {
|
|
72
|
+
return Promise.all(this.clients.map(fn));
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// ----------------------------------------------------------------------- events
|
|
76
|
+
|
|
77
|
+
_wireChild(child, i) {
|
|
78
|
+
child.on('error', (err) => {
|
|
79
|
+
if (err && typeof err === 'object') err.shard = i;
|
|
80
|
+
this.emit('error', err);
|
|
81
|
+
});
|
|
82
|
+
child.on('connect', () => this._onChildState());
|
|
83
|
+
child.on('reconnect', () => {
|
|
84
|
+
this.emit('shard-reconnect', i);
|
|
85
|
+
this._onChildState();
|
|
86
|
+
});
|
|
87
|
+
child.on('disconnect', () => {
|
|
88
|
+
this.emit('shard-disconnect', i);
|
|
89
|
+
if (this.clients.every((c) => !c.connected)) this.emit('disconnect');
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** Edge-trigger the mesh-level 'connect' once every shard is connected. */
|
|
94
|
+
_onChildState() {
|
|
95
|
+
const allUp = this.clients.every((c) => c.connected);
|
|
96
|
+
if (allUp && !this._allUp) this.emit('connect');
|
|
97
|
+
this._allUp = allUp;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// -------------------------------------------------------------------- connection
|
|
101
|
+
|
|
102
|
+
async connect() {
|
|
103
|
+
// Fail-fast: a half-connected mesh would silently drop a slice of the keyspace.
|
|
104
|
+
await this._fanOut((c) => c.connect());
|
|
105
|
+
this._allUp = true;
|
|
106
|
+
return this;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
async close() {
|
|
110
|
+
if (this.closed) return;
|
|
111
|
+
this.closed = true;
|
|
112
|
+
await this._fanOut((c) => c.close());
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// --------------------------------------------------------------------- cache
|
|
116
|
+
|
|
117
|
+
get(key) {
|
|
118
|
+
return this._clientForKey(key).get(key);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
set(key, value, opts = {}) {
|
|
122
|
+
return this._clientForKey(key).set(key, value, opts);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
del(key) {
|
|
126
|
+
return this._clientForKey(key).del(key);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
has(key) {
|
|
130
|
+
return this._clientForKey(key).has(key);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
async keys() {
|
|
134
|
+
const lists = await this._fanOut((c) => c.keys());
|
|
135
|
+
return [].concat(...lists);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
async clear() {
|
|
139
|
+
await this._fanOut((c) => c.clear());
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** Split keys per shard, fan out, recombine aligned to input order (undefined for misses). */
|
|
143
|
+
async mget(keys) {
|
|
144
|
+
const buckets = this.clients.map(() => []);
|
|
145
|
+
keys.forEach((key, idx) => buckets[shardIndex(key, this.n)].push({ key, idx }));
|
|
146
|
+
const out = new Array(keys.length);
|
|
147
|
+
await Promise.all(
|
|
148
|
+
buckets.map(async (bucket, s) => {
|
|
149
|
+
if (!bucket.length) return;
|
|
150
|
+
const values = await this.clients[s].mget(bucket.map((x) => x.key));
|
|
151
|
+
bucket.forEach((x, j) => {
|
|
152
|
+
out[x.idx] = values[j];
|
|
153
|
+
});
|
|
154
|
+
})
|
|
155
|
+
);
|
|
156
|
+
return out;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** Group entries per shard, fan out the writes. */
|
|
160
|
+
async mset(entries) {
|
|
161
|
+
const list = Array.isArray(entries) ? entries : Object.entries(entries);
|
|
162
|
+
const buckets = this.clients.map(() => []);
|
|
163
|
+
for (const entry of list) buckets[shardIndex(entry[0], this.n)].push(entry);
|
|
164
|
+
await Promise.all(buckets.map((bucket, s) => (bucket.length ? this.clients[s].mset(bucket) : null)));
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// -------------------------------------------------------------------- atomic
|
|
168
|
+
|
|
169
|
+
incr(key, by = 1) {
|
|
170
|
+
return this._clientForKey(key).incr(key, by);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
decr(key, by = 1) {
|
|
174
|
+
return this._clientForKey(key).decr(key, by);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
cas(key, prev, next) {
|
|
178
|
+
return this._clientForKey(key).cas(key, prev, next);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// -------------------------------------------------------------------- pub/sub
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Exact channels subscribe on the channel's owning shard; wildcard patterns subscribe
|
|
185
|
+
* on EVERY shard (a publish can land on any of them). The returned off() mirrors the
|
|
186
|
+
* subscription's footprint.
|
|
187
|
+
*/
|
|
188
|
+
async subscribe(channel, handler) {
|
|
189
|
+
if (!isPattern(channel)) return this._clientForChannel(channel).subscribe(channel, handler);
|
|
190
|
+
const offs = await this._fanOut((c) => c.subscribe(channel, handler));
|
|
191
|
+
return async () => {
|
|
192
|
+
await Promise.all(offs.map((off) => off()));
|
|
193
|
+
};
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
unsubscribe(channel, handler) {
|
|
197
|
+
if (!isPattern(channel)) return this._clientForChannel(channel).unsubscribe(channel, handler);
|
|
198
|
+
return this._fanOut((c) => c.unsubscribe(channel, handler));
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* Publish to exactly ONE broker (the channel's owning shard). Exact subscribers hash to
|
|
203
|
+
* that same shard and wildcard subscribers are present on every shard, so every matching
|
|
204
|
+
* subscriber is reachable there — and a message can never be delivered twice. The returned
|
|
205
|
+
* `delivered` count is that broker's matching-subscriber count (which is all of them).
|
|
206
|
+
*/
|
|
207
|
+
publish(channel, payload) {
|
|
208
|
+
return this._clientForChannel(channel).publish(channel, payload);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// ------------------------------------------------------------------------ rpc
|
|
212
|
+
|
|
213
|
+
register(name, fn) {
|
|
214
|
+
return this._clientForProc(name).register(name, fn);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
unregister(name) {
|
|
218
|
+
return this._clientForProc(name).unregister(name);
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
call(name, args = [], opts = {}) {
|
|
222
|
+
return this._clientForProc(name).call(name, args, opts);
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// ---------------------------------------------------------------------- locks
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* Locks and their fenced mutations route by the LOCK key, so a lock and every fenced write
|
|
229
|
+
* guarded by it share one broker (the fencing counter is per-broker). NOTE: this means a
|
|
230
|
+
* fenced write to data key `k` lands on the lock's shard, not shardIndex(k) — keep the lock
|
|
231
|
+
* key and guarded data keys colocated (use the same string, or namespace data under the lock).
|
|
232
|
+
*/
|
|
233
|
+
lock(key, opts = {}) {
|
|
234
|
+
return this._clientForKey(key).lock(key, opts);
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
withLock(key, fn, opts = {}) {
|
|
238
|
+
return this._clientForKey(key).withLock(key, fn, opts);
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
// ------------------------------------------------------- fenced mutations (lock-guarded)
|
|
242
|
+
|
|
243
|
+
fencedSet(lockKey, token, key, value, opts = {}) {
|
|
244
|
+
return this._clientForKey(lockKey).fencedSet(lockKey, token, key, value, opts);
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
fencedCas(lockKey, token, key, prev, next) {
|
|
248
|
+
return this._clientForKey(lockKey).fencedCas(lockKey, token, key, prev, next);
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
fencedDel(lockKey, token, key) {
|
|
252
|
+
return this._clientForKey(lockKey).fencedDel(lockKey, token, key);
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
// ---------------------------------------------------------------- misc / admin
|
|
256
|
+
|
|
257
|
+
/** True only if every shard answered its ping. */
|
|
258
|
+
async ping() {
|
|
259
|
+
const replies = await this._fanOut((c) => c.ping());
|
|
260
|
+
return replies.every(Boolean);
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/** Aggregated snapshot across all shards, with a per-shard `shards` array for drill-down. */
|
|
264
|
+
async stats() {
|
|
265
|
+
const per = await this._fanOut((c) => c.stats());
|
|
266
|
+
return aggregateStats(per);
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/** Ask every shard broker to shut down (best-effort). */
|
|
270
|
+
async shutdownBroker() {
|
|
271
|
+
await this._fanOut((c) => c.shutdownBroker().catch(() => {}));
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/**
|
|
276
|
+
* Resolve opts.shards to an array of { name, address } specs.
|
|
277
|
+
* - number N → name#0 .. name#(N-1), each address derived from resolveAddress.
|
|
278
|
+
* - Array<string> → each string is a NAME (resolved via resolveAddress).
|
|
279
|
+
* - Array<{name|address}> → {address} used verbatim; {name} resolved.
|
|
280
|
+
*/
|
|
281
|
+
function resolveShardSpecs(opts, baseName) {
|
|
282
|
+
const { shards } = opts;
|
|
283
|
+
if (typeof shards === 'number') {
|
|
284
|
+
const specs = [];
|
|
285
|
+
for (let i = 0; i < shards; i++) {
|
|
286
|
+
const name = `${baseName}#${i}`;
|
|
287
|
+
specs.push({ name, address: resolveAddress(name) });
|
|
288
|
+
}
|
|
289
|
+
return specs;
|
|
290
|
+
}
|
|
291
|
+
if (Array.isArray(shards)) {
|
|
292
|
+
return shards.map((s) => {
|
|
293
|
+
if (typeof s === 'string') return { name: s, address: resolveAddress(s) };
|
|
294
|
+
if (s && typeof s === 'object') {
|
|
295
|
+
if (s.address) return { name: s.name, address: s.address };
|
|
296
|
+
if (s.name) return { name: s.name, address: resolveAddress(s.name) };
|
|
297
|
+
}
|
|
298
|
+
throw new TypeError('each shard spec must be a name string or { name } / { address }');
|
|
299
|
+
});
|
|
300
|
+
}
|
|
301
|
+
throw new TypeError('opts.shards must be a number or an array of names/specs');
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
/** Merge per-shard snapshots into one mesh-level snapshot (superset of Broker.snapshot()). */
|
|
305
|
+
function aggregateStats(per) {
|
|
306
|
+
const sum = (field) => per.reduce((acc, s) => acc + (s[field] || 0), 0);
|
|
307
|
+
const memField = (field) => per.reduce((acc, s) => acc + (s.memory ? s.memory[field] || 0 : 0), 0);
|
|
308
|
+
const ops = {};
|
|
309
|
+
for (const s of per) {
|
|
310
|
+
for (const [k, v] of Object.entries(s.ops || {})) ops[k] = (ops[k] || 0) + v;
|
|
311
|
+
}
|
|
312
|
+
return {
|
|
313
|
+
shardCount: per.length,
|
|
314
|
+
shards: per,
|
|
315
|
+
uptimeMs: per.reduce((m, s) => Math.max(m, s.uptimeMs || 0), 0),
|
|
316
|
+
connections: sum('connections'),
|
|
317
|
+
cacheSize: sum('cacheSize'),
|
|
318
|
+
ops,
|
|
319
|
+
dropped: sum('dropped'),
|
|
320
|
+
reaped: sum('reaped'),
|
|
321
|
+
locks: sum('locks'),
|
|
322
|
+
lockWaiters: sum('lockWaiters'),
|
|
323
|
+
pendingCalls: sum('pendingCalls'),
|
|
324
|
+
// Over-counts wildcard subscriptions (present on every shard) — documented.
|
|
325
|
+
subscriptions: sum('subscriptions'),
|
|
326
|
+
procs: [].concat(...per.map((s) => s.procs || [])),
|
|
327
|
+
// SUM across brokers — can exceed 1.0, which is the whole point of sharding.
|
|
328
|
+
cpuCoreFraction: per.reduce((acc, s) => acc + (s.cpuCoreFraction || 0), 0),
|
|
329
|
+
memory: {
|
|
330
|
+
rss: memField('rss'),
|
|
331
|
+
heapTotal: memField('heapTotal'),
|
|
332
|
+
heapUsed: memField('heapUsed'),
|
|
333
|
+
external: memField('external'),
|
|
334
|
+
},
|
|
335
|
+
};
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
module.exports = ShardedClient;
|
package/src/store.js
ADDED
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { LRUCache } = require('lru-cache');
|
|
4
|
+
|
|
5
|
+
/** Structural equality via canonical JSON; undefined is treated as "absent" (null). */
|
|
6
|
+
function eq(a, b) {
|
|
7
|
+
return JSON.stringify(a === undefined ? null : a) === JSON.stringify(b === undefined ? null : b);
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/** Rough byte size of a value, for optional maxSize-based eviction. */
|
|
11
|
+
function approxSize(value) {
|
|
12
|
+
try {
|
|
13
|
+
return Buffer.byteLength(JSON.stringify(value) || '') + 1;
|
|
14
|
+
} catch {
|
|
15
|
+
return 1;
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* The authoritative key-value store, held in the broker process only.
|
|
21
|
+
* Wraps lru-cache for size/TTL-based eviction. All mutations run on the broker's
|
|
22
|
+
* single event loop, so atomic ops (incr/decr/cas) need no internal locking.
|
|
23
|
+
*/
|
|
24
|
+
class Store {
|
|
25
|
+
constructor({ max = 10000, ttl = 0, maxSize = 0 } = {}) {
|
|
26
|
+
const opts = {};
|
|
27
|
+
// lru-cache requires at least one bound. We always set `max` unless a byte
|
|
28
|
+
// budget is given. Per-item TTLs work as long as the ttl feature is enabled,
|
|
29
|
+
// so we enable it with `ttl: 0` (no default expiry) when no global ttl is set.
|
|
30
|
+
if (maxSize > 0) {
|
|
31
|
+
opts.maxSize = maxSize;
|
|
32
|
+
opts.sizeCalculation = approxSize;
|
|
33
|
+
} else {
|
|
34
|
+
opts.max = max;
|
|
35
|
+
}
|
|
36
|
+
opts.ttl = ttl > 0 ? ttl : 0;
|
|
37
|
+
opts.ttlAutopurge = ttl > 0; // proactively purge expired entries when a default ttl exists
|
|
38
|
+
opts.allowStale = false;
|
|
39
|
+
this.cache = new LRUCache(opts);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
get(key) {
|
|
43
|
+
return this.cache.get(key);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
set(key, value, ttl) {
|
|
47
|
+
const opts = ttl && ttl > 0 ? { ttl } : undefined;
|
|
48
|
+
this.cache.set(key, value, opts);
|
|
49
|
+
return true;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
del(key) {
|
|
53
|
+
return this.cache.delete(key);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
has(key) {
|
|
57
|
+
return this.cache.has(key);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
keys() {
|
|
61
|
+
return [...this.cache.keys()];
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
clear() {
|
|
65
|
+
this.cache.clear();
|
|
66
|
+
return true;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Bulk get. Returns { values, found } so callers can distinguish a missing key
|
|
71
|
+
* from a stored `null`/`undefined` even across a JSON boundary (which would
|
|
72
|
+
* otherwise collapse array holes to null).
|
|
73
|
+
*/
|
|
74
|
+
mget(keys) {
|
|
75
|
+
const values = [];
|
|
76
|
+
const found = [];
|
|
77
|
+
for (const k of keys) {
|
|
78
|
+
const hit = this.cache.has(k);
|
|
79
|
+
found.push(hit);
|
|
80
|
+
values.push(hit ? this.cache.get(k) : null);
|
|
81
|
+
}
|
|
82
|
+
return { values, found };
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
mset(entries) {
|
|
86
|
+
for (const [k, v] of entries) this.cache.set(k, v);
|
|
87
|
+
return true;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
incr(key, by = 1) {
|
|
91
|
+
const cur = this.cache.get(key);
|
|
92
|
+
const base = cur === undefined ? 0 : cur;
|
|
93
|
+
if (typeof base !== 'number') {
|
|
94
|
+
const err = new Error(`value at "${key}" is not a number`);
|
|
95
|
+
err.code = 'ENOTNUMBER';
|
|
96
|
+
throw err;
|
|
97
|
+
}
|
|
98
|
+
const next = base + by;
|
|
99
|
+
// Read-modify-write must not reset an existing per-item TTL: a counter created with an
|
|
100
|
+
// expiry keeps counting down rather than becoming immortal on the next incr.
|
|
101
|
+
this.cache.set(key, next, { noUpdateTTL: true });
|
|
102
|
+
return next;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** Compare-and-set. Sets to `next` only if current value equals `prev`. */
|
|
106
|
+
cas(key, prev, next) {
|
|
107
|
+
const cur = this.cache.get(key);
|
|
108
|
+
if (!eq(cur, prev)) return false;
|
|
109
|
+
if (next === undefined) this.cache.delete(key);
|
|
110
|
+
else this.cache.set(key, next, { noUpdateTTL: true }); // in-place update preserves any TTL
|
|
111
|
+
return true;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
get size() {
|
|
115
|
+
return this.cache.size;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Remaining lifetime of `key` in ms, or 0 for "no expiry" (and for a missing/expired key).
|
|
120
|
+
* Used to mirror the live TTL into the persistence log after an atomic op.
|
|
121
|
+
*/
|
|
122
|
+
remainingTTL(key) {
|
|
123
|
+
const r = this.cache.getRemainingTTL(key);
|
|
124
|
+
return r === Infinity ? 0 : Math.max(0, r);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Snapshot every live entry as `{ k, v, e }` where `e` is an ABSOLUTE expiry timestamp
|
|
129
|
+
* (ms epoch), or 0 for no expiry. Absolute (not remaining) so a reload after delay restores
|
|
130
|
+
* the correct lifetime. Expired entries are skipped.
|
|
131
|
+
*/
|
|
132
|
+
dump() {
|
|
133
|
+
const now = Date.now();
|
|
134
|
+
const entries = [];
|
|
135
|
+
for (const key of this.cache.keys()) {
|
|
136
|
+
const remaining = this.cache.getRemainingTTL(key);
|
|
137
|
+
if (remaining <= 0) continue; // expired (0) — drop it
|
|
138
|
+
const value = this.cache.peek(key); // no recency churn during a dump
|
|
139
|
+
entries.push({ k: key, v: value, e: remaining === Infinity ? 0 : now + remaining });
|
|
140
|
+
}
|
|
141
|
+
return entries;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** Restore entries produced by `dump()` (or individual persisted set records). */
|
|
145
|
+
load(entries) {
|
|
146
|
+
const now = Date.now();
|
|
147
|
+
for (const { k, v, e } of entries) {
|
|
148
|
+
if (e && e <= now) continue; // already expired
|
|
149
|
+
const ttl = e ? e - now : undefined;
|
|
150
|
+
this.cache.set(k, v, ttl ? { ttl } : undefined);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
module.exports = Store;
|
package/src/transport.js
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const os = require('os');
|
|
4
|
+
const path = require('path');
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Resolve the local socket address for a given broker name.
|
|
8
|
+
*
|
|
9
|
+
* On Windows we must use a named pipe (`\\.\pipe\<name>`); on POSIX systems we
|
|
10
|
+
* use a Unix domain socket file under the OS temp dir. Node's `net` module
|
|
11
|
+
* accepts both forms transparently as the `path` argument to listen()/connect().
|
|
12
|
+
*
|
|
13
|
+
* Precedence: explicit env override (PROCMESH_SOCKET) > derived from name.
|
|
14
|
+
*/
|
|
15
|
+
function resolveAddress(name = 'default') {
|
|
16
|
+
if (process.env.PROCMESH_SOCKET) return process.env.PROCMESH_SOCKET;
|
|
17
|
+
const id = `procmesh-${name}`;
|
|
18
|
+
if (process.platform === 'win32') {
|
|
19
|
+
return `\\\\.\\pipe\\${id}`;
|
|
20
|
+
}
|
|
21
|
+
return path.join(os.tmpdir(), `${id}.sock`);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** True if the address is a Windows named pipe (no filesystem entry to clean up). */
|
|
25
|
+
function isPipe(address) {
|
|
26
|
+
return (
|
|
27
|
+
typeof address === 'string' &&
|
|
28
|
+
(address.startsWith('\\\\.\\pipe\\') || address.startsWith('\\\\?\\pipe\\'))
|
|
29
|
+
);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
module.exports = { resolveAddress, isPipe };
|