@gregiteen/ssss-cli 0.9.1 → 0.9.2
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/CHANGELOG.md +28 -0
- package/VERSION +1 -1
- package/conformance/reference-bundle.ucw.json +1 -1
- package/docs/help/concurrency.md +45 -0
- package/docs/help/overview.md +2 -1
- package/package.json +1 -1
- package/scripts/conformance-09.mjs +121 -11
- package/scripts/release.sh +20 -13
- package/src/events.mjs +10 -2
- package/src/file-lock.mjs +112 -0
- package/src/idempotency.mjs +15 -7
- package/src/leases.mjs +27 -14
- package/src/vfs.mjs +33 -7
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,34 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.9.2] - 2026-09-24
|
|
11
|
+
### Fixed
|
|
12
|
+
- The file adapters are now safe to share between processes. Each one checked
|
|
13
|
+
state and then wrote it as two steps, so concurrent processes could both pass
|
|
14
|
+
the check: `FileSystemVfs` could lose an update behind a passed
|
|
15
|
+
compare-and-swap, `FileLeaseStore` could grant the same lease twice (spec §7),
|
|
16
|
+
`FileIdempotencyStore.put` could replace an existing entry, and
|
|
17
|
+
`JsonlEventStore` could append one `event_id` twice. Each check-then-write now
|
|
18
|
+
runs under an exclusive lock file (`O_EXCL`). A lock whose holder process has
|
|
19
|
+
exited is broken immediately; any lock older than 30 s is treated as stale;
|
|
20
|
+
a waiter gives up after 40 s with `SSSS_LOCK_TIMEOUT`. Adapters accept
|
|
21
|
+
`{ lock: { timeoutMs, staleMs } }`. `FileSystemVfs` keeps its locks in a
|
|
22
|
+
reserved `.ssss-locks/` directory that VFS paths cannot address and `list()`
|
|
23
|
+
skips. Locking costs about 0.5 ms per write on APFS.
|
|
24
|
+
- `scripts/release.sh` bumps `package.json` and `package-lock.json` and moves the
|
|
25
|
+
`[Unreleased]` entries into the new version's section instead of inserting a
|
|
26
|
+
stub above `[Unreleased]`.
|
|
27
|
+
|
|
28
|
+
### Added
|
|
29
|
+
- `ssss help concurrency`.
|
|
30
|
+
- Conformance checks that race six processes against each file adapter, plus
|
|
31
|
+
lock checks for dead holders, stale locks, timeouts, symlinked locks, and the
|
|
32
|
+
reserved VFS lock directory.
|
|
33
|
+
- Git tags `v0.8.0` and `v0.9.0` on commits whose package files match the
|
|
34
|
+
published npm tarballs byte-for-byte. Both versions were published from
|
|
35
|
+
uncommitted trees, so no existing commit matched. The push skill now publishes
|
|
36
|
+
from a clean clone of the release tag.
|
|
37
|
+
|
|
10
38
|
## [0.9.1] - 2026-09-24
|
|
11
39
|
### Fixed
|
|
12
40
|
- `JsonlEventStore.append` no longer re-reads and re-parses the whole
|
package/VERSION
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
0.9.
|
|
1
|
+
0.9.2
|
|
@@ -106,7 +106,7 @@
|
|
|
106
106
|
"file_count": 14,
|
|
107
107
|
"provenance": {
|
|
108
108
|
"content_hash": "sha256:7febd673938a3dc04c068e0bf08097a9783c09e612606d2df9a4db8dcb8907f5",
|
|
109
|
-
"exporter": "@gregiteen/ssss-cli@0.9.
|
|
109
|
+
"exporter": "@gregiteen/ssss-cli@0.9.2"
|
|
110
110
|
}
|
|
111
111
|
},
|
|
112
112
|
"files": [
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# File adapter concurrency
|
|
2
|
+
|
|
3
|
+
The reference file adapters can be shared by several processes on one
|
|
4
|
+
filesystem. Each adapter's check-then-write step runs under a lock file, so at
|
|
5
|
+
most one racer wins:
|
|
6
|
+
|
|
7
|
+
| Adapter | Guarantee across processes | Lock file |
|
|
8
|
+
|---------|----------------------------|-----------|
|
|
9
|
+
| `JsonlEventStore.append` | An `event_id` is appended at most once. | `<events>/<workspace>.jsonl.lock` |
|
|
10
|
+
| `FileSystemVfs.writeAtomic` / `remove` | `ifAbsent`, `ifPresent`, and `version` preconditions are compare-and-swap. | `<vault>/.ssss-locks/<sha256(path)>.lock` |
|
|
11
|
+
| `FileLeaseStore.acquire` / `renew` / `release` | At most one active lease per `(workspace_id, target)` (spec §7). | `<leases>/<sha256>.lease.json.lock` |
|
|
12
|
+
| `FileIdempotencyStore.put` | A second `put` for a key fails instead of replacing the first. | `<idempotency>/<sha256>.json.lock` |
|
|
13
|
+
|
|
14
|
+
`.ssss-locks/` is reserved: VFS paths under it are rejected and `list()` skips it.
|
|
15
|
+
|
|
16
|
+
## Crashed holders
|
|
17
|
+
|
|
18
|
+
A lock records its holder's pid and host. A waiter breaks the lock when the
|
|
19
|
+
holder is on the same host and no longer running, or when the lock is older than
|
|
20
|
+
`staleMs` (default 30 s). A waiter gives up after `timeoutMs` (default 40 s) with
|
|
21
|
+
`SSSS_LOCK_TIMEOUT`. Both are adjustable per adapter:
|
|
22
|
+
|
|
23
|
+
```js
|
|
24
|
+
new JsonlEventStore(root, { lock: { timeoutMs: 10_000, staleMs: 15_000 } });
|
|
25
|
+
new FileSystemVfs(root, { lock: { timeoutMs: 10_000 } });
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
Symlinked lock files are refused.
|
|
29
|
+
|
|
30
|
+
## Limits
|
|
31
|
+
|
|
32
|
+
- Locks are advisory: only writers using these adapters take them. Writers from
|
|
33
|
+
`@gregiteen/ssss-cli` 0.9.1 and earlier do not.
|
|
34
|
+
- The kernel's idempotency check for `event` envelopes is read before commit and
|
|
35
|
+
written after, so two processes sending the same idempotency key at the same
|
|
36
|
+
moment can each append an event. `operation`, `patch`, and `delete` are
|
|
37
|
+
protected by the VFS compare-and-swap.
|
|
38
|
+
- Network filesystems must support exclusive create (`O_EXCL`).
|
|
39
|
+
- Dead-holder detection assumes processes that share the filesystem have
|
|
40
|
+
distinct hostnames (containers with the same hostname and separate pid
|
|
41
|
+
namespaces would look dead to each other). Stale detection compares the lock's
|
|
42
|
+
mtime with the local clock, so hosts' clocks must agree to well within
|
|
43
|
+
`staleMs`.
|
|
44
|
+
|
|
45
|
+
See also: `ssss help leases`, `ssss help conformance`.
|
package/docs/help/overview.md
CHANGED
|
@@ -33,7 +33,8 @@ vault ──export──▶ .ucw bundle ──provision──▶ envelope plan
|
|
|
33
33
|
|
|
34
34
|
Run `ssss help <topic>` for any of:
|
|
35
35
|
`runtime`, `semantic`, `portability`, `bundle`, `provisioning`,
|
|
36
|
-
`export`, `provision`, `import`, `conformance`, `autolink
|
|
36
|
+
`export`, `provision`, `import`, `conformance`, `autolink`, `leases`,
|
|
37
|
+
`concurrency`.
|
|
37
38
|
|
|
38
39
|
Run `ssss <command> --help` for: `export`, `validate`, `inspect`, `provision`,
|
|
39
40
|
`import`, `semantic`, `primitive`, `registry`, `adapter`, `migrate`,
|
package/package.json
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import fs from 'node:fs';
|
|
2
|
-
import { execFileSync } from 'node:child_process';
|
|
2
|
+
import { execFileSync, spawn, spawnSync } from 'node:child_process';
|
|
3
3
|
import os from 'node:os';
|
|
4
4
|
import path from 'node:path';
|
|
5
5
|
import { definePrimitive, definitionsToExtensionRegistry } from '../src/primitive.mjs';
|
|
@@ -25,7 +25,7 @@ import { createKernel } from '../src/kernel.mjs';
|
|
|
25
25
|
import { UiRegistry, actionToEnvelope, createDeterministicUi, planUi, validateUiManifest } from '../src/ui.mjs';
|
|
26
26
|
import { createCommandHandler, createDomainCommand } from '../src/http.mjs';
|
|
27
27
|
import { detectDirectWrites } from '../src/guard.mjs';
|
|
28
|
-
import { MemoryLeaseStore, FileLeaseStore, runLeaseContract } from '../src/leases.mjs';
|
|
28
|
+
import { MemoryLeaseStore, FileLeaseStore, leaseStatePath, runLeaseContract } from '../src/leases.mjs';
|
|
29
29
|
import { MemoryIdempotencyStore, FileIdempotencyStore, runIdempotencyContract } from '../src/idempotency.mjs';
|
|
30
30
|
import { createCapabilityAuthorizer } from '../src/authorization.mjs';
|
|
31
31
|
import { validateVerifiedPrincipal } from '../src/authorization.mjs';
|
|
@@ -40,25 +40,133 @@ async function replayed(store, options) {
|
|
|
40
40
|
return items;
|
|
41
41
|
}
|
|
42
42
|
|
|
43
|
-
// Counts bytes
|
|
44
|
-
async function
|
|
45
|
-
const { readSync, readFileSync }
|
|
43
|
+
// Counts bytes read from `file` during `fn`, to prove appends do not re-read the log.
|
|
44
|
+
async function bytesReadFrom(file, fn) {
|
|
45
|
+
const original = { openSync: fs.openSync, closeSync: fs.closeSync, readSync: fs.readSync, readFileSync: fs.readFileSync };
|
|
46
|
+
const fds = new Set();
|
|
46
47
|
let bytes = 0;
|
|
47
|
-
fs.
|
|
48
|
-
fs.
|
|
49
|
-
|
|
48
|
+
fs.openSync = (target, ...rest) => { const fd = original.openSync(target, ...rest); if (target === file) fds.add(fd); return fd; };
|
|
49
|
+
fs.closeSync = (fd) => { fds.delete(fd); return original.closeSync(fd); };
|
|
50
|
+
fs.readSync = (fd, ...rest) => { const read = original.readSync(fd, ...rest); if (fds.has(fd)) bytes += read; return read; };
|
|
51
|
+
fs.readFileSync = (target, ...rest) => {
|
|
52
|
+
const out = original.readFileSync(target, ...rest);
|
|
53
|
+
if (target === file || fds.has(target)) bytes += Buffer.byteLength(out);
|
|
54
|
+
return out;
|
|
55
|
+
};
|
|
56
|
+
try { await fn(); } finally { Object.assign(fs, original); }
|
|
50
57
|
return bytes;
|
|
51
58
|
}
|
|
52
59
|
|
|
60
|
+
// Runs `body` in `count` child processes released on one shared deadline, so
|
|
61
|
+
// their check-then-write sections overlap. Each resolves to { ok, racer, error }.
|
|
62
|
+
function raceProcesses(count, imports, body) {
|
|
63
|
+
const startAt = Date.now() + 1000;
|
|
64
|
+
const code = [
|
|
65
|
+
...Object.entries(imports).map(([name, file]) => `import { ${name} } from ${JSON.stringify(new URL(file, import.meta.url).href)};`),
|
|
66
|
+
'const racer = Number(process.env.SSSS_RACER);',
|
|
67
|
+
`while (Date.now() < ${startAt});`,
|
|
68
|
+
`try { ${body}; console.log(JSON.stringify({ ok: true, racer })); }`,
|
|
69
|
+
'catch (error) { console.log(JSON.stringify({ ok: false, racer, error: error.message })); }',
|
|
70
|
+
].join('\n');
|
|
71
|
+
return Promise.all(Array.from({ length: count }, (_, racer) => new Promise((resolve) => {
|
|
72
|
+
const child = spawn(process.execPath, ['--input-type=module', '-e', code], { env: { ...process.env, SSSS_RACER: String(racer) } });
|
|
73
|
+
let out = '';
|
|
74
|
+
let err = '';
|
|
75
|
+
child.stdout.on('data', (chunk) => { out += chunk; });
|
|
76
|
+
child.stderr.on('data', (chunk) => { err += chunk; });
|
|
77
|
+
child.on('close', () => {
|
|
78
|
+
try { resolve(JSON.parse(out.trim().split('\n').pop())); }
|
|
79
|
+
catch { resolve({ ok: false, racer, error: `no result: ${err || out}` }); }
|
|
80
|
+
});
|
|
81
|
+
})));
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const winners = (results) => results.filter((result) => result.ok);
|
|
85
|
+
const describe = (results) => JSON.stringify(results.map(({ ok, racer, error }) => (ok ? racer : `${racer}:${error}`)));
|
|
86
|
+
|
|
87
|
+
async function checkFileAdapterRaces(root, principal, check) {
|
|
88
|
+
const racers = 6;
|
|
89
|
+
const eventsRoot = path.join(root, 'events');
|
|
90
|
+
const event = createCanonicalEvent({ workspace_id: 'acme', action: 'event', subject: 'events/race.md', operation_id: 'race', idempotency_key: 'race', principal });
|
|
91
|
+
const appends = await raceProcesses(racers, { JsonlEventStore: '../src/events.mjs' },
|
|
92
|
+
`await new JsonlEventStore(${JSON.stringify(eventsRoot)}).append(${JSON.stringify(event)})`);
|
|
93
|
+
const lines = fs.readFileSync(path.join(eventsRoot, 'acme.jsonl'), 'utf8').split('\n').filter(Boolean);
|
|
94
|
+
check('jsonl event store lets one of several processes append the same event_id', winners(appends).length === 1 && lines.length === 1, describe(appends));
|
|
95
|
+
|
|
96
|
+
const vfsRoot = path.join(root, 'vfs');
|
|
97
|
+
const creates = await raceProcesses(racers, { FileSystemVfs: '../src/vfs.mjs' },
|
|
98
|
+
`await new FileSystemVfs(${JSON.stringify(vfsRoot)}).writeAtomic('race/create.md', 'racer-' + racer, { ifAbsent: true })`);
|
|
99
|
+
const created = fs.readFileSync(path.join(vfsRoot, 'race', 'create.md'), 'utf8');
|
|
100
|
+
check('filesystem VFS lets one of several processes create the same path',
|
|
101
|
+
winners(creates).length === 1 && created === `racer-${winners(creates)[0]?.racer}`, describe(creates));
|
|
102
|
+
const base = await new FileSystemVfs(vfsRoot).writeAtomic('race/cas.md', 'base', { ifAbsent: true });
|
|
103
|
+
const swaps = await raceProcesses(racers, { FileSystemVfs: '../src/vfs.mjs' },
|
|
104
|
+
`await new FileSystemVfs(${JSON.stringify(vfsRoot)}).writeAtomic('race/cas.md', 'racer-' + racer, { version: ${JSON.stringify(base.version)} })`);
|
|
105
|
+
const swapped = fs.readFileSync(path.join(vfsRoot, 'race', 'cas.md'), 'utf8');
|
|
106
|
+
check('filesystem VFS lets one of several processes compare-and-swap the same version',
|
|
107
|
+
winners(swaps).length === 1 && swapped === `racer-${winners(swaps)[0]?.racer}`, describe(swaps));
|
|
108
|
+
|
|
109
|
+
const leaseRoot = path.join(root, 'leases');
|
|
110
|
+
const leases = await raceProcesses(racers, { FileLeaseStore: '../src/leases.mjs' },
|
|
111
|
+
`await new FileLeaseStore(${JSON.stringify(leaseRoot)}).acquire({ workspace_id: 'acme', target: 'race.md', principal_id: 'p' + racer, operation_id: 'o' + racer }, 60000)`);
|
|
112
|
+
const held = JSON.parse(fs.readFileSync(leaseStatePath(fs.realpathSync(leaseRoot), 'acme', 'race.md'), 'utf8'));
|
|
113
|
+
check('filesystem lease store grants one of several processes the same lease',
|
|
114
|
+
winners(leases).length === 1 && held.principal_id === `p${winners(leases)[0]?.racer}`, describe(leases));
|
|
115
|
+
|
|
116
|
+
const idempotencyRoot = path.join(root, 'idempotency');
|
|
117
|
+
const puts = await raceProcesses(racers, { FileIdempotencyStore: '../src/idempotency.mjs' },
|
|
118
|
+
`await new FileIdempotencyStore(${JSON.stringify(idempotencyRoot)}).put('acme', 'race', { request_hash: 'racer-' + racer })`);
|
|
119
|
+
const stored = await new FileIdempotencyStore(idempotencyRoot).get('acme', 'race');
|
|
120
|
+
check('filesystem idempotency store accepts one of several processes putting the same key',
|
|
121
|
+
winners(puts).length === 1 && stored?.request_hash === `racer-${winners(puts)[0]?.racer}`, describe(puts));
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
async function checkFileLocks(root, principal, check) {
|
|
125
|
+
const make = (id) => createCanonicalEvent({ workspace_id: 'acme', action: 'event', subject: 'events/lock.md', operation_id: id, idempotency_key: id, principal });
|
|
126
|
+
const lock = path.join(root, 'acme.jsonl.lock');
|
|
127
|
+
const holder = (fields) => fs.writeFileSync(lock, JSON.stringify({ token: 'other', host: os.hostname(), ...fields }));
|
|
128
|
+
const store = new JsonlEventStore(root, { lock: { timeoutMs: 500, staleMs: 60_000 } });
|
|
129
|
+
|
|
130
|
+
holder({ pid: process.pid });
|
|
131
|
+
const started = Date.now();
|
|
132
|
+
check('file locks make a writer wait for a live holder and then time out',
|
|
133
|
+
await rejects(store.append(make('lock-live')), /Timed out waiting for lock/) && Date.now() - started >= 450 && fs.existsSync(lock));
|
|
134
|
+
const exited = spawnSync(process.execPath, ['-e', '']).pid;
|
|
135
|
+
holder({ pid: exited });
|
|
136
|
+
await store.append(make('lock-dead'));
|
|
137
|
+
check('file locks break a lock whose holder process has exited', !fs.existsSync(lock));
|
|
138
|
+
holder({ pid: process.pid, host: 'another-host' });
|
|
139
|
+
const past = new Date(Date.now() - 120_000);
|
|
140
|
+
fs.utimesSync(lock, past, past);
|
|
141
|
+
await new JsonlEventStore(root, { lock: { timeoutMs: 500, staleMs: 30_000 } }).append(make('lock-stale'));
|
|
142
|
+
check('file locks break a lock older than the stale threshold', !fs.existsSync(lock));
|
|
143
|
+
const decoy = path.join(root, 'decoy.txt');
|
|
144
|
+
fs.writeFileSync(decoy, 'untouched');
|
|
145
|
+
fs.symlinkSync(decoy, lock);
|
|
146
|
+
check('file locks refuse a symlinked lock file',
|
|
147
|
+
await rejects(store.append(make('lock-symlink')), /not a regular file/) && fs.readFileSync(decoy, 'utf8') === 'untouched');
|
|
148
|
+
fs.rmSync(lock);
|
|
149
|
+
|
|
150
|
+
const vfs = new FileSystemVfs(path.join(root, 'vfs'));
|
|
151
|
+
await vfs.writeAtomic('docs/one.md', 'one', { ifAbsent: true });
|
|
152
|
+
fs.writeFileSync(path.join(vfs.root, '.ssss-locks', 'leftover.lock'), '{}');
|
|
153
|
+
const listed = [];
|
|
154
|
+
for await (const entry of vfs.list()) listed.push(entry.path);
|
|
155
|
+
check('filesystem VFS reserves its lock directory',
|
|
156
|
+
await rejects(vfs.writeAtomic('.ssss-locks/forged.lock', 'x'), /reserved/)
|
|
157
|
+
&& await rejects(vfs.writeAtomic('.SSSS-Locks/forged.lock', 'x'), /reserved/)
|
|
158
|
+
&& JSON.stringify(listed) === JSON.stringify(['docs/one.md']));
|
|
159
|
+
}
|
|
160
|
+
|
|
53
161
|
async function checkJsonlEventStoreIndex(root, principal, check) {
|
|
54
162
|
let seq = 0;
|
|
55
163
|
const make = (workspace = 'acme') => createCanonicalEvent({
|
|
56
164
|
workspace_id: workspace, action: 'event', subject: 'events/indexed.md',
|
|
57
165
|
operation_id: `indexed-${++seq}`, idempotency_key: `indexed-${seq}`, principal,
|
|
58
166
|
});
|
|
59
|
-
const log = path.join(root, 'acme.jsonl');
|
|
60
167
|
const first = new JsonlEventStore(root);
|
|
61
168
|
const second = new JsonlEventStore(root);
|
|
169
|
+
const log = path.join(first.root, 'acme.jsonl');
|
|
62
170
|
const appended = [];
|
|
63
171
|
const add = async (store, event) => { await store.append(event); appended.push(event.event_id); return event; };
|
|
64
172
|
|
|
@@ -78,10 +186,10 @@ async function checkJsonlEventStoreIndex(root, principal, check) {
|
|
|
78
186
|
|
|
79
187
|
const seeded = fs.statSync(log).size;
|
|
80
188
|
const fresh = new JsonlEventStore(root);
|
|
81
|
-
const batchBytes = await
|
|
189
|
+
const batchBytes = await bytesReadFrom(log, async () => { for (let i = 0; i < 200; i++) await add(fresh, make()); });
|
|
82
190
|
check('jsonl event store reads the log once for many appends from one instance', batchBytes === seeded, `read ${batchBytes} bytes; log was ${seeded}`);
|
|
83
191
|
await add(second, make());
|
|
84
|
-
const revalidateBytes = await
|
|
192
|
+
const revalidateBytes = await bytesReadFrom(log, () => add(fresh, make()));
|
|
85
193
|
check('jsonl event store rebuilds its index after another writer appends', revalidateBytes > seeded);
|
|
86
194
|
|
|
87
195
|
const other = [];
|
|
@@ -277,6 +385,8 @@ export async function runKernel09Conformance() {
|
|
|
277
385
|
check(`${name} event store is append-only and replayable`, duplicate && replayed === 1);
|
|
278
386
|
}
|
|
279
387
|
await checkJsonlEventStoreIndex(path.join(temp, 'events-index'), principal, check);
|
|
388
|
+
await checkFileAdapterRaces(path.join(temp, 'races'), principal, check);
|
|
389
|
+
await checkFileLocks(path.join(temp, 'locks'), principal, check);
|
|
280
390
|
const denied = await kernel.execute({ type: 'patch', workspace_id: 'acme', idempotency_key: 'denied', path: 'bookings/one.md', patches: { status: 'pending' } }, {
|
|
281
391
|
principal: { ...principal, id: 'outsider', workspaceIds: [], capabilities: ['*:*'] },
|
|
282
392
|
});
|
package/scripts/release.sh
CHANGED
|
@@ -2,7 +2,9 @@
|
|
|
2
2
|
# ============================================================
|
|
3
3
|
# SSSS — Version Release Script
|
|
4
4
|
#
|
|
5
|
-
# Bumps
|
|
5
|
+
# Bumps VERSION, package.json and package-lock.json, and turns the
|
|
6
|
+
# CHANGELOG's [Unreleased] entries into a section for the new version
|
|
7
|
+
# (a stub when [Unreleased] is empty).
|
|
6
8
|
# ============================================================
|
|
7
9
|
|
|
8
10
|
set -euo pipefail
|
|
@@ -20,16 +22,21 @@ if [[ ! "$NEW_VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?$ ]]; then
|
|
|
20
22
|
exit 1
|
|
21
23
|
fi
|
|
22
24
|
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
# Quick sed to insert new version at the top of the changelog
|
|
26
|
-
sed -i.bak "/^## \[.*\]/i \\
|
|
27
|
-
## [$NEW_VERSION] - $(date +%Y-%m-%d)\\
|
|
28
|
-
### Added\\
|
|
29
|
-
- \\
|
|
30
|
-
\\
|
|
31
|
-
" CHANGELOG.md
|
|
25
|
+
cd "$(dirname "$0")/.."
|
|
32
26
|
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
27
|
+
echo "$NEW_VERSION" > VERSION
|
|
28
|
+
npm version "$NEW_VERSION" --no-git-tag-version --allow-same-version > /dev/null
|
|
29
|
+
|
|
30
|
+
node --input-type=module -e '
|
|
31
|
+
import fs from "node:fs";
|
|
32
|
+
const [version, date] = process.argv.slice(1);
|
|
33
|
+
const text = fs.readFileSync("CHANGELOG.md", "utf8");
|
|
34
|
+
if (text.includes(`## [${version}]`)) process.exit(0);
|
|
35
|
+
const match = text.match(/^## \[Unreleased\]\n([\s\S]*?)(?=^## \[)/m);
|
|
36
|
+
if (!match) { console.error("CHANGELOG.md has no ## [Unreleased] section followed by a release."); process.exit(1); }
|
|
37
|
+
const entries = match[1].trim() || "### Added\n- ";
|
|
38
|
+
const section = `## [Unreleased]\n\n## [${version}] - ${date}\n${entries}\n\n`;
|
|
39
|
+
fs.writeFileSync("CHANGELOG.md", text.replace(match[0], section));
|
|
40
|
+
' "$NEW_VERSION" "$(date +%Y-%m-%d)"
|
|
41
|
+
|
|
42
|
+
echo "✅ Bumped version to $NEW_VERSION (VERSION, package.json, package-lock.json, CHANGELOG.md)"
|
package/src/events.mjs
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
import fs from 'node:fs';
|
|
3
3
|
import path from 'node:path';
|
|
4
4
|
import crypto from 'node:crypto';
|
|
5
|
+
import { withFileLock } from './file-lock.mjs';
|
|
5
6
|
|
|
6
7
|
export function createCanonicalEvent(input, options = {}) {
|
|
7
8
|
const required = ['workspace_id', 'action', 'subject', 'operation_id', 'idempotency_key'];
|
|
@@ -90,10 +91,13 @@ export class JsonlEventStore {
|
|
|
90
91
|
// Per-log index of event_ids, built on the first append to that log. Each
|
|
91
92
|
// index records the stat of the bytes it covers; any change (another store
|
|
92
93
|
// instance or process appended, or the file was replaced) rebuilds it.
|
|
94
|
+
// Appenders serialize on `<log>.lock` so the duplicate check and the append
|
|
95
|
+
// are atomic across processes.
|
|
93
96
|
#indexes = new Map();
|
|
94
|
-
constructor(root) {
|
|
97
|
+
constructor(root, options = {}) {
|
|
95
98
|
fs.mkdirSync(root, { recursive: true });
|
|
96
99
|
this.root = fs.realpathSync(root);
|
|
100
|
+
this.lockOptions = options.lock || {};
|
|
97
101
|
}
|
|
98
102
|
#file(workspaceId) {
|
|
99
103
|
if (!/^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/.test(workspaceId || '')) throw new Error('Unsafe event workspace_id.');
|
|
@@ -116,6 +120,9 @@ export class JsonlEventStore {
|
|
|
116
120
|
}
|
|
117
121
|
async append(event) {
|
|
118
122
|
const file = this.#file(event.workspace_id);
|
|
123
|
+
return withFileLock(`${file}.lock`, () => this.#appendLocked(file, event), this.lockOptions);
|
|
124
|
+
}
|
|
125
|
+
#appendLocked(file, event) {
|
|
119
126
|
const index = this.#index(file);
|
|
120
127
|
if (index.ids.has(event.event_id)) throw new Error(`Duplicate event_id '${event.event_id}'.`);
|
|
121
128
|
const line = `${JSON.stringify(event)}\n`;
|
|
@@ -127,7 +134,8 @@ export class JsonlEventStore {
|
|
|
127
134
|
after = fs.fstatSync(fd);
|
|
128
135
|
} finally { fs.closeSync(fd); }
|
|
129
136
|
// Keep the index only when the log grew by exactly this line on the same
|
|
130
|
-
// file; otherwise
|
|
137
|
+
// file; otherwise a writer that skips the lock interleaved, and the next
|
|
138
|
+
// append rebuilds.
|
|
131
139
|
const before = index.stat;
|
|
132
140
|
const expectedSize = (before?.size || 0) + Buffer.byteLength(line);
|
|
133
141
|
if (after.size === expectedSize && (!before || (before.dev === after.dev && before.ino === after.ino))) {
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cross-process exclusive lock files for the reference file adapters.
|
|
3
|
+
*
|
|
4
|
+
* A lock is a file created with O_EXCL and removed by its holder. A lock left
|
|
5
|
+
* behind by a crashed holder is broken once the holder is known to be dead
|
|
6
|
+
* (same host, process gone) or the lock is older than `staleMs`.
|
|
7
|
+
*/
|
|
8
|
+
import fs from 'node:fs';
|
|
9
|
+
import os from 'node:os';
|
|
10
|
+
import path from 'node:path';
|
|
11
|
+
import crypto from 'node:crypto';
|
|
12
|
+
|
|
13
|
+
const NOFOLLOW = fs.constants.O_NOFOLLOW || 0;
|
|
14
|
+
const CREATE_EXCLUSIVE = fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_EXCL | NOFOLLOW;
|
|
15
|
+
const HOST = os.hostname();
|
|
16
|
+
|
|
17
|
+
export const FILE_LOCK_DEFAULTS = Object.freeze({ timeoutMs: 40_000, staleMs: 30_000 });
|
|
18
|
+
|
|
19
|
+
export class FileLockTimeoutError extends Error {
|
|
20
|
+
constructor(lockPath) {
|
|
21
|
+
super(`Timed out waiting for lock '${path.basename(lockPath)}'.`);
|
|
22
|
+
this.name = 'FileLockTimeoutError';
|
|
23
|
+
this.code = 'SSSS_LOCK_TIMEOUT';
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function notRegular(lockPath) {
|
|
28
|
+
return new Error(`Lock '${path.basename(lockPath)}' is not a regular file.`);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function readHolder(lockPath) {
|
|
32
|
+
try {
|
|
33
|
+
const fd = fs.openSync(lockPath, fs.constants.O_RDONLY | NOFOLLOW);
|
|
34
|
+
try { return JSON.parse(fs.readFileSync(fd, 'utf8')); } finally { fs.closeSync(fd); }
|
|
35
|
+
} catch { return null; }
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function holderIsDead(holder) {
|
|
39
|
+
if (holder?.host !== HOST || !Number.isInteger(holder.pid) || holder.pid === process.pid) return false;
|
|
40
|
+
try { process.kill(holder.pid, 0); return false; }
|
|
41
|
+
catch (error) { return error.code === 'ESRCH'; }
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const sameFile = (a, b) => a.dev === b.dev && a.ino === b.ino && a.mtimeMs === b.mtimeMs;
|
|
45
|
+
|
|
46
|
+
/** Create the lock and return its stat (proof of ownership), or null when it is held. */
|
|
47
|
+
function tryAcquire(lockPath, createDir) {
|
|
48
|
+
let fd;
|
|
49
|
+
try { fd = fs.openSync(lockPath, CREATE_EXCLUSIVE, 0o600); }
|
|
50
|
+
catch (error) {
|
|
51
|
+
if (error.code === 'EEXIST') return null;
|
|
52
|
+
if (error.code === 'ELOOP') throw notRegular(lockPath);
|
|
53
|
+
if (error.code === 'ENOENT' && createDir) {
|
|
54
|
+
fs.mkdirSync(path.dirname(lockPath), { recursive: true, mode: 0o700 });
|
|
55
|
+
return tryAcquire(lockPath, false);
|
|
56
|
+
}
|
|
57
|
+
throw error;
|
|
58
|
+
}
|
|
59
|
+
try {
|
|
60
|
+
fs.writeFileSync(fd, JSON.stringify({ pid: process.pid, host: HOST, acquired_at: new Date().toISOString() }));
|
|
61
|
+
return fs.fstatSync(fd);
|
|
62
|
+
} catch (error) {
|
|
63
|
+
fs.rmSync(lockPath, { force: true });
|
|
64
|
+
throw error;
|
|
65
|
+
} finally { fs.closeSync(fd); }
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function breakIfStale(lockPath, staleMs) {
|
|
69
|
+
let stat;
|
|
70
|
+
try { stat = fs.lstatSync(lockPath); }
|
|
71
|
+
catch (error) { if (error.code === 'ENOENT') return; throw error; }
|
|
72
|
+
if (!stat.isFile()) throw notRegular(lockPath);
|
|
73
|
+
if (Date.now() - stat.mtimeMs < staleMs && !holderIsDead(readHolder(lockPath))) return;
|
|
74
|
+
// Move the lock aside so only one breaker wins. If a live holder replaced it
|
|
75
|
+
// after the stat above, the moved file is a different inode: put it back.
|
|
76
|
+
const aside = `${lockPath}.${crypto.randomUUID()}.stale`;
|
|
77
|
+
try { fs.renameSync(lockPath, aside); }
|
|
78
|
+
catch (error) { if (error.code === 'ENOENT') return; throw error; }
|
|
79
|
+
const moved = fs.lstatSync(aside);
|
|
80
|
+
if (moved.ino !== stat.ino || moved.dev !== stat.dev) {
|
|
81
|
+
try { fs.linkSync(aside, lockPath); } catch {}
|
|
82
|
+
}
|
|
83
|
+
fs.rmSync(aside, { force: true });
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// Remove the lock only if it is still the file we created; a lock broken as
|
|
87
|
+
// stale and re-taken by another holder is a different file.
|
|
88
|
+
function release(lockPath, owned) {
|
|
89
|
+
try {
|
|
90
|
+
if (sameFile(fs.lstatSync(lockPath), owned)) fs.unlinkSync(lockPath);
|
|
91
|
+
} catch (error) { if (error.code !== 'ENOENT') throw error; }
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Run `fn` while holding the lock file at `lockPath`. With `createDir`, the
|
|
96
|
+
* lock's directory is created (mode 0700) when it is missing.
|
|
97
|
+
*/
|
|
98
|
+
export async function withFileLock(lockPath, fn, options = {}) {
|
|
99
|
+
const timeoutMs = options.timeoutMs ?? FILE_LOCK_DEFAULTS.timeoutMs;
|
|
100
|
+
const staleMs = options.staleMs ?? FILE_LOCK_DEFAULTS.staleMs;
|
|
101
|
+
const deadline = Date.now() + timeoutMs;
|
|
102
|
+
let owned = tryAcquire(lockPath, options.createDir);
|
|
103
|
+
for (let delay = 1; !owned; delay = Math.min(delay * 2, 50)) {
|
|
104
|
+
breakIfStale(lockPath, staleMs);
|
|
105
|
+
owned = tryAcquire(lockPath, options.createDir);
|
|
106
|
+
if (owned) break;
|
|
107
|
+
if (Date.now() >= deadline) throw new FileLockTimeoutError(lockPath);
|
|
108
|
+
await new Promise((resolve) => setTimeout(resolve, delay * (1 + Math.random())));
|
|
109
|
+
}
|
|
110
|
+
try { return await fn(); }
|
|
111
|
+
finally { release(lockPath, owned); }
|
|
112
|
+
}
|
package/src/idempotency.mjs
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
import fs from 'node:fs';
|
|
3
3
|
import path from 'node:path';
|
|
4
4
|
import crypto from 'node:crypto';
|
|
5
|
+
import { withFileLock } from './file-lock.mjs';
|
|
5
6
|
|
|
6
7
|
function key(workspaceId, idempotencyKey) { return JSON.stringify([workspaceId, idempotencyKey]); }
|
|
7
8
|
|
|
@@ -16,7 +17,11 @@ export class MemoryIdempotencyStore {
|
|
|
16
17
|
}
|
|
17
18
|
|
|
18
19
|
export class FileIdempotencyStore {
|
|
19
|
-
constructor(root
|
|
20
|
+
constructor(root, options = {}) {
|
|
21
|
+
fs.mkdirSync(root, { recursive: true });
|
|
22
|
+
this.root = fs.realpathSync(root);
|
|
23
|
+
this.lockOptions = options.lock || {};
|
|
24
|
+
}
|
|
20
25
|
#file(workspaceId, idempotencyKey) {
|
|
21
26
|
const digest = crypto.createHash('sha256').update(key(workspaceId, idempotencyKey)).digest('hex');
|
|
22
27
|
return path.join(this.root, `${digest}.json`);
|
|
@@ -31,12 +36,15 @@ export class FileIdempotencyStore {
|
|
|
31
36
|
}
|
|
32
37
|
async put(workspaceId, idempotencyKey, value) {
|
|
33
38
|
const file = this.#file(workspaceId, idempotencyKey);
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
39
|
+
// Serialize writers of one key so a second put fails instead of replacing the first.
|
|
40
|
+
return withFileLock(`${file}.lock`, () => {
|
|
41
|
+
if (fs.existsSync(file)) throw new Error('Idempotency entry already exists.');
|
|
42
|
+
const temp = `${file}.${crypto.randomUUID()}.tmp`;
|
|
43
|
+
try {
|
|
44
|
+
fs.writeFileSync(temp, `${JSON.stringify(value)}\n`, { flag: 'wx', mode: 0o600 });
|
|
45
|
+
fs.renameSync(temp, file);
|
|
46
|
+
} finally { fs.rmSync(temp, { force: true }); }
|
|
47
|
+
}, this.lockOptions);
|
|
40
48
|
}
|
|
41
49
|
}
|
|
42
50
|
|
package/src/leases.mjs
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
import fs from 'node:fs';
|
|
3
3
|
import path from 'node:path';
|
|
4
4
|
import crypto from 'node:crypto';
|
|
5
|
+
import { withFileLock } from './file-lock.mjs';
|
|
5
6
|
|
|
6
7
|
function validateLeaseInput(value) {
|
|
7
8
|
for (const field of ['workspace_id', 'target', 'principal_id', 'operation_id']) {
|
|
@@ -56,10 +57,16 @@ export class FileLeaseStore {
|
|
|
56
57
|
fs.mkdirSync(root, { recursive: true });
|
|
57
58
|
this.root = fs.realpathSync(root);
|
|
58
59
|
this.clock = options.clock || (() => Date.now());
|
|
60
|
+
this.lockOptions = options.lock || {};
|
|
59
61
|
}
|
|
60
62
|
#file(workspaceId, target) {
|
|
61
63
|
return leaseStatePath(this.root, workspaceId, target);
|
|
62
64
|
}
|
|
65
|
+
// Mutations of one lease serialize on a lock so "at most one active lease"
|
|
66
|
+
// holds across processes.
|
|
67
|
+
#locked(input, fn) {
|
|
68
|
+
return withFileLock(`${this.#file(input.workspace_id, input.target)}.lock`, fn, this.lockOptions);
|
|
69
|
+
}
|
|
63
70
|
#load(workspaceId, target) {
|
|
64
71
|
const file = this.#file(workspaceId, target);
|
|
65
72
|
if (!fs.existsSync(file)) return null;
|
|
@@ -78,11 +85,13 @@ export class FileLeaseStore {
|
|
|
78
85
|
}
|
|
79
86
|
async acquire(input, ttlMs = 30_000) {
|
|
80
87
|
validateLeaseInput(input);
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
88
|
+
return this.#locked(input, () => {
|
|
89
|
+
const existing = this.#load(input.workspace_id, input.target);
|
|
90
|
+
if (existing && existing.expires_at > this.clock()) throw new Error('Lease conflict.');
|
|
91
|
+
const lease = { ...input, lease_id: input.lease_id || crypto.randomUUID(), issued_at: this.clock(), expires_at: this.clock() + ttlMs };
|
|
92
|
+
this.#write(lease);
|
|
93
|
+
return { ...lease };
|
|
94
|
+
});
|
|
86
95
|
}
|
|
87
96
|
async verify(input) {
|
|
88
97
|
const lease = this.#load(input.workspace_id, input.target);
|
|
@@ -94,17 +103,21 @@ export class FileLeaseStore {
|
|
|
94
103
|
return { valid: true, lease };
|
|
95
104
|
}
|
|
96
105
|
async renew(input, ttlMs = 30_000) {
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
106
|
+
return this.#locked(input, async () => {
|
|
107
|
+
const verified = await this.verify(input);
|
|
108
|
+
if (!verified.valid) throw new Error(verified.reason);
|
|
109
|
+
verified.lease.expires_at = this.clock() + ttlMs;
|
|
110
|
+
this.#write(verified.lease);
|
|
111
|
+
return verified.lease;
|
|
112
|
+
});
|
|
102
113
|
}
|
|
103
114
|
async release(input) {
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
115
|
+
return this.#locked(input, async () => {
|
|
116
|
+
const verified = await this.verify(input);
|
|
117
|
+
if (!verified.valid) throw new Error(verified.reason);
|
|
118
|
+
fs.rmSync(this.#file(input.workspace_id, input.target), { force: true });
|
|
119
|
+
return { released: true, lease_id: input.lease_id };
|
|
120
|
+
});
|
|
108
121
|
}
|
|
109
122
|
}
|
|
110
123
|
|
package/src/vfs.mjs
CHANGED
|
@@ -3,6 +3,10 @@ import fs from 'node:fs';
|
|
|
3
3
|
import path from 'node:path';
|
|
4
4
|
import crypto from 'node:crypto';
|
|
5
5
|
import { isSafeDocumentPath } from './registry.mjs';
|
|
6
|
+
import { withFileLock } from './file-lock.mjs';
|
|
7
|
+
|
|
8
|
+
/** Reserved FileSystemVfs directory for per-path write locks; never a document path. */
|
|
9
|
+
export const VFS_LOCK_DIR = '.ssss-locks';
|
|
6
10
|
|
|
7
11
|
export class VfsConflictError extends Error {
|
|
8
12
|
constructor(message) { super(message); this.name = 'VfsConflictError'; this.code = 'SSSS_VFS_CONFLICT'; }
|
|
@@ -77,14 +81,28 @@ export class MemoryVfs {
|
|
|
77
81
|
}
|
|
78
82
|
|
|
79
83
|
export class FileSystemVfs {
|
|
80
|
-
constructor(root) {
|
|
84
|
+
constructor(root, options = {}) {
|
|
81
85
|
if (!root) throw new Error('FileSystemVfs requires a root directory.');
|
|
82
86
|
fs.mkdirSync(root, { recursive: true });
|
|
83
87
|
this.root = fs.realpathSync(root);
|
|
88
|
+
this.lockOptions = options.lock || {};
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// Writers to one path serialize on a lock so precondition checks and commits
|
|
92
|
+
// are atomic across processes.
|
|
93
|
+
#locked(vfsPath, fn) {
|
|
94
|
+
const dir = path.join(this.root, VFS_LOCK_DIR);
|
|
95
|
+
let stat = null;
|
|
96
|
+
try { stat = fs.lstatSync(dir); } catch (error) { if (error.code !== 'ENOENT') throw error; }
|
|
97
|
+
if (stat && (stat.isSymbolicLink() || !stat.isDirectory())) throw new VfsPathError(`VFS lock directory '${VFS_LOCK_DIR}' must be a real directory.`);
|
|
98
|
+
const name = crypto.createHash('sha256').update(vfsPath).digest('hex');
|
|
99
|
+
return withFileLock(path.join(dir, `${name}.lock`), fn, { ...this.lockOptions, createDir: true });
|
|
84
100
|
}
|
|
85
101
|
|
|
86
102
|
#resolve(vfsPath, { allowMissing = true } = {}) {
|
|
87
103
|
if (!isSafeDocumentPath(vfsPath)) throw new VfsPathError(`Unsafe VFS path '${vfsPath}'.`);
|
|
104
|
+
// Case-insensitive: on APFS/NTFS any casing reaches the same directory.
|
|
105
|
+
if (vfsPath.split('/')[0].toLowerCase() === VFS_LOCK_DIR) throw new VfsPathError(`VFS path '${vfsPath}' is reserved.`);
|
|
88
106
|
const target = path.resolve(this.root, ...vfsPath.split('/'));
|
|
89
107
|
if (target === this.root || !target.startsWith(`${this.root}${path.sep}`)) {
|
|
90
108
|
throw new VfsPathError(`VFS path '${vfsPath}' escapes the root.`);
|
|
@@ -131,6 +149,7 @@ export class FileSystemVfs {
|
|
|
131
149
|
while (stack.length) {
|
|
132
150
|
const dir = stack.pop();
|
|
133
151
|
for (const entry of fs.readdirSync(dir, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) {
|
|
152
|
+
if (dir === this.root && entry.name === VFS_LOCK_DIR) continue;
|
|
134
153
|
const absolute = path.join(dir, entry.name);
|
|
135
154
|
const relative = path.relative(this.root, absolute).split(path.sep).join('/');
|
|
136
155
|
if (entry.isSymbolicLink()) throw new VfsPathError(`Symlinked VFS entry is forbidden: '${relative}'.`);
|
|
@@ -144,6 +163,10 @@ export class FileSystemVfs {
|
|
|
144
163
|
|
|
145
164
|
async writeAtomic(vfsPath, bytes, precondition = {}) {
|
|
146
165
|
const target = this.#resolve(vfsPath);
|
|
166
|
+
return this.#locked(vfsPath, () => this.#commit(vfsPath, target, bytes, precondition));
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
async #commit(vfsPath, target, bytes, precondition) {
|
|
147
170
|
const current = await this.read(vfsPath);
|
|
148
171
|
checkPrecondition(current, precondition);
|
|
149
172
|
fs.mkdirSync(path.dirname(target), { recursive: true });
|
|
@@ -171,12 +194,15 @@ export class FileSystemVfs {
|
|
|
171
194
|
}
|
|
172
195
|
|
|
173
196
|
async remove(vfsPath, precondition = {}) {
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
197
|
+
this.#resolve(vfsPath);
|
|
198
|
+
return this.#locked(vfsPath, async () => {
|
|
199
|
+
const target = this.#resolve(vfsPath, { allowMissing: false });
|
|
200
|
+
const current = await this.read(vfsPath);
|
|
201
|
+
checkPrecondition(current, precondition);
|
|
202
|
+
if (!target || !current) return { path: vfsPath, removed: false };
|
|
203
|
+
fs.rmSync(target);
|
|
204
|
+
return { path: vfsPath, removed: true, previous_hash: current.hash };
|
|
205
|
+
});
|
|
180
206
|
}
|
|
181
207
|
}
|
|
182
208
|
|