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/codec.js
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* A codec serializes/deserializes message objects to/from Buffers.
|
|
5
|
+
* Shape: { encode(obj) -> Buffer, decode(Buffer) -> obj }.
|
|
6
|
+
*
|
|
7
|
+
* The default JSON codec is zero-dependency and debuggable. `msgpackr` is
|
|
8
|
+
* offered as an optional faster binary codec (graceful fallback if not installed).
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
const jsonCodec = {
|
|
12
|
+
name: 'json',
|
|
13
|
+
encode(obj) {
|
|
14
|
+
return Buffer.from(JSON.stringify(obj));
|
|
15
|
+
},
|
|
16
|
+
decode(buf) {
|
|
17
|
+
return JSON.parse(buf.toString('utf8'));
|
|
18
|
+
},
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
let msgpackCodec = null;
|
|
22
|
+
function loadMsgpack() {
|
|
23
|
+
if (msgpackCodec) return msgpackCodec;
|
|
24
|
+
let mod;
|
|
25
|
+
try {
|
|
26
|
+
mod = require('msgpackr');
|
|
27
|
+
} catch (err) {
|
|
28
|
+
throw new Error(
|
|
29
|
+
'codec "msgpack" requires the optional dependency "msgpackr". Run `npm install msgpackr`, or use the default "json" codec.'
|
|
30
|
+
);
|
|
31
|
+
}
|
|
32
|
+
const packer = new mod.Packr({ structuredClone: true });
|
|
33
|
+
msgpackCodec = {
|
|
34
|
+
name: 'msgpack',
|
|
35
|
+
encode: (obj) => packer.pack(obj),
|
|
36
|
+
decode: (buf) => packer.unpack(buf),
|
|
37
|
+
};
|
|
38
|
+
return msgpackCodec;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Normalize a codec option into a concrete codec object.
|
|
43
|
+
* Accepts: undefined | 'json' | 'msgpack' | a custom { encode, decode } object.
|
|
44
|
+
*/
|
|
45
|
+
function resolveCodec(codec) {
|
|
46
|
+
if (!codec || codec === 'json') return jsonCodec;
|
|
47
|
+
if (codec === 'msgpack' || codec === 'msgpackr') return loadMsgpack();
|
|
48
|
+
if (typeof codec === 'object' && typeof codec.encode === 'function' && typeof codec.decode === 'function') {
|
|
49
|
+
return codec;
|
|
50
|
+
}
|
|
51
|
+
throw new Error(`invalid codec: ${String(codec)} (expected "json", "msgpack", or a { encode, decode } object)`);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
module.exports = { jsonCodec, resolveCodec };
|
package/src/errors.js
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
class ProcMeshError extends Error {
|
|
4
|
+
constructor(message, code) {
|
|
5
|
+
super(message);
|
|
6
|
+
this.name = this.constructor.name;
|
|
7
|
+
this.code = code;
|
|
8
|
+
}
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
class Disconnected extends ProcMeshError {
|
|
12
|
+
constructor(message = 'not connected to broker') {
|
|
13
|
+
super(message, 'EDISCONNECTED');
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
class CallTimeout extends ProcMeshError {
|
|
18
|
+
constructor(message = 'request timed out') {
|
|
19
|
+
super(message, 'ETIMEOUT');
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
class LockTimeout extends ProcMeshError {
|
|
24
|
+
constructor(message = 'could not acquire lock') {
|
|
25
|
+
super(message, 'ELOCKTIMEOUT');
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** A fenced write was rejected: the caller's lock was superseded (stale fencing token). */
|
|
30
|
+
class Fenced extends ProcMeshError {
|
|
31
|
+
constructor(message = 'fenced: stale lock token') {
|
|
32
|
+
super(message, 'EFENCED');
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** An error raised on the broker or a remote RPC handler, relayed to the caller. */
|
|
37
|
+
class RemoteError extends ProcMeshError {
|
|
38
|
+
constructor(message, code) {
|
|
39
|
+
super(message, code || 'EREMOTE');
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
module.exports = { ProcMeshError, Disconnected, CallTimeout, LockTimeout, Fenced, RemoteError };
|
package/src/hashring.js
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Keyspace hashing for sharded deployments. Every process that joins a sharded mesh
|
|
5
|
+
* must hash each key the same way, so that cache/atomic/locks for a given key always
|
|
6
|
+
* land on the single broker that owns it (preserving correctness). This is the one
|
|
7
|
+
* shared implementation used by both the library (src/sharded-client.js) and the
|
|
8
|
+
* benchmark harness (bench/shard.js re-exports it) — they can never drift apart.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
/** FNV-1a 32-bit hash of a string — fast, dependency-free, good enough for sharding. */
|
|
12
|
+
function fnv1a(str) {
|
|
13
|
+
let h = 0x811c9dc5;
|
|
14
|
+
for (let i = 0; i < str.length; i++) {
|
|
15
|
+
h ^= str.charCodeAt(i);
|
|
16
|
+
h = Math.imul(h, 0x01000193);
|
|
17
|
+
}
|
|
18
|
+
return h >>> 0;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** Pick the shard index for a key given the number of shards. */
|
|
22
|
+
function shardIndex(key, n) {
|
|
23
|
+
if (n <= 1) return 0;
|
|
24
|
+
return fnv1a(String(key)) % n;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
module.exports = { fnv1a, shardIndex };
|
package/src/index.js
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const Client = require('./client');
|
|
4
|
+
const ShardedClient = require('./sharded-client');
|
|
5
|
+
const Broker = require('./broker');
|
|
6
|
+
const errors = require('./errors');
|
|
7
|
+
const { resolveAddress } = require('./transport');
|
|
8
|
+
|
|
9
|
+
/** True when opts asks for more than one shard (a count > 1 or a multi-element array). */
|
|
10
|
+
function isSharded(opts) {
|
|
11
|
+
const s = opts.shards;
|
|
12
|
+
return (typeof s === 'number' && s > 1) || (Array.isArray(s) && s.length > 1);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Connect to (and, by default, auto-spawn) the shared broker.
|
|
17
|
+
* Returns a connected Client — or, when `opts.shards` requests more than one shard, a
|
|
18
|
+
* ShardedClient that spreads work across N broker processes behind the identical API.
|
|
19
|
+
*
|
|
20
|
+
* @param {object} [opts]
|
|
21
|
+
* @param {string} [opts.name='default'] logical mesh name (maps to a socket)
|
|
22
|
+
* @param {string} [opts.address] explicit socket path / pipe name
|
|
23
|
+
* @param {string|object} [opts.codec] 'json' (default) | 'msgpack' | custom
|
|
24
|
+
* @param {boolean} [opts.autoSpawn=true] spawn a broker if none is running
|
|
25
|
+
* @param {boolean} [opts.reconnect=true] auto-reconnect on connection loss
|
|
26
|
+
* @param {object} [opts.cache] broker cache config { max, ttl, maxSize }
|
|
27
|
+
* @param {number|Array<string|object>} [opts.shards] shard across N brokers: a count N
|
|
28
|
+
* (auto-spawns brokers named `${name}#0..#N-1`) or an array of names/{name,address} specs
|
|
29
|
+
*/
|
|
30
|
+
async function createClient(opts = {}) {
|
|
31
|
+
const client = isSharded(opts) ? new ShardedClient(opts) : new Client(opts);
|
|
32
|
+
await client.connect();
|
|
33
|
+
return client;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Create (but do not start) a Broker. Call `.start()` to begin listening. */
|
|
37
|
+
function createBroker(opts = {}) {
|
|
38
|
+
return new Broker(opts);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
module.exports = {
|
|
42
|
+
createClient,
|
|
43
|
+
createBroker,
|
|
44
|
+
Client,
|
|
45
|
+
ShardedClient,
|
|
46
|
+
Broker,
|
|
47
|
+
resolveAddress,
|
|
48
|
+
errors,
|
|
49
|
+
};
|
package/src/locks.js
ADDED
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Cross-process mutex manager, held in the broker. Each lock has at most one
|
|
5
|
+
* owner; contenders queue FIFO. A lock auto-releases after its TTL (so a crashed
|
|
6
|
+
* owner can't deadlock the system) and all of a connection's locks are released
|
|
7
|
+
* when it disconnects.
|
|
8
|
+
*
|
|
9
|
+
* Connections are identified by an opaque `connId`. acquire() resolves with a
|
|
10
|
+
* boolean: true if the lock was granted, false if it timed out / was unavailable.
|
|
11
|
+
*/
|
|
12
|
+
class LockManager {
|
|
13
|
+
/**
|
|
14
|
+
* @param {object} [opts]
|
|
15
|
+
* @param {function} [opts.mintToken] returns the next monotonic fencing token; the broker
|
|
16
|
+
* injects one backed by a (persisted) global counter. Defaults to a private counter so the
|
|
17
|
+
* manager is usable standalone in tests.
|
|
18
|
+
*/
|
|
19
|
+
constructor({ mintToken } = {}) {
|
|
20
|
+
this.locks = new Map(); // key -> { owner, ttl, timer, token, waiters: [{ connId, ttl, resolve, timer }] }
|
|
21
|
+
this.fenceHigh = new Map(); // lockKey -> highest fencing token ever issued (never deleted)
|
|
22
|
+
let n = 0;
|
|
23
|
+
this.mintToken = mintToken || (() => (n += 1));
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
acquire(key, connId, { ttl = 30000, wait = 0 } = {}) {
|
|
27
|
+
return new Promise((resolve) => {
|
|
28
|
+
let lock = this.locks.get(key);
|
|
29
|
+
if (!lock) {
|
|
30
|
+
lock = { owner: null, ttl, timer: null, waiters: [] };
|
|
31
|
+
this.locks.set(key, lock);
|
|
32
|
+
}
|
|
33
|
+
if (lock.owner === null) {
|
|
34
|
+
this._grant(key, lock, connId, ttl);
|
|
35
|
+
return resolve({ acquired: true, token: lock.token });
|
|
36
|
+
}
|
|
37
|
+
if (wait <= 0) {
|
|
38
|
+
this._gcIfEmpty(key, lock);
|
|
39
|
+
return resolve({ acquired: false, token: null });
|
|
40
|
+
}
|
|
41
|
+
const waiter = { connId, ttl, resolve, timer: null };
|
|
42
|
+
waiter.timer = setTimeout(() => {
|
|
43
|
+
lock.waiters = lock.waiters.filter((w) => w !== waiter);
|
|
44
|
+
this._gcIfEmpty(key, lock);
|
|
45
|
+
resolve({ acquired: false, token: null });
|
|
46
|
+
}, wait);
|
|
47
|
+
if (waiter.timer.unref) waiter.timer.unref();
|
|
48
|
+
lock.waiters.push(waiter);
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
release(key, connId) {
|
|
53
|
+
const lock = this.locks.get(key);
|
|
54
|
+
if (!lock || lock.owner !== connId) return false;
|
|
55
|
+
this._release(key);
|
|
56
|
+
return true;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Release everything held or awaited by a (disconnected) connection. */
|
|
60
|
+
releaseAll(connId) {
|
|
61
|
+
for (const [key, lock] of this.locks) {
|
|
62
|
+
lock.waiters = lock.waiters.filter((w) => {
|
|
63
|
+
if (w.connId === connId) {
|
|
64
|
+
clearTimeout(w.timer);
|
|
65
|
+
return false;
|
|
66
|
+
}
|
|
67
|
+
return true;
|
|
68
|
+
});
|
|
69
|
+
if (lock.owner === connId) this._release(key);
|
|
70
|
+
else this._gcIfEmpty(key, lock);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
_grant(key, lock, connId, ttl) {
|
|
75
|
+
lock.owner = connId;
|
|
76
|
+
lock.ttl = ttl;
|
|
77
|
+
// Each grant gets a fresh, strictly-larger token (its epoch). Raising the fence bar
|
|
78
|
+
// here means the instant a lock is (re-)granted — including a TTL-expiry hand-off to a
|
|
79
|
+
// waiter — any prior holder's token is already below the bar and will be fenced off.
|
|
80
|
+
lock.token = this.mintToken();
|
|
81
|
+
this.bumpFence(key, lock.token);
|
|
82
|
+
clearTimeout(lock.timer);
|
|
83
|
+
lock.timer = setTimeout(() => this._release(key), ttl);
|
|
84
|
+
if (lock.timer.unref) lock.timer.unref();
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
_release(key) {
|
|
88
|
+
const lock = this.locks.get(key);
|
|
89
|
+
if (!lock) return;
|
|
90
|
+
clearTimeout(lock.timer);
|
|
91
|
+
lock.owner = null;
|
|
92
|
+
lock.timer = null;
|
|
93
|
+
const next = lock.waiters.shift();
|
|
94
|
+
if (next) {
|
|
95
|
+
clearTimeout(next.timer);
|
|
96
|
+
this._grant(key, lock, next.connId, next.ttl);
|
|
97
|
+
next.resolve({ acquired: true, token: lock.token });
|
|
98
|
+
} else {
|
|
99
|
+
this._gcIfEmpty(key, lock);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
_gcIfEmpty(key, lock) {
|
|
104
|
+
if (lock.owner === null && lock.waiters.length === 0) {
|
|
105
|
+
clearTimeout(lock.timer);
|
|
106
|
+
this.locks.delete(key);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** Raise the per-key fence bar. Never lowered, never deleted (a freed key may be re-locked). */
|
|
111
|
+
bumpFence(key, token) {
|
|
112
|
+
const cur = this.fenceHigh.get(key) || 0;
|
|
113
|
+
if (token > cur) this.fenceHigh.set(key, token);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** Highest fencing token ever issued for a key (0 if never locked). */
|
|
117
|
+
getFenceHigh(key) {
|
|
118
|
+
return this.fenceHigh.get(key) || 0;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** Lightweight observability snapshot: held lock count and total queued waiters. */
|
|
122
|
+
stats() {
|
|
123
|
+
let waiters = 0;
|
|
124
|
+
for (const lock of this.locks.values()) waiters += lock.waiters.length;
|
|
125
|
+
return { locks: this.locks.size, waiters };
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
module.exports = LockManager;
|
|
@@ -0,0 +1,327 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('fs');
|
|
4
|
+
const path = require('path');
|
|
5
|
+
const os = require('os');
|
|
6
|
+
const { encodeFrame, FrameDecoder } = require('./protocol');
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Crash-survival persistence for the broker: a periodic snapshot + an append-only log (AOF)
|
|
10
|
+
* of mutation EFFECTS (absolute set / delete / clear), so replay is idempotent and order-
|
|
11
|
+
* independent. Connection-scoped state (locks, subscriptions, RPC regs, in-flight calls) is
|
|
12
|
+
* intentionally NOT persisted — see the production-hardening plan. Node built-ins only.
|
|
13
|
+
*
|
|
14
|
+
* Durability modes (fsync policy):
|
|
15
|
+
* 'no' — best-effort async writes; OS decides when to flush.
|
|
16
|
+
* 'everysec' — async writes + ~1s periodic fdatasync (default); ≤1s power-loss window.
|
|
17
|
+
* 'always' — synchronous write + fsync before returning; durable, blocks the loop.
|
|
18
|
+
*
|
|
19
|
+
* The fencing-token counter is kept monotonic across restarts by reserving token blocks: each
|
|
20
|
+
* time issuance crosses a block boundary we log a 'token' record for the new ceiling, so the
|
|
21
|
+
* restored seed is always ≥ any token ever issued.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
const SNAPSHOT_VERSION = 1;
|
|
25
|
+
const TOKEN_BLOCK = 1024; // reserve fencing tokens in blocks; ~1 AOF record per 1024 grants
|
|
26
|
+
const DEFAULT_AOF_REWRITE_OPS = 100000; // compact (snapshot + truncate) after this many appends
|
|
27
|
+
|
|
28
|
+
/** No-op persistence used when the feature is disabled (zero-config default). */
|
|
29
|
+
class NullPersistence {
|
|
30
|
+
constructor() {
|
|
31
|
+
this.enabled = false;
|
|
32
|
+
this.loadedToken = 0;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// eslint-disable-next-line class-methods-use-this
|
|
36
|
+
async load() {}
|
|
37
|
+
|
|
38
|
+
// eslint-disable-next-line class-methods-use-this
|
|
39
|
+
logMutation() {}
|
|
40
|
+
|
|
41
|
+
// eslint-disable-next-line class-methods-use-this
|
|
42
|
+
noteToken() {}
|
|
43
|
+
|
|
44
|
+
// eslint-disable-next-line class-methods-use-this
|
|
45
|
+
start() {}
|
|
46
|
+
|
|
47
|
+
// eslint-disable-next-line class-methods-use-this
|
|
48
|
+
async flushAndClose() {}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
class Persistence {
|
|
52
|
+
constructor({ dir, mode = 'everysec', codec, snapshotInterval = 0, aofRewriteOps = DEFAULT_AOF_REWRITE_OPS } = {}) {
|
|
53
|
+
this.enabled = true;
|
|
54
|
+
this.dir = dir;
|
|
55
|
+
this.mode = mode; // 'no' | 'everysec' | 'always'
|
|
56
|
+
this.codec = codec;
|
|
57
|
+
this.snapshotInterval = snapshotInterval;
|
|
58
|
+
this.aofRewriteOps = aofRewriteOps;
|
|
59
|
+
|
|
60
|
+
this.snapshotPath = path.join(dir, 'snapshot.bin');
|
|
61
|
+
this.aofPath = path.join(dir, 'aof.bin');
|
|
62
|
+
this.lockPath = path.join(dir, 'broker.lock');
|
|
63
|
+
|
|
64
|
+
this.fd = null; // AOF file descriptor (append)
|
|
65
|
+
this.queue = []; // pending frames (async modes)
|
|
66
|
+
this.writing = false;
|
|
67
|
+
this.dirty = false; // unsynced bytes present
|
|
68
|
+
this.opsSinceSnapshot = 0;
|
|
69
|
+
|
|
70
|
+
this.loadedToken = 0; // highest reserved token recovered on load
|
|
71
|
+
this.tokenReserved = 0; // current reserved ceiling
|
|
72
|
+
this._store = null; // set in load(), used for compaction snapshots
|
|
73
|
+
|
|
74
|
+
this._fsyncTimer = null;
|
|
75
|
+
this._snapshotTimer = null;
|
|
76
|
+
this._closed = false;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// --------------------------------------------------------------------- recovery
|
|
80
|
+
|
|
81
|
+
async load(store) {
|
|
82
|
+
this._store = store;
|
|
83
|
+
fs.mkdirSync(this.dir, { recursive: true });
|
|
84
|
+
this._acquireLock();
|
|
85
|
+
|
|
86
|
+
// 1. Snapshot (compaction base).
|
|
87
|
+
if (fs.existsSync(this.snapshotPath)) {
|
|
88
|
+
try {
|
|
89
|
+
const snap = this.codec.decode(fs.readFileSync(this.snapshotPath));
|
|
90
|
+
if (snap && snap.version === SNAPSHOT_VERSION) {
|
|
91
|
+
store.load(snap.entries || []);
|
|
92
|
+
this.loadedToken = Math.max(this.loadedToken, snap.fenceToken || 0);
|
|
93
|
+
}
|
|
94
|
+
} catch {
|
|
95
|
+
// Corrupt/foreign snapshot — recover from the AOF alone rather than refuse to start.
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// 2. AOF tail. FrameDecoder yields only complete frames, so a torn final record (kill -9
|
|
100
|
+
// mid-write) is silently dropped — the log self-truncates at the last good frame.
|
|
101
|
+
if (fs.existsSync(this.aofPath)) {
|
|
102
|
+
const decoder = new FrameDecoder(this.codec);
|
|
103
|
+
const buf = fs.readFileSync(this.aofPath);
|
|
104
|
+
try {
|
|
105
|
+
decoder.push(buf, (rec) => this._apply(store, rec));
|
|
106
|
+
} catch {
|
|
107
|
+
// Decode error mid-stream — stop at the corruption; valid prefix is already applied.
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// 3. Compact: fold what we just recovered into a fresh snapshot, then start a clean AOF.
|
|
112
|
+
this.tokenReserved = this.loadedToken;
|
|
113
|
+
this._writeSnapshot(store);
|
|
114
|
+
this.fd = fs.openSync(this.aofPath, 'a');
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
_apply(store, rec) {
|
|
118
|
+
switch (rec.op) {
|
|
119
|
+
case 'set':
|
|
120
|
+
store.load([{ k: rec.k, v: rec.v, e: rec.e || 0 }]);
|
|
121
|
+
break;
|
|
122
|
+
case 'del':
|
|
123
|
+
store.del(rec.k);
|
|
124
|
+
break;
|
|
125
|
+
case 'clear':
|
|
126
|
+
store.clear();
|
|
127
|
+
break;
|
|
128
|
+
case 'token':
|
|
129
|
+
this.loadedToken = Math.max(this.loadedToken, rec.n || 0);
|
|
130
|
+
break;
|
|
131
|
+
default:
|
|
132
|
+
break;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// --------------------------------------------------------------------- logging
|
|
137
|
+
|
|
138
|
+
/** Record a mutation effect. `rec` is { op:'set',k,v,e } | { op:'del',k } | { op:'clear' }. */
|
|
139
|
+
logMutation(rec) {
|
|
140
|
+
this._append(rec);
|
|
141
|
+
this.opsSinceSnapshot += 1;
|
|
142
|
+
if (this.opsSinceSnapshot >= this.aofRewriteOps && this._store) this._compact();
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** Called by the broker on every fencing-token mint; reserves a block when crossed. */
|
|
146
|
+
noteToken(n) {
|
|
147
|
+
if (n > this.tokenReserved) {
|
|
148
|
+
this.tokenReserved = Math.ceil((n + 1) / TOKEN_BLOCK) * TOKEN_BLOCK;
|
|
149
|
+
this._append({ op: 'token', n: this.tokenReserved });
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
_append(rec) {
|
|
154
|
+
if (this._closed || this.fd == null) {
|
|
155
|
+
// Before the AOF fd is open (during load), records are already in the snapshot/store.
|
|
156
|
+
return;
|
|
157
|
+
}
|
|
158
|
+
const frame = encodeFrame(this.codec, rec);
|
|
159
|
+
if (this.mode === 'always') {
|
|
160
|
+
try {
|
|
161
|
+
fs.writeSync(this.fd, frame);
|
|
162
|
+
fs.fsyncSync(this.fd);
|
|
163
|
+
} catch (err) {
|
|
164
|
+
this._onWriteError(err);
|
|
165
|
+
}
|
|
166
|
+
return;
|
|
167
|
+
}
|
|
168
|
+
this.queue.push(frame);
|
|
169
|
+
this._drain();
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
_drain() {
|
|
173
|
+
if (this.writing || this.queue.length === 0 || this.fd == null) return;
|
|
174
|
+
const batch = this.queue.length === 1 ? this.queue[0] : Buffer.concat(this.queue);
|
|
175
|
+
this.queue = [];
|
|
176
|
+
this.writing = true;
|
|
177
|
+
fs.write(this.fd, batch, (err) => {
|
|
178
|
+
this.writing = false;
|
|
179
|
+
if (err) {
|
|
180
|
+
this._onWriteError(err);
|
|
181
|
+
return;
|
|
182
|
+
}
|
|
183
|
+
this.dirty = true;
|
|
184
|
+
if (this.queue.length) this._drain();
|
|
185
|
+
});
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
_onWriteError(err) {
|
|
189
|
+
// Disk full / read-only / etc.: keep serving from memory, surface the failure, stop trying.
|
|
190
|
+
this.writeError = err;
|
|
191
|
+
if (this.onError) this.onError(err);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// ------------------------------------------------------------------- snapshots
|
|
195
|
+
|
|
196
|
+
_writeSnapshot(store) {
|
|
197
|
+
const payload = this.codec.encode({
|
|
198
|
+
version: SNAPSHOT_VERSION,
|
|
199
|
+
createdAt: Date.now(),
|
|
200
|
+
fenceToken: this.tokenReserved,
|
|
201
|
+
entries: store.dump(),
|
|
202
|
+
});
|
|
203
|
+
const tmp = `${this.snapshotPath}.tmp`;
|
|
204
|
+
const fd = fs.openSync(tmp, 'w');
|
|
205
|
+
try {
|
|
206
|
+
fs.writeSync(fd, payload);
|
|
207
|
+
fs.fsyncSync(fd);
|
|
208
|
+
} finally {
|
|
209
|
+
fs.closeSync(fd);
|
|
210
|
+
}
|
|
211
|
+
fs.renameSync(tmp, this.snapshotPath); // atomic replace
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/** Compaction: snapshot current state, then truncate the AOF (snapshot now subsumes it). */
|
|
215
|
+
_compact() {
|
|
216
|
+
if (!this._store) return;
|
|
217
|
+
this._writeSnapshot(this._store);
|
|
218
|
+
this.opsSinceSnapshot = 0;
|
|
219
|
+
// Truncate and reopen the AOF so future appends start from empty.
|
|
220
|
+
if (this.fd != null) fs.closeSync(this.fd);
|
|
221
|
+
this.fd = fs.openSync(this.aofPath, 'w'); // 'w' truncates
|
|
222
|
+
fs.closeSync(this.fd);
|
|
223
|
+
this.fd = fs.openSync(this.aofPath, 'a');
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// ------------------------------------------------------------------- lifecycle
|
|
227
|
+
|
|
228
|
+
start() {
|
|
229
|
+
if (this.mode === 'everysec') {
|
|
230
|
+
this._fsyncTimer = setInterval(() => {
|
|
231
|
+
if (this.dirty && this.fd != null) {
|
|
232
|
+
fs.fdatasync(this.fd, () => {});
|
|
233
|
+
this.dirty = false;
|
|
234
|
+
}
|
|
235
|
+
}, 1000);
|
|
236
|
+
if (this._fsyncTimer.unref) this._fsyncTimer.unref();
|
|
237
|
+
}
|
|
238
|
+
if (this.snapshotInterval > 0) {
|
|
239
|
+
this._snapshotTimer = setInterval(() => {
|
|
240
|
+
if (this._store) this._compact();
|
|
241
|
+
}, this.snapshotInterval);
|
|
242
|
+
if (this._snapshotTimer.unref) this._snapshotTimer.unref();
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
async flushAndClose() {
|
|
247
|
+
this._closed = true;
|
|
248
|
+
if (this._fsyncTimer) clearInterval(this._fsyncTimer);
|
|
249
|
+
if (this._snapshotTimer) clearInterval(this._snapshotTimer);
|
|
250
|
+
// Final compaction makes a planned shutdown lossless.
|
|
251
|
+
try {
|
|
252
|
+
if (this._store) this._writeSnapshot(this._store);
|
|
253
|
+
} catch (err) {
|
|
254
|
+
this._onWriteError(err);
|
|
255
|
+
}
|
|
256
|
+
if (this.fd != null) {
|
|
257
|
+
try {
|
|
258
|
+
fs.fsyncSync(this.fd);
|
|
259
|
+
fs.closeSync(this.fd);
|
|
260
|
+
} catch {
|
|
261
|
+
/* ignore */
|
|
262
|
+
}
|
|
263
|
+
this.fd = null;
|
|
264
|
+
}
|
|
265
|
+
this._releaseLock();
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
// ------------------------------------------------------------------- dir lock
|
|
269
|
+
|
|
270
|
+
_acquireLock() {
|
|
271
|
+
for (let attempt = 0; attempt < 2; attempt++) {
|
|
272
|
+
try {
|
|
273
|
+
const fd = fs.openSync(this.lockPath, 'wx'); // fail if exists
|
|
274
|
+
fs.writeSync(fd, String(process.pid));
|
|
275
|
+
fs.closeSync(fd);
|
|
276
|
+
return;
|
|
277
|
+
} catch (err) {
|
|
278
|
+
if (err.code !== 'EEXIST') throw err;
|
|
279
|
+
const stale = this._lockIsStale();
|
|
280
|
+
if (!stale) {
|
|
281
|
+
const e = new Error(`persist dir ${this.dir} is locked by a live broker`);
|
|
282
|
+
e.code = 'EPERSISTLOCKED';
|
|
283
|
+
throw e;
|
|
284
|
+
}
|
|
285
|
+
try {
|
|
286
|
+
fs.unlinkSync(this.lockPath);
|
|
287
|
+
} catch {
|
|
288
|
+
/* race: another cleared it; retry */
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
_lockIsStale() {
|
|
295
|
+
try {
|
|
296
|
+
const pid = parseInt(fs.readFileSync(this.lockPath, 'utf8').trim(), 10);
|
|
297
|
+
if (!pid) return true;
|
|
298
|
+
process.kill(pid, 0); // throws ESRCH if the pid is gone
|
|
299
|
+
return false; // pid alive → not stale
|
|
300
|
+
} catch (err) {
|
|
301
|
+
return err.code === 'ESRCH' || err.code === 'ENOENT'; // gone → stale
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
_releaseLock() {
|
|
306
|
+
try {
|
|
307
|
+
fs.unlinkSync(this.lockPath);
|
|
308
|
+
} catch {
|
|
309
|
+
/* ignore */
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/**
|
|
315
|
+
* Build a Persistence (or a no-op) from broker options.
|
|
316
|
+
* Disabled unless `opts` is set or PROCMESH_PERSIST_DIR is present (zero-config stays in-memory).
|
|
317
|
+
*/
|
|
318
|
+
function createPersistence(opts, name, codec) {
|
|
319
|
+
const envDir = process.env.PROCMESH_PERSIST_DIR;
|
|
320
|
+
if (!opts && !envDir) return new NullPersistence();
|
|
321
|
+
const cfg = opts === true ? {} : opts || {};
|
|
322
|
+
if (cfg.mode === 'off') return new NullPersistence();
|
|
323
|
+
const dir = cfg.dir || envDir || path.join(os.tmpdir(), `procmesh-${name || 'default'}`);
|
|
324
|
+
return new Persistence({ ...cfg, dir, codec });
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
module.exports = { Persistence, NullPersistence, createPersistence };
|