@gregiteen/ssss-cli 0.9.2 → 0.9.3
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 +18 -0
- package/VERSION +1 -1
- package/conformance/reference-bundle.ucw.json +1 -1
- package/docs/help/concurrency.md +17 -4
- package/package.json +1 -1
- package/scripts/conformance-09.mjs +28 -1
- package/src/events.mjs +16 -0
- package/src/kernel.mjs +19 -6
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,24 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.9.3] - 2026-09-24
|
|
11
|
+
### Fixed
|
|
12
|
+
- Two processes sending an `event` envelope with the same idempotency key at the
|
|
13
|
+
same moment could both append an event, because the kernel records the result
|
|
14
|
+
only after committing and `event` envelopes have no VFS compare-and-swap. The
|
|
15
|
+
kernel now derives an `event` envelope's `event_id` from `(workspace_id,
|
|
16
|
+
idempotency_key)` (`idempotentEventId`, UUIDv5), so the event store rejects
|
|
17
|
+
the second append. Any request that loses at commit re-reads the idempotency
|
|
18
|
+
store and returns the winner's result as a replay, or an idempotency conflict
|
|
19
|
+
when its request differs. If a process dies after appending an event but
|
|
20
|
+
before recording its result, retries of that key now fail instead of
|
|
21
|
+
appending a second event.
|
|
22
|
+
|
|
23
|
+
### Added
|
|
24
|
+
- `idempotentEventId(workspaceId, idempotencyKey)` in `@gregiteen/ssss-cli/events`.
|
|
25
|
+
- Conformance checks racing six kernel processes on one `event` idempotency key,
|
|
26
|
+
with identical and with differing content.
|
|
27
|
+
|
|
10
28
|
## [0.9.2] - 2026-09-24
|
|
11
29
|
### Fixed
|
|
12
30
|
- The file adapters are now safe to share between processes. Each one checked
|
package/VERSION
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
0.9.
|
|
1
|
+
0.9.3
|
|
@@ -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.3"
|
|
110
110
|
}
|
|
111
111
|
},
|
|
112
112
|
"files": [
|
package/docs/help/concurrency.md
CHANGED
|
@@ -13,6 +13,23 @@ most one racer wins:
|
|
|
13
13
|
|
|
14
14
|
`.ssss-locks/` is reserved: VFS paths under it are rejected and `list()` skips it.
|
|
15
15
|
|
|
16
|
+
## Same idempotency key from two processes
|
|
17
|
+
|
|
18
|
+
The kernel records a request's result only after it commits, so two processes
|
|
19
|
+
can both miss the stored result for one key. The commit step then decides:
|
|
20
|
+
|
|
21
|
+
- `operation`, `patch`, `delete`: the VFS compare-and-swap lets one commit; the
|
|
22
|
+
other fails its precondition.
|
|
23
|
+
- `event`: the event's `event_id` is derived from `(workspace_id,
|
|
24
|
+
idempotency_key)` (`idempotentEventId`, a UUIDv5), so the event store rejects
|
|
25
|
+
the second append.
|
|
26
|
+
|
|
27
|
+
The loser then re-reads the idempotency store: if the winner has already
|
|
28
|
+
recorded its result, the loser returns it as a replay (or an idempotency
|
|
29
|
+
conflict when its request differs); otherwise it fails and a retry replays. If a
|
|
30
|
+
process dies after appending an event but before recording the result, retries
|
|
31
|
+
of that key fail rather than append a second event.
|
|
32
|
+
|
|
16
33
|
## Crashed holders
|
|
17
34
|
|
|
18
35
|
A lock records its holder's pid and host. A waiter breaks the lock when the
|
|
@@ -31,10 +48,6 @@ Symlinked lock files are refused.
|
|
|
31
48
|
|
|
32
49
|
- Locks are advisory: only writers using these adapters take them. Writers from
|
|
33
50
|
`@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
51
|
- Network filesystems must support exclusive create (`O_EXCL`).
|
|
39
52
|
- Dead-holder detection assumes processes that share the filesystem have
|
|
40
53
|
distinct hostnames (containers with the same hostname and separate pid
|
package/package.json
CHANGED
|
@@ -13,7 +13,7 @@ import {
|
|
|
13
13
|
} from '../src/registry.mjs';
|
|
14
14
|
import { createValidator } from '../src/validator.mjs';
|
|
15
15
|
import { FileSystemVfs, MemoryVfs, runVfsContract } from '../src/vfs.mjs';
|
|
16
|
-
import { JsonlEventStore, MemoryEventStore, createCanonicalEvent } from '../src/events.mjs';
|
|
16
|
+
import { JsonlEventStore, MemoryEventStore, createCanonicalEvent, idempotentEventId } from '../src/events.mjs';
|
|
17
17
|
import {
|
|
18
18
|
ProjectionCoordinator,
|
|
19
19
|
createSqlProjectionAdapter,
|
|
@@ -119,6 +119,33 @@ async function checkFileAdapterRaces(root, principal, check) {
|
|
|
119
119
|
const stored = await new FileIdempotencyStore(idempotencyRoot).get('acme', 'race');
|
|
120
120
|
check('filesystem idempotency store accepts one of several processes putting the same key',
|
|
121
121
|
winners(puts).length === 1 && stored?.request_hash === `racer-${winners(puts)[0]?.racer}`, describe(puts));
|
|
122
|
+
|
|
123
|
+
const kernelRoot = path.join(root, 'kernel');
|
|
124
|
+
const kernelPrincipal = { ...principal, capabilities: ['*:*'] };
|
|
125
|
+
const stores = () => ({
|
|
126
|
+
vfs: new FileSystemVfs(kernelRoot),
|
|
127
|
+
eventStore: new JsonlEventStore(path.join(kernelRoot, '.events')),
|
|
128
|
+
idempotencyStore: new FileIdempotencyStore(path.join(kernelRoot, '.idempotency')),
|
|
129
|
+
});
|
|
130
|
+
const envelope = (key, content) => ({ type: 'event', workspace_id: 'acme', idempotency_key: key, path: 'events/race.md', content });
|
|
131
|
+
const kernelRace = (key, contentExpression) => raceProcesses(racers, {
|
|
132
|
+
createKernel: '../src/kernel.mjs', FileSystemVfs: '../src/vfs.mjs', JsonlEventStore: '../src/events.mjs', FileIdempotencyStore: '../src/idempotency.mjs',
|
|
133
|
+
}, [
|
|
134
|
+
`const kernel = createKernel({ vfs: new FileSystemVfs(${JSON.stringify(kernelRoot)}), eventStore: new JsonlEventStore(${JSON.stringify(path.join(kernelRoot, '.events'))}), idempotencyStore: new FileIdempotencyStore(${JSON.stringify(path.join(kernelRoot, '.idempotency'))}) })`,
|
|
135
|
+
`const result = await kernel.execute({ type: 'event', workspace_id: 'acme', idempotency_key: ${JSON.stringify(key)}, path: 'events/race.md', content: ${contentExpression} }, { principal: ${JSON.stringify(kernelPrincipal)} })`,
|
|
136
|
+
"if (!result.success) throw new Error(result.validation.errors.join('; '))",
|
|
137
|
+
].join(';\n'));
|
|
138
|
+
const eventsFor = (key) => fs.readFileSync(path.join(kernelRoot, '.events', 'acme.jsonl'), 'utf8')
|
|
139
|
+
.split('\n').filter(Boolean).map((line) => JSON.parse(line)).filter((event) => event.idempotency_key === key);
|
|
140
|
+
const same = await kernelRace('race-same', 'JSON.stringify({ n: 1 })');
|
|
141
|
+
const retried = await createKernel(stores()).execute(envelope('race-same', JSON.stringify({ n: 1 })), { principal: kernelPrincipal });
|
|
142
|
+
check('kernel appends one event when several processes send the same event envelope',
|
|
143
|
+
eventsFor('race-same').length === 1 && winners(same).length >= 1 && retried.replay === true, describe(same));
|
|
144
|
+
const differing = await kernelRace('race-differ', 'JSON.stringify({ racer })');
|
|
145
|
+
check('kernel appends one event when several processes reuse a key with different content',
|
|
146
|
+
eventsFor('race-differ').length === 1 && winners(differing).length === 1, describe(differing));
|
|
147
|
+
check('kernel derives event envelope ids from the idempotency key',
|
|
148
|
+
eventsFor('race-same')[0]?.event_id === idempotentEventId('acme', 'race-same'));
|
|
122
149
|
}
|
|
123
150
|
|
|
124
151
|
async function checkFileLocks(root, principal, check) {
|
package/src/events.mjs
CHANGED
|
@@ -4,6 +4,22 @@ import path from 'node:path';
|
|
|
4
4
|
import crypto from 'node:crypto';
|
|
5
5
|
import { withFileLock } from './file-lock.mjs';
|
|
6
6
|
|
|
7
|
+
// UUIDv5 namespace for event ids derived from idempotency keys.
|
|
8
|
+
const IDEMPOTENT_EVENT_NAMESPACE = Buffer.from('6f1c2d9e4b7a4e0c9f35a1d8c3b2e7f4', 'hex');
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Deterministic event_id (UUIDv5) for one (workspace_id, idempotency_key).
|
|
12
|
+
* The kernel uses it for `event` envelopes so a store's duplicate-event_id
|
|
13
|
+
* check stops two concurrent requests with the same key from both appending.
|
|
14
|
+
*/
|
|
15
|
+
export function idempotentEventId(workspaceId, idempotencyKey) {
|
|
16
|
+
const hash = crypto.createHash('sha1').update(IDEMPOTENT_EVENT_NAMESPACE).update(JSON.stringify([workspaceId, idempotencyKey])).digest();
|
|
17
|
+
hash[6] = (hash[6] & 0x0f) | 0x50;
|
|
18
|
+
hash[8] = (hash[8] & 0x3f) | 0x80;
|
|
19
|
+
const hex = hash.subarray(0, 16).toString('hex');
|
|
20
|
+
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
|
|
21
|
+
}
|
|
22
|
+
|
|
7
23
|
export function createCanonicalEvent(input, options = {}) {
|
|
8
24
|
const required = ['workspace_id', 'action', 'subject', 'operation_id', 'idempotency_key'];
|
|
9
25
|
for (const field of required) if (typeof input?.[field] !== 'string' || !input[field]) throw new Error(`Event ${field} is required.`);
|
package/src/kernel.mjs
CHANGED
|
@@ -7,7 +7,7 @@ import { parseDocument, serializeDocument } from './frontmatter.mjs';
|
|
|
7
7
|
import { createValidator } from './validator.mjs';
|
|
8
8
|
import { isAppendType, resolvePrimitiveDefinition } from './registry.mjs';
|
|
9
9
|
import { createCapabilityAuthorizer } from './authorization.mjs';
|
|
10
|
-
import { createCanonicalEvent } from './events.mjs';
|
|
10
|
+
import { createCanonicalEvent, idempotentEventId } from './events.mjs';
|
|
11
11
|
import { MemoryIdempotencyStore } from './idempotency.mjs';
|
|
12
12
|
|
|
13
13
|
export { MemoryIdempotencyStore } from './idempotency.mjs';
|
|
@@ -92,13 +92,17 @@ export function createKernel(options = {}) {
|
|
|
92
92
|
}
|
|
93
93
|
|
|
94
94
|
const requestHash = canonicalRequestHash(envelope, principal);
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
95
|
+
// A stored result for this key: replay it, or refuse a different request.
|
|
96
|
+
const settled = async () => {
|
|
97
|
+
const stored = await idempotencyStore.get(envelope.workspace_id, envelope.idempotency_key);
|
|
98
|
+
if (!stored) return null;
|
|
99
|
+
if (stored.request_hash !== requestHash) {
|
|
98
100
|
return failure(envelope, operationId, ['Idempotency key was already used for a different request.'], [{ field: 'idempotency_key', issue: 'Request hash conflict.' }]);
|
|
99
101
|
}
|
|
100
|
-
return { ...
|
|
101
|
-
}
|
|
102
|
+
return { ...stored.response, replay: true };
|
|
103
|
+
};
|
|
104
|
+
const replay = await settled();
|
|
105
|
+
if (replay) return replay;
|
|
102
106
|
|
|
103
107
|
let current;
|
|
104
108
|
try { current = await vfs.read(envelope.path); }
|
|
@@ -211,6 +215,9 @@ export function createKernel(options = {}) {
|
|
|
211
215
|
}
|
|
212
216
|
|
|
213
217
|
const event = createCanonicalEvent({
|
|
218
|
+
// `event` envelopes have no VFS compare-and-swap, so a key-derived id
|
|
219
|
+
// lets the event store reject a concurrent request with the same key.
|
|
220
|
+
event_id: envelope.type === 'event' ? idempotentEventId(envelope.workspace_id, envelope.idempotency_key) : undefined,
|
|
214
221
|
workspace_id: envelope.workspace_id,
|
|
215
222
|
primitive_id: definition.qualified_type,
|
|
216
223
|
primitive_version: definition.primitive_version || 1,
|
|
@@ -252,6 +259,12 @@ export function createKernel(options = {}) {
|
|
|
252
259
|
return failure(envelope, operationId, [`Commit failed: ${error.message}; rollback failed: ${rollbackError.message}`]);
|
|
253
260
|
}
|
|
254
261
|
}
|
|
262
|
+
// A concurrent request with the same key may have won the commit (VFS
|
|
263
|
+
// compare-and-swap or duplicate event_id); answer with its result.
|
|
264
|
+
try {
|
|
265
|
+
const winner = await settled();
|
|
266
|
+
if (winner) return winner;
|
|
267
|
+
} catch {}
|
|
255
268
|
return failure(envelope, operationId, [`Commit failed: ${error.message}`]);
|
|
256
269
|
}
|
|
257
270
|
}
|