wicked-bus 1.1.0 → 2.0.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/commands/cli.js +11 -1
- package/commands/cmd-daemon.js +238 -0
- package/commands/cmd-ui.js +156 -0
- package/install.mjs +88 -15
- package/lib/archive.js +198 -0
- package/lib/cas.js +310 -0
- package/lib/causality.js +129 -0
- package/lib/daemon-client.js +209 -0
- package/lib/daemon-notify.js +97 -0
- package/lib/daemon-singleton.js +186 -0
- package/lib/daemon.js +444 -0
- package/lib/db.js +13 -6
- package/lib/emit.js +94 -5
- package/lib/errors.js +15 -0
- package/lib/ipc-protocol.js +158 -0
- package/lib/migrate.js +133 -0
- package/lib/query.js +189 -0
- package/lib/schema-registry.js +271 -0
- package/lib/subscribe-push-or-poll.js +263 -0
- package/lib/sweep-v2.js +420 -0
- package/lib/sweep.js +22 -12
- package/lib/ui-server.js +301 -0
- package/package.json +1 -1
package/lib/archive.js
ADDED
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Archive bucket lifecycle for the v2 tiered-storage warm tier.
|
|
3
|
+
*
|
|
4
|
+
* Each warm bucket is an independent SQLite file at
|
|
5
|
+
* `<data_dir>/archive/bus-YYYY-MM[suffix].db`. Files are independent —
|
|
6
|
+
* corrupting one bucket affects only the events in that month/split.
|
|
7
|
+
*
|
|
8
|
+
* Each bucket carries a `_meta` table (NOT a fictitious PRAGMA) holding
|
|
9
|
+
* `min_event_id`, `max_event_id`, `created_at`, and `sealed_at`. The query
|
|
10
|
+
* resolver reads these via normal SELECT to decide which buckets to ATTACH
|
|
11
|
+
* during a warm-spill (see lib/query.js and DESIGN-v2.md §5.4).
|
|
12
|
+
*
|
|
13
|
+
* Buckets without a `sealed_at` value are still being filled by sweep and
|
|
14
|
+
* are conservatively treated as covering up to MAX_SAFE_INTEGER until sealed.
|
|
15
|
+
*
|
|
16
|
+
* @module lib/archive
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import path from 'node:path';
|
|
20
|
+
import fs from 'node:fs';
|
|
21
|
+
import { createRequire } from 'node:module';
|
|
22
|
+
|
|
23
|
+
const require = createRequire(import.meta.url);
|
|
24
|
+
|
|
25
|
+
const BUCKET_FILE_RE = /^bus-\d{4}-\d{2}[a-z]?\.db$/;
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Build the absolute path to the archive directory for a given data_dir.
|
|
29
|
+
* @param {string} dataDir
|
|
30
|
+
* @returns {string}
|
|
31
|
+
*/
|
|
32
|
+
export function archiveDir(dataDir) {
|
|
33
|
+
return path.join(dataDir, 'archive');
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Ensure the archive directory exists (idempotent).
|
|
38
|
+
* @param {string} archDir
|
|
39
|
+
*/
|
|
40
|
+
export function ensureArchiveDir(archDir) {
|
|
41
|
+
fs.mkdirSync(archDir, { recursive: true });
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Create a new archive bucket file with the v2 `events` schema mirrored
|
|
46
|
+
* from the live tier, plus the `_meta` table. Returns the open Database
|
|
47
|
+
* handle. Caller is responsible for closing it.
|
|
48
|
+
*
|
|
49
|
+
* @param {string} filepath - absolute path to the bucket .db file
|
|
50
|
+
* @param {{ minEventId?: number, maxEventId?: number }} [meta]
|
|
51
|
+
* @returns {import('better-sqlite3').Database}
|
|
52
|
+
*/
|
|
53
|
+
export function createBucket(filepath, meta = {}) {
|
|
54
|
+
ensureArchiveDir(path.dirname(filepath));
|
|
55
|
+
const Database = require('better-sqlite3');
|
|
56
|
+
const db = new Database(filepath);
|
|
57
|
+
|
|
58
|
+
db.pragma('journal_mode = WAL');
|
|
59
|
+
db.pragma('synchronous = NORMAL');
|
|
60
|
+
db.pragma('busy_timeout = 5000');
|
|
61
|
+
|
|
62
|
+
db.exec(`
|
|
63
|
+
CREATE TABLE IF NOT EXISTS events (
|
|
64
|
+
event_id INTEGER PRIMARY KEY,
|
|
65
|
+
event_type TEXT NOT NULL,
|
|
66
|
+
domain TEXT NOT NULL,
|
|
67
|
+
subdomain TEXT NOT NULL DEFAULT '',
|
|
68
|
+
payload TEXT NOT NULL,
|
|
69
|
+
schema_version TEXT NOT NULL DEFAULT '1.0.0',
|
|
70
|
+
idempotency_key TEXT NOT NULL,
|
|
71
|
+
emitted_at INTEGER NOT NULL,
|
|
72
|
+
expires_at INTEGER NOT NULL,
|
|
73
|
+
dedup_expires_at INTEGER NOT NULL,
|
|
74
|
+
metadata TEXT,
|
|
75
|
+
parent_event_id INTEGER,
|
|
76
|
+
session_id TEXT,
|
|
77
|
+
correlation_id TEXT,
|
|
78
|
+
producer_id TEXT,
|
|
79
|
+
origin_node_id TEXT,
|
|
80
|
+
registry_schema_version INTEGER,
|
|
81
|
+
payload_cas_sha TEXT
|
|
82
|
+
);
|
|
83
|
+
CREATE INDEX IF NOT EXISTS idx_events_event_type ON events(event_type);
|
|
84
|
+
CREATE INDEX IF NOT EXISTS idx_events_domain ON events(domain);
|
|
85
|
+
CREATE INDEX IF NOT EXISTS idx_events_correlation_id ON events(correlation_id);
|
|
86
|
+
|
|
87
|
+
CREATE TABLE IF NOT EXISTS _meta (
|
|
88
|
+
k TEXT PRIMARY KEY,
|
|
89
|
+
v TEXT NOT NULL
|
|
90
|
+
);
|
|
91
|
+
`);
|
|
92
|
+
|
|
93
|
+
setMeta(db, 'created_at', String(Date.now()));
|
|
94
|
+
if (meta.minEventId != null) setMeta(db, 'min_event_id', String(meta.minEventId));
|
|
95
|
+
if (meta.maxEventId != null) setMeta(db, 'max_event_id', String(meta.maxEventId));
|
|
96
|
+
|
|
97
|
+
return db;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Read the `_meta` key/value pairs from a bucket file.
|
|
102
|
+
* Opens read-only so a sealed bucket can be inspected without taking a write lock.
|
|
103
|
+
*
|
|
104
|
+
* @param {string} filepath
|
|
105
|
+
* @returns {Record<string,string>}
|
|
106
|
+
*/
|
|
107
|
+
export function getBucketMeta(filepath) {
|
|
108
|
+
const Database = require('better-sqlite3');
|
|
109
|
+
const db = new Database(filepath, { readonly: true });
|
|
110
|
+
try {
|
|
111
|
+
const rows = db.prepare('SELECT k, v FROM _meta').all();
|
|
112
|
+
const meta = {};
|
|
113
|
+
for (const r of rows) meta[r.k] = r.v;
|
|
114
|
+
return meta;
|
|
115
|
+
} finally {
|
|
116
|
+
db.close();
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Mark a bucket sealed: write `sealed_at` and (optionally) finalize `max_event_id`.
|
|
122
|
+
* Once sealed, a bucket's covered range is fixed and the resolver can use
|
|
123
|
+
* the [min, max] interval to skip it when out of range.
|
|
124
|
+
*
|
|
125
|
+
* @param {string} filepath
|
|
126
|
+
* @param {{ maxEventId?: number }} [opts]
|
|
127
|
+
*/
|
|
128
|
+
export function sealBucket(filepath, opts = {}) {
|
|
129
|
+
const Database = require('better-sqlite3');
|
|
130
|
+
const db = new Database(filepath);
|
|
131
|
+
try {
|
|
132
|
+
setMeta(db, 'sealed_at', String(Date.now()));
|
|
133
|
+
if (opts.maxEventId != null) {
|
|
134
|
+
setMeta(db, 'max_event_id', String(opts.maxEventId));
|
|
135
|
+
}
|
|
136
|
+
} finally {
|
|
137
|
+
db.close();
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function setMeta(db, k, v) {
|
|
142
|
+
db.prepare('INSERT OR REPLACE INTO _meta(k, v) VALUES (?, ?)').run(k, v);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* List all bucket files in the archive directory, sorted lexicographically.
|
|
147
|
+
* Suffix letters (`a`, `b`, …) preserve creation order within a month after
|
|
148
|
+
* auto-split, so lexical sort matches creation order.
|
|
149
|
+
*
|
|
150
|
+
* @param {string} archDir
|
|
151
|
+
* @returns {string[]} absolute paths
|
|
152
|
+
*/
|
|
153
|
+
export function listBuckets(archDir) {
|
|
154
|
+
if (!fs.existsSync(archDir)) return [];
|
|
155
|
+
return fs.readdirSync(archDir)
|
|
156
|
+
.filter(f => BUCKET_FILE_RE.test(f))
|
|
157
|
+
.sort()
|
|
158
|
+
.map(f => path.join(archDir, f));
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Find buckets that cover (any part of) the event_id range [gapStart, gapEnd].
|
|
163
|
+
* Returns descriptors with absolute path + parsed range + unsealed flag.
|
|
164
|
+
* Unsealed buckets are conservatively included whenever their `min_event_id`
|
|
165
|
+
* is at or below `gapEnd`.
|
|
166
|
+
*
|
|
167
|
+
* @param {string} archDir
|
|
168
|
+
* @param {number} gapStart - inclusive
|
|
169
|
+
* @param {number} gapEnd - inclusive
|
|
170
|
+
* @returns {Array<{ filepath: string, min: number, max: number, unsealed: boolean }>}
|
|
171
|
+
*/
|
|
172
|
+
export function bucketsCoveringRange(archDir, gapStart, gapEnd) {
|
|
173
|
+
const all = listBuckets(archDir);
|
|
174
|
+
const covering = [];
|
|
175
|
+
for (const filepath of all) {
|
|
176
|
+
let meta;
|
|
177
|
+
try {
|
|
178
|
+
meta = getBucketMeta(filepath);
|
|
179
|
+
} catch (_e) {
|
|
180
|
+
// Unreadable bucket — surface to caller via spill, not enumeration.
|
|
181
|
+
// Caller's ATTACH attempt will throw WB-013 if needed.
|
|
182
|
+
covering.push({ filepath, min: -Infinity, max: Infinity, unsealed: true });
|
|
183
|
+
continue;
|
|
184
|
+
}
|
|
185
|
+
const min = meta.min_event_id != null ? Number(meta.min_event_id) : null;
|
|
186
|
+
const sealed = meta.sealed_at != null;
|
|
187
|
+
const max = sealed && meta.max_event_id != null
|
|
188
|
+
? Number(meta.max_event_id)
|
|
189
|
+
: Number.MAX_SAFE_INTEGER;
|
|
190
|
+
|
|
191
|
+
if (min === null) continue; // empty bucket — nothing to cover
|
|
192
|
+
|
|
193
|
+
if (max >= gapStart && min <= gapEnd) {
|
|
194
|
+
covering.push({ filepath, min, max, unsealed: !sealed });
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
return covering;
|
|
198
|
+
}
|
package/lib/cas.js
ADDED
|
@@ -0,0 +1,310 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Content-addressable store (CAS) for large event payloads.
|
|
3
|
+
*
|
|
4
|
+
* Per DESIGN-v2.md §6:
|
|
5
|
+
* - Storage: `<dataDir>/cas/<sha[0:2]>/<sha>` (200-bit sharding)
|
|
6
|
+
* - SHA-256 of uncompressed content; filename is full hex SHA
|
|
7
|
+
* - O_EXCL writes; collision = no-op (immutable, content-addressed)
|
|
8
|
+
* - Object size cap (default 256 MB) — `WB-008 PAYLOAD_TOO_LARGE` above
|
|
9
|
+
* - Reference-tracked via `events.payload_cas_sha`; GC walks live + warm
|
|
10
|
+
* buckets and deletes orphans past a configurable grace window
|
|
11
|
+
* - GC aborts with `WB-010 CAS_GC_INCOMPLETE_BUCKET_SET` if any bucket is
|
|
12
|
+
* unreadable (offline-bucket safety, round-1 council fix)
|
|
13
|
+
*
|
|
14
|
+
* The functions are synchronous because better-sqlite3 is synchronous and
|
|
15
|
+
* fs.* sync APIs are fine for the CAS sizes we target (≤256 MB by default).
|
|
16
|
+
*
|
|
17
|
+
* @module lib/cas
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import fs from 'node:fs';
|
|
21
|
+
import path from 'node:path';
|
|
22
|
+
import { createHash } from 'node:crypto';
|
|
23
|
+
import { createRequire } from 'node:module';
|
|
24
|
+
import { WBError } from './errors.js';
|
|
25
|
+
import { archiveDir, listBuckets } from './archive.js';
|
|
26
|
+
|
|
27
|
+
const require = createRequire(import.meta.url);
|
|
28
|
+
|
|
29
|
+
export const DEFAULT_OBJECT_MAX_BYTES = 256 * 1024 * 1024;
|
|
30
|
+
export const DEFAULT_GC_GRACE_DAYS = 7;
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Standard CAS root for a given dataDir.
|
|
34
|
+
* @param {string} dataDir
|
|
35
|
+
* @returns {string}
|
|
36
|
+
*/
|
|
37
|
+
export function casDir(dataDir) {
|
|
38
|
+
return path.join(dataDir, 'cas');
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function shardedPathFor(dataDir, sha) {
|
|
42
|
+
return path.join(casDir(dataDir), sha.slice(0, 2), sha);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function sha256(buf) {
|
|
46
|
+
return createHash('sha256').update(buf).digest('hex');
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function ensureBuffer(content) {
|
|
50
|
+
if (Buffer.isBuffer(content)) return content;
|
|
51
|
+
if (typeof content === 'string') return Buffer.from(content, 'utf8');
|
|
52
|
+
throw new WBError('WB-001', 'INVALID_EVENT_SCHEMA', {
|
|
53
|
+
message: 'cas.put requires Buffer or string content',
|
|
54
|
+
received: typeof content,
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// ---------------------------------------------------------------------------
|
|
59
|
+
// put / get / exists / stats
|
|
60
|
+
// ---------------------------------------------------------------------------
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Store content in the CAS. Returns the SHA-256 hex digest.
|
|
64
|
+
* Writes are O_EXCL — duplicate content (same SHA) is a no-op.
|
|
65
|
+
*
|
|
66
|
+
* @param {string} dataDir
|
|
67
|
+
* @param {Buffer|string} content
|
|
68
|
+
* @param {object} [opts]
|
|
69
|
+
* @param {number} [opts.max_bytes] override DEFAULT_OBJECT_MAX_BYTES
|
|
70
|
+
* @returns {string} SHA-256 hex digest
|
|
71
|
+
*/
|
|
72
|
+
export function put(dataDir, content, opts = {}) {
|
|
73
|
+
const buf = ensureBuffer(content);
|
|
74
|
+
const cap = opts.max_bytes ?? DEFAULT_OBJECT_MAX_BYTES;
|
|
75
|
+
|
|
76
|
+
if (buf.length > cap) {
|
|
77
|
+
throw new WBError('WB-008', 'PAYLOAD_TOO_LARGE', {
|
|
78
|
+
message: `CAS object exceeds ${cap}-byte cap`,
|
|
79
|
+
size: buf.length,
|
|
80
|
+
cap,
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const sha = sha256(buf);
|
|
85
|
+
const target = shardedPathFor(dataDir, sha);
|
|
86
|
+
|
|
87
|
+
if (fs.existsSync(target)) return sha; // immutable; collision = no-op
|
|
88
|
+
|
|
89
|
+
fs.mkdirSync(path.dirname(target), { recursive: true });
|
|
90
|
+
|
|
91
|
+
// Write to a temp file in the same directory then rename; this gives us
|
|
92
|
+
// atomic visibility and avoids partial reads during a crash.
|
|
93
|
+
const tmp = `${target}.tmp.${process.pid}.${Date.now()}`;
|
|
94
|
+
let fd;
|
|
95
|
+
try {
|
|
96
|
+
fd = fs.openSync(tmp, 'wx', 0o600);
|
|
97
|
+
fs.writeSync(fd, buf);
|
|
98
|
+
fs.fsyncSync(fd);
|
|
99
|
+
} finally {
|
|
100
|
+
if (fd !== undefined) {
|
|
101
|
+
try { fs.closeSync(fd); } catch (_e) { /* ignore */ }
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
try {
|
|
106
|
+
fs.renameSync(tmp, target);
|
|
107
|
+
} catch (e) {
|
|
108
|
+
// Race: another writer just moved their copy into place. Same SHA →
|
|
109
|
+
// identical content → safe to drop ours.
|
|
110
|
+
try { fs.unlinkSync(tmp); } catch (_e) { /* ignore */ }
|
|
111
|
+
if (e.code !== 'EEXIST') throw e;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
return sha;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Read content for a given SHA. Returns a Buffer, or null if not found.
|
|
119
|
+
*/
|
|
120
|
+
export function get(dataDir, sha) {
|
|
121
|
+
const target = shardedPathFor(dataDir, sha);
|
|
122
|
+
try {
|
|
123
|
+
return fs.readFileSync(target);
|
|
124
|
+
} catch (e) {
|
|
125
|
+
if (e.code === 'ENOENT') return null;
|
|
126
|
+
throw e;
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export function exists(dataDir, sha) {
|
|
131
|
+
return fs.existsSync(shardedPathFor(dataDir, sha));
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Aggregate stats: total objects, total bytes, root path.
|
|
136
|
+
*/
|
|
137
|
+
export function stats(dataDir) {
|
|
138
|
+
const root = casDir(dataDir);
|
|
139
|
+
if (!fs.existsSync(root)) {
|
|
140
|
+
return { root, object_count: 0, total_bytes: 0 };
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
let count = 0;
|
|
144
|
+
let bytes = 0;
|
|
145
|
+
for (const file of walkCasFiles(root)) {
|
|
146
|
+
count++;
|
|
147
|
+
try { bytes += fs.statSync(file).size; }
|
|
148
|
+
catch (_e) { /* removed concurrently */ }
|
|
149
|
+
}
|
|
150
|
+
return { root, object_count: count, total_bytes: bytes };
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function* walkCasFiles(root) {
|
|
154
|
+
for (const shard of safeReaddir(root)) {
|
|
155
|
+
const shardDir = path.join(root, shard);
|
|
156
|
+
let stat;
|
|
157
|
+
try { stat = fs.statSync(shardDir); }
|
|
158
|
+
catch (_e) { continue; }
|
|
159
|
+
if (!stat.isDirectory()) continue;
|
|
160
|
+
for (const f of safeReaddir(shardDir)) {
|
|
161
|
+
yield path.join(shardDir, f);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function safeReaddir(p) {
|
|
167
|
+
try { return fs.readdirSync(p); } catch (_e) { return []; }
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// ---------------------------------------------------------------------------
|
|
171
|
+
// GC — offline-bucket-safe (round-1 council fix)
|
|
172
|
+
// ---------------------------------------------------------------------------
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Walk the live tier and all warm buckets to compute the live SHA set, then
|
|
176
|
+
* remove CAS entries that are NOT referenced and were last modified before
|
|
177
|
+
* `now - graceMs`. ABORTS with WB-010 if any bucket is unreadable so we
|
|
178
|
+
* never silently shrink the live set on incomplete data.
|
|
179
|
+
*
|
|
180
|
+
* @param {object} opts
|
|
181
|
+
* @param {string} opts.dataDir
|
|
182
|
+
* @param {import('better-sqlite3').Database} opts.liveDb
|
|
183
|
+
* @param {number} [opts.grace_days]
|
|
184
|
+
* @param {number} [opts.now] test-injectable timestamp
|
|
185
|
+
* @param {string[]} [opts.allow_missing_buckets] basenames (or globs) the
|
|
186
|
+
* operator has agreed are
|
|
187
|
+
* intentionally absent (e.g.,
|
|
188
|
+
* exported to cold and removed)
|
|
189
|
+
* @param {boolean} [opts.dry_run=false]
|
|
190
|
+
* @returns {{
|
|
191
|
+
* live_shas: number,
|
|
192
|
+
* considered: number,
|
|
193
|
+
* deleted: number,
|
|
194
|
+
* skipped_in_grace: number,
|
|
195
|
+
* bytes_freed: number,
|
|
196
|
+
* }}
|
|
197
|
+
*/
|
|
198
|
+
export function gc(opts) {
|
|
199
|
+
const dataDir = opts.dataDir;
|
|
200
|
+
const liveDb = opts.liveDb;
|
|
201
|
+
const graceDays = opts.grace_days ?? DEFAULT_GC_GRACE_DAYS;
|
|
202
|
+
const graceMs = graceDays * 86400_000;
|
|
203
|
+
const now = opts.now ?? Date.now();
|
|
204
|
+
const allow = new Set(opts.allow_missing_buckets ?? []);
|
|
205
|
+
const dryRun = !!opts.dry_run;
|
|
206
|
+
|
|
207
|
+
if (!dataDir) throw new Error('gc requires opts.dataDir');
|
|
208
|
+
if (!liveDb) throw new Error('gc requires opts.liveDb');
|
|
209
|
+
|
|
210
|
+
const liveShas = collectLiveShasOrAbort(dataDir, liveDb, allow);
|
|
211
|
+
|
|
212
|
+
const root = casDir(dataDir);
|
|
213
|
+
if (!fs.existsSync(root)) {
|
|
214
|
+
return { live_shas: liveShas.size, considered: 0, deleted: 0, skipped_in_grace: 0, bytes_freed: 0 };
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
let considered = 0;
|
|
218
|
+
let deleted = 0;
|
|
219
|
+
let skippedInGrace = 0;
|
|
220
|
+
let bytesFreed = 0;
|
|
221
|
+
|
|
222
|
+
for (const file of walkCasFiles(root)) {
|
|
223
|
+
considered++;
|
|
224
|
+
const sha = path.basename(file);
|
|
225
|
+
if (liveShas.has(sha)) continue;
|
|
226
|
+
|
|
227
|
+
let stat;
|
|
228
|
+
try { stat = fs.statSync(file); }
|
|
229
|
+
catch (_e) { continue; } // removed concurrently
|
|
230
|
+
|
|
231
|
+
if (now - stat.mtimeMs < graceMs) {
|
|
232
|
+
skippedInGrace++;
|
|
233
|
+
continue;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
if (!dryRun) {
|
|
237
|
+
try {
|
|
238
|
+
fs.unlinkSync(file);
|
|
239
|
+
deleted++;
|
|
240
|
+
bytesFreed += stat.size;
|
|
241
|
+
} catch (_e) { /* concurrent delete or permission — skip */ }
|
|
242
|
+
} else {
|
|
243
|
+
deleted++;
|
|
244
|
+
bytesFreed += stat.size;
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
return {
|
|
249
|
+
live_shas: liveShas.size,
|
|
250
|
+
considered,
|
|
251
|
+
deleted,
|
|
252
|
+
skipped_in_grace: skippedInGrace,
|
|
253
|
+
bytes_freed: bytesFreed,
|
|
254
|
+
};
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* Build the set of payload_cas_sha values referenced by ANY event in live or
|
|
259
|
+
* any warm bucket. Aborts with WB-010 if any bucket is unreadable.
|
|
260
|
+
*/
|
|
261
|
+
function collectLiveShasOrAbort(dataDir, liveDb, allowSet) {
|
|
262
|
+
const shas = new Set();
|
|
263
|
+
|
|
264
|
+
// Live tier
|
|
265
|
+
for (const row of liveDb.prepare(
|
|
266
|
+
'SELECT payload_cas_sha FROM events WHERE payload_cas_sha IS NOT NULL'
|
|
267
|
+
).all()) {
|
|
268
|
+
if (row.payload_cas_sha) shas.add(row.payload_cas_sha);
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
// Warm tier — every expected bucket must be readable
|
|
272
|
+
const archDir = archiveDir(dataDir);
|
|
273
|
+
for (const filepath of listBuckets(archDir)) {
|
|
274
|
+
const basename = path.basename(filepath);
|
|
275
|
+
if (allowSet.has(basename)) continue; // operator-acknowledged absence
|
|
276
|
+
|
|
277
|
+
try { fs.accessSync(filepath, fs.constants.R_OK); }
|
|
278
|
+
catch (e) {
|
|
279
|
+
throw new WBError('WB-010', 'CAS_GC_INCOMPLETE_BUCKET_SET', {
|
|
280
|
+
message: `CAS GC requires every warm bucket to be readable; ${basename} is not`,
|
|
281
|
+
bucket: filepath,
|
|
282
|
+
cause: e.code,
|
|
283
|
+
});
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
let bucketDb;
|
|
287
|
+
try {
|
|
288
|
+
const Database = require('better-sqlite3');
|
|
289
|
+
bucketDb = new Database(filepath, { readonly: true });
|
|
290
|
+
} catch (e) {
|
|
291
|
+
throw new WBError('WB-010', 'CAS_GC_INCOMPLETE_BUCKET_SET', {
|
|
292
|
+
message: `failed to open warm bucket: ${basename}`,
|
|
293
|
+
bucket: filepath,
|
|
294
|
+
cause: e.message,
|
|
295
|
+
});
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
try {
|
|
299
|
+
for (const row of bucketDb.prepare(
|
|
300
|
+
'SELECT payload_cas_sha FROM events WHERE payload_cas_sha IS NOT NULL'
|
|
301
|
+
).all()) {
|
|
302
|
+
if (row.payload_cas_sha) shas.add(row.payload_cas_sha);
|
|
303
|
+
}
|
|
304
|
+
} finally {
|
|
305
|
+
bucketDb.close();
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
return shas;
|
|
310
|
+
}
|
package/lib/causality.js
ADDED
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cross-process causality propagation — DESIGN-v2.md §8.
|
|
3
|
+
*
|
|
4
|
+
* In-process: callers wrap a body in `withContext({ correlation_id, ... }, fn)`;
|
|
5
|
+
* any `emit()` calls inside the body inherit the context, and successive
|
|
6
|
+
* emits chain via parent_event_id.
|
|
7
|
+
*
|
|
8
|
+
* Cross-process: spawned subprocesses inherit four env vars and the same
|
|
9
|
+
* `withContext` runs at the entry point of the child to re-enter the trace.
|
|
10
|
+
* - WICKED_BUS_CORRELATION_ID
|
|
11
|
+
* - WICKED_BUS_SESSION_ID
|
|
12
|
+
* - WICKED_BUS_PARENT_EVENT_ID
|
|
13
|
+
* - WICKED_BUS_PRODUCER_ID
|
|
14
|
+
*
|
|
15
|
+
* The `currentContext()` accessor returns the active context, drawing from
|
|
16
|
+
* the AsyncLocalStorage frame if one exists, otherwise from the env vars.
|
|
17
|
+
*
|
|
18
|
+
* @module lib/causality
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import { AsyncLocalStorage } from 'node:async_hooks';
|
|
22
|
+
|
|
23
|
+
const ENV_KEYS = Object.freeze({
|
|
24
|
+
correlation_id: 'WICKED_BUS_CORRELATION_ID',
|
|
25
|
+
session_id: 'WICKED_BUS_SESSION_ID',
|
|
26
|
+
parent_event_id: 'WICKED_BUS_PARENT_EVENT_ID',
|
|
27
|
+
producer_id: 'WICKED_BUS_PRODUCER_ID',
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
const als = new AsyncLocalStorage();
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Run `fn` with the given context fields attached. Nested withContext()
|
|
34
|
+
* calls inherit-and-override (last write wins per field).
|
|
35
|
+
*
|
|
36
|
+
* @param {object} ctx
|
|
37
|
+
* @param {string} [ctx.correlation_id]
|
|
38
|
+
* @param {string} [ctx.session_id]
|
|
39
|
+
* @param {number} [ctx.parent_event_id]
|
|
40
|
+
* @param {string} [ctx.producer_id]
|
|
41
|
+
* @param {Function} fn - sync or async; awaited if it returns a Promise
|
|
42
|
+
* @returns {*}
|
|
43
|
+
*/
|
|
44
|
+
export function withContext(ctx, fn) {
|
|
45
|
+
const inherited = currentContext();
|
|
46
|
+
const merged = { ...inherited, ...sanitize(ctx) };
|
|
47
|
+
return als.run(merged, fn);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Read the active context. Returns an object with the four fields (any of
|
|
52
|
+
* which may be `null`/`undefined`). The result is a snapshot copy — mutating
|
|
53
|
+
* it does not affect the running context.
|
|
54
|
+
*/
|
|
55
|
+
export function currentContext() {
|
|
56
|
+
const frame = als.getStore();
|
|
57
|
+
if (frame) return { ...frame };
|
|
58
|
+
|
|
59
|
+
// Fallback: read env vars. Useful at the entry point of a spawned child
|
|
60
|
+
// before any explicit withContext() wrap.
|
|
61
|
+
const fromEnv = {
|
|
62
|
+
correlation_id: process.env[ENV_KEYS.correlation_id] || null,
|
|
63
|
+
session_id: process.env[ENV_KEYS.session_id] || null,
|
|
64
|
+
parent_event_id: parseIntOrNull(process.env[ENV_KEYS.parent_event_id]),
|
|
65
|
+
producer_id: process.env[ENV_KEYS.producer_id] || null,
|
|
66
|
+
};
|
|
67
|
+
// Return undefined-y context only when truly nothing is set — keeps the
|
|
68
|
+
// happy path's "no causality at all" comparison cheap.
|
|
69
|
+
if (
|
|
70
|
+
!fromEnv.correlation_id && !fromEnv.session_id &&
|
|
71
|
+
!fromEnv.parent_event_id && !fromEnv.producer_id
|
|
72
|
+
) return {};
|
|
73
|
+
return fromEnv;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Set the parent_event_id for subsequent emits inside the active context.
|
|
78
|
+
* Used by emit() so the next event in the same withContext() block chains
|
|
79
|
+
* to the most recently emitted event_id.
|
|
80
|
+
*
|
|
81
|
+
* @param {number} eventId
|
|
82
|
+
*/
|
|
83
|
+
export function recordEmit(eventId) {
|
|
84
|
+
const frame = als.getStore();
|
|
85
|
+
if (frame) frame.parent_event_id = eventId;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Build an env object suitable for passing to spawn()/exec() so the child
|
|
90
|
+
* inherits the active causality context. Caller usually does:
|
|
91
|
+
*
|
|
92
|
+
* spawn(cmd, args, { env: { ...process.env, ...causalityEnv() } });
|
|
93
|
+
*
|
|
94
|
+
* If no context is active, returns {} so the caller's spawn is unaffected.
|
|
95
|
+
*/
|
|
96
|
+
export function causalityEnv() {
|
|
97
|
+
const ctx = currentContext();
|
|
98
|
+
const out = {};
|
|
99
|
+
if (ctx.correlation_id) out[ENV_KEYS.correlation_id] = String(ctx.correlation_id);
|
|
100
|
+
if (ctx.session_id) out[ENV_KEYS.session_id] = String(ctx.session_id);
|
|
101
|
+
if (ctx.parent_event_id != null) {
|
|
102
|
+
out[ENV_KEYS.parent_event_id] = String(ctx.parent_event_id);
|
|
103
|
+
}
|
|
104
|
+
if (ctx.producer_id) out[ENV_KEYS.producer_id] = String(ctx.producer_id);
|
|
105
|
+
return out;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** Constant export for callers wanting to manipulate env vars directly. */
|
|
109
|
+
export const CAUSALITY_ENV_KEYS = ENV_KEYS;
|
|
110
|
+
|
|
111
|
+
// ---------------------------------------------------------------------------
|
|
112
|
+
|
|
113
|
+
function sanitize(obj) {
|
|
114
|
+
const out = {};
|
|
115
|
+
if (obj.correlation_id) out.correlation_id = String(obj.correlation_id);
|
|
116
|
+
if (obj.session_id) out.session_id = String(obj.session_id);
|
|
117
|
+
if (obj.parent_event_id != null) {
|
|
118
|
+
const n = Number(obj.parent_event_id);
|
|
119
|
+
if (Number.isInteger(n) && n > 0) out.parent_event_id = n;
|
|
120
|
+
}
|
|
121
|
+
if (obj.producer_id) out.producer_id = String(obj.producer_id);
|
|
122
|
+
return out;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function parseIntOrNull(v) {
|
|
126
|
+
if (v == null) return null;
|
|
127
|
+
const n = parseInt(v, 10);
|
|
128
|
+
return Number.isInteger(n) ? n : null;
|
|
129
|
+
}
|