@rivus/agent-kit-collab 0.0.0 → 0.3.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 +149 -0
- package/dist/acquire-process-lock-A0oYDN9m.js +311 -0
- package/dist/lanes.d.ts +120 -0
- package/dist/lanes.js +323 -0
- package/dist/lease.d.ts +265 -0
- package/dist/lease.js +670 -0
- package/dist/process-lock.d.ts +58 -0
- package/dist/process-lock.js +2 -0
- package/package.json +66 -6
package/dist/lease.js
ADDED
|
@@ -0,0 +1,670 @@
|
|
|
1
|
+
import { a as holderLiveness, i as isSqliteBusy, n as backoffMs, o as readText, r as tryProcessLock } from "./acquire-process-lock-A0oYDN9m.js";
|
|
2
|
+
import { PlatformService } from "@rivus/agent-kit/platform/effect";
|
|
3
|
+
import * as Effect from "effect/Effect";
|
|
4
|
+
import * as Layer from "effect/Layer";
|
|
5
|
+
import * as z from "zod/mini";
|
|
6
|
+
import * as Context from "effect/Context";
|
|
7
|
+
import { AgentKitError, err, ok } from "@rivus/agent-kit/catalog";
|
|
8
|
+
import * as Semaphore from "effect/Semaphore";
|
|
9
|
+
import * as Schedule from "effect/Schedule";
|
|
10
|
+
import * as Cause from "effect/Cause";
|
|
11
|
+
import * as Deferred from "effect/Deferred";
|
|
12
|
+
import * as Exit from "effect/Exit";
|
|
13
|
+
import * as Ref from "effect/Ref";
|
|
14
|
+
//#region src/lease/application/ports.ts
|
|
15
|
+
const LeaseStoreBase = Context.Service()("@rivus/agent-kit-collab/lease/LeaseStore/v1");
|
|
16
|
+
/** The lease store port; `sqliteLeaseStore`, `fileLeaseStore` and `memoryLeaseStore` provide it. */
|
|
17
|
+
var LeaseStore = class extends LeaseStoreBase {};
|
|
18
|
+
//#endregion
|
|
19
|
+
//#region src/lease/application/store-failure.ts
|
|
20
|
+
function storeFailure(key, reason, message, cause) {
|
|
21
|
+
return cause === void 0 ? {
|
|
22
|
+
_tag: "LeaseStoreFailure",
|
|
23
|
+
key,
|
|
24
|
+
reason,
|
|
25
|
+
message
|
|
26
|
+
} : {
|
|
27
|
+
_tag: "LeaseStoreFailure",
|
|
28
|
+
key,
|
|
29
|
+
reason,
|
|
30
|
+
message,
|
|
31
|
+
cause
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
//#endregion
|
|
35
|
+
//#region src/lease/adapters/key-file-name.ts
|
|
36
|
+
/** File names stay well under the usual 255-byte limit even with a suffix such as `.lease.json.stale`. */
|
|
37
|
+
const MAX_LENGTH = 160;
|
|
38
|
+
/**
|
|
39
|
+
* The part of a file name that stands for `key`. Percent-encoding keeps `/`, `.` and other separators from forming a
|
|
40
|
+
* path; a longer key keeps a readable prefix and a SHA-256 of the whole key.
|
|
41
|
+
*/
|
|
42
|
+
function keyFileName(key) {
|
|
43
|
+
const encoded = encodeURIComponent(key).replace(/[!'()*.~]/g, (char) => `%${char.charCodeAt(0).toString(16).toUpperCase()}`);
|
|
44
|
+
if (encoded.length <= MAX_LENGTH) return Effect.succeed(encoded);
|
|
45
|
+
return Effect.promise(() => crypto.subtle.digest("SHA-256", new TextEncoder().encode(key))).pipe(Effect.map((digest) => {
|
|
46
|
+
const hex = [...new Uint8Array(digest)].map((byte) => byte.toString(16).padStart(2, "0")).join("");
|
|
47
|
+
return `${encoded.slice(0, 64)}~${hex}`;
|
|
48
|
+
}));
|
|
49
|
+
}
|
|
50
|
+
//#endregion
|
|
51
|
+
//#region src/lease/adapters/lease-record-codec.ts
|
|
52
|
+
const holderSchema = z.object({
|
|
53
|
+
host: z.string(),
|
|
54
|
+
bootId: z.string(),
|
|
55
|
+
pid: z.number(),
|
|
56
|
+
startTime: z.number()
|
|
57
|
+
});
|
|
58
|
+
const snapshotSchema = z.object({
|
|
59
|
+
key: z.string(),
|
|
60
|
+
generation: z.number(),
|
|
61
|
+
revision: z.number(),
|
|
62
|
+
holder: z.nullable(holderSchema),
|
|
63
|
+
holderId: z.nullable(z.string()),
|
|
64
|
+
renewedAt: z.number()
|
|
65
|
+
});
|
|
66
|
+
/** Checks the shape of a stored record; the Lease aggregate checks its invariants when the manager restores it. */
|
|
67
|
+
function decodeSnapshot(value) {
|
|
68
|
+
const parsed = snapshotSchema.safeParse(value);
|
|
69
|
+
return parsed.success ? parsed.data : void 0;
|
|
70
|
+
}
|
|
71
|
+
function decodeHolder(json) {
|
|
72
|
+
if (json === null) return null;
|
|
73
|
+
try {
|
|
74
|
+
const parsed = holderSchema.safeParse(JSON.parse(json));
|
|
75
|
+
return parsed.success ? parsed.data : void 0;
|
|
76
|
+
} catch {
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
/** Only the identity fields, whatever else the platform's `ProcessIdentity` carries. */
|
|
81
|
+
function encodeHolder(holder) {
|
|
82
|
+
if (holder === null) return null;
|
|
83
|
+
const { host, bootId, pid, startTime } = holder;
|
|
84
|
+
return JSON.stringify({
|
|
85
|
+
host,
|
|
86
|
+
bootId,
|
|
87
|
+
pid,
|
|
88
|
+
startTime
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
//#endregion
|
|
92
|
+
//#region src/lease/domain/lease/policies/acquisition.ts
|
|
93
|
+
/**
|
|
94
|
+
* A lease can be taken when there is no record, the record is a tombstone, its holder is dead, or it has not been
|
|
95
|
+
* renewed within the TTL as the observer saw it. A live or unknown holder that keeps renewing keeps the lease.
|
|
96
|
+
*/
|
|
97
|
+
function canAcquire(lease, view) {
|
|
98
|
+
return lease === void 0 || lease.holder === null || view.liveness === "dead" || !view.fresh;
|
|
99
|
+
}
|
|
100
|
+
//#endregion
|
|
101
|
+
//#region src/lease/domain/lease/policies/fence-check.ts
|
|
102
|
+
/** The token the next acquisition of `key` receives: one generation above the record, 1 for a new key. */
|
|
103
|
+
function nextFencingToken(current, key) {
|
|
104
|
+
return {
|
|
105
|
+
key,
|
|
106
|
+
generation: (current?.generation ?? 0) + 1
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
/**
|
|
110
|
+
* The check a protected resource runs inside its own atomic write: `lastSeen` is the highest token it has accepted
|
|
111
|
+
* for the key. A smaller generation is refused; an equal one is the same holder writing again. On success, store the
|
|
112
|
+
* returned token as the new `lastSeen`.
|
|
113
|
+
*/
|
|
114
|
+
function checkFence(lastSeen, token) {
|
|
115
|
+
if (lastSeen !== void 0 && lastSeen.key === token.key && token.generation < lastSeen.generation) return err({
|
|
116
|
+
_tag: "FenceRejected",
|
|
117
|
+
key: token.key,
|
|
118
|
+
generation: token.generation,
|
|
119
|
+
current: lastSeen.generation
|
|
120
|
+
});
|
|
121
|
+
return ok(token);
|
|
122
|
+
}
|
|
123
|
+
//#endregion
|
|
124
|
+
//#region src/lease/domain/lease/aggregate/lease.ts
|
|
125
|
+
/**
|
|
126
|
+
* One key's lease. It has one holder at a time; its generation never decreases, release included, because release
|
|
127
|
+
* keeps the record as a tombstone; every write increases its revision. Stores persist the snapshot and compare
|
|
128
|
+
* revisions, so a transition only takes effect when no other writer got there first.
|
|
129
|
+
*/
|
|
130
|
+
var Lease = class Lease {
|
|
131
|
+
/** The first acquisition of a key that has no record. */
|
|
132
|
+
static create(claim) {
|
|
133
|
+
const { generation } = nextFencingToken(void 0, claim.key);
|
|
134
|
+
const state = new Lease({
|
|
135
|
+
key: claim.key,
|
|
136
|
+
generation,
|
|
137
|
+
revision: 1,
|
|
138
|
+
holder: claim.holder,
|
|
139
|
+
holderId: claim.holderId,
|
|
140
|
+
renewedAt: claim.now
|
|
141
|
+
});
|
|
142
|
+
return {
|
|
143
|
+
state,
|
|
144
|
+
events: [acquired(state.snapshot, null)]
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
static restore(snapshot) {
|
|
148
|
+
const problem = invalidity(snapshot);
|
|
149
|
+
if (problem !== void 0) return err({
|
|
150
|
+
_tag: "LeaseRecordInvalid",
|
|
151
|
+
key: snapshot.key,
|
|
152
|
+
message: problem
|
|
153
|
+
});
|
|
154
|
+
return ok(new Lease({ ...snapshot }));
|
|
155
|
+
}
|
|
156
|
+
snapshot;
|
|
157
|
+
constructor(snapshot) {
|
|
158
|
+
this.snapshot = snapshot;
|
|
159
|
+
Object.freeze(this);
|
|
160
|
+
}
|
|
161
|
+
/** Takes the lease when `canAcquire` allows it; the generation moves to the next fencing token. */
|
|
162
|
+
acquire(claim, view) {
|
|
163
|
+
const { holder, holderId, generation, key } = this.snapshot;
|
|
164
|
+
if (holder !== null && holderId !== null && !canAcquire(this.snapshot, view)) return err({
|
|
165
|
+
_tag: "LeaseHeld",
|
|
166
|
+
key,
|
|
167
|
+
generation,
|
|
168
|
+
holder,
|
|
169
|
+
holderId
|
|
170
|
+
});
|
|
171
|
+
const state = new Lease({
|
|
172
|
+
key,
|
|
173
|
+
generation: nextFencingToken(this.snapshot, key).generation,
|
|
174
|
+
revision: this.snapshot.revision + 1,
|
|
175
|
+
holder: claim.holder,
|
|
176
|
+
holderId: claim.holderId,
|
|
177
|
+
renewedAt: claim.now
|
|
178
|
+
});
|
|
179
|
+
return ok({
|
|
180
|
+
state,
|
|
181
|
+
events: [acquired(state.snapshot, holderId)]
|
|
182
|
+
});
|
|
183
|
+
}
|
|
184
|
+
/** A heartbeat: only the holding acquisition renews, and the generation stays. */
|
|
185
|
+
renew(holding, now) {
|
|
186
|
+
const lost = this.lostBy(holding);
|
|
187
|
+
if (lost !== void 0) return err(lost);
|
|
188
|
+
const state = new Lease({
|
|
189
|
+
...this.snapshot,
|
|
190
|
+
revision: this.snapshot.revision + 1,
|
|
191
|
+
renewedAt: now
|
|
192
|
+
});
|
|
193
|
+
const { key, generation, revision } = state.snapshot;
|
|
194
|
+
return ok({
|
|
195
|
+
state,
|
|
196
|
+
events: [{
|
|
197
|
+
_tag: "LeaseRenewed",
|
|
198
|
+
key,
|
|
199
|
+
generation,
|
|
200
|
+
revision
|
|
201
|
+
}]
|
|
202
|
+
});
|
|
203
|
+
}
|
|
204
|
+
/** Leaves a tombstone: no holder, the same generation, so the next acquisition still moves forward. */
|
|
205
|
+
release(holding, now) {
|
|
206
|
+
const lost = this.lostBy(holding);
|
|
207
|
+
if (lost !== void 0) return err(lost);
|
|
208
|
+
const state = new Lease({
|
|
209
|
+
...this.snapshot,
|
|
210
|
+
revision: this.snapshot.revision + 1,
|
|
211
|
+
holder: null,
|
|
212
|
+
holderId: null,
|
|
213
|
+
renewedAt: now
|
|
214
|
+
});
|
|
215
|
+
const { key, generation } = state.snapshot;
|
|
216
|
+
return ok({
|
|
217
|
+
state,
|
|
218
|
+
events: [{
|
|
219
|
+
_tag: "LeaseReleased",
|
|
220
|
+
key,
|
|
221
|
+
generation,
|
|
222
|
+
holderId: holding.holderId
|
|
223
|
+
}]
|
|
224
|
+
});
|
|
225
|
+
}
|
|
226
|
+
toSnapshot() {
|
|
227
|
+
return this.snapshot;
|
|
228
|
+
}
|
|
229
|
+
lostBy(holding) {
|
|
230
|
+
const { key, holderId, generation } = this.snapshot;
|
|
231
|
+
if (holderId === holding.holderId && generation === holding.generation) return;
|
|
232
|
+
const reason = holderId === null ? "released" : "taken-over";
|
|
233
|
+
return {
|
|
234
|
+
_tag: "LeaseLost",
|
|
235
|
+
key,
|
|
236
|
+
generation: holding.generation,
|
|
237
|
+
reason
|
|
238
|
+
};
|
|
239
|
+
}
|
|
240
|
+
};
|
|
241
|
+
function acquired(snapshot, previousHolderId) {
|
|
242
|
+
const { key, generation, holderId } = snapshot;
|
|
243
|
+
return {
|
|
244
|
+
_tag: "LeaseAcquired",
|
|
245
|
+
key,
|
|
246
|
+
generation,
|
|
247
|
+
holderId: holderId ?? "",
|
|
248
|
+
previousHolderId
|
|
249
|
+
};
|
|
250
|
+
}
|
|
251
|
+
function invalidity(snapshot) {
|
|
252
|
+
if (snapshot.key === "") return "the key is empty";
|
|
253
|
+
if (!Number.isSafeInteger(snapshot.generation) || snapshot.generation < 1) return `generation ${snapshot.generation} is not a positive integer`;
|
|
254
|
+
if (!Number.isSafeInteger(snapshot.revision) || snapshot.revision < snapshot.generation) return `revision ${snapshot.revision} is not an integer at least as large as the generation`;
|
|
255
|
+
if (snapshot.holder === null !== (snapshot.holderId === null)) return "a holder and a holder id come together";
|
|
256
|
+
return Number.isFinite(snapshot.renewedAt) ? void 0 : "renewedAt is not a number";
|
|
257
|
+
}
|
|
258
|
+
//#endregion
|
|
259
|
+
//#region src/lease/domain/lease/policies/freshness.ts
|
|
260
|
+
/** Restarts the observer's clock when the record has a revision it has not seen. */
|
|
261
|
+
function observe(previous, lease, now) {
|
|
262
|
+
return previous?.revision === lease.revision ? previous : {
|
|
263
|
+
revision: lease.revision,
|
|
264
|
+
observedAt: now
|
|
265
|
+
};
|
|
266
|
+
}
|
|
267
|
+
/**
|
|
268
|
+
* Whether a held lease is still valid for this observer: its record changed less than `ttlMs` ago by the observer's
|
|
269
|
+
* monotonic clock. A record whose revision the observation has not seen has just changed, so it is fresh. A
|
|
270
|
+
* tombstone is never fresh.
|
|
271
|
+
*/
|
|
272
|
+
function isFresh(lease, observation, now, ttlMs) {
|
|
273
|
+
return lease.holder !== null && (observation.revision !== lease.revision || now - observation.observedAt < ttlMs);
|
|
274
|
+
}
|
|
275
|
+
//#endregion
|
|
276
|
+
//#region src/lease/adapters/process-fence.ts
|
|
277
|
+
/** The first retry of a waiting fence or guard; later ones back off from it. */
|
|
278
|
+
const RETRY_MS = 10;
|
|
279
|
+
/**
|
|
280
|
+
* Holds a process lock at `path` until the Scope closes: an exclusive SQLite lock when the platform has SQLite, a
|
|
281
|
+
* lock file otherwise. Each attempt is uninterruptible and registers its release in the same step; waiting happens
|
|
282
|
+
* between attempts, where interruption is safe, with a growing pause. A holder that exits frees the lock at once.
|
|
283
|
+
*/
|
|
284
|
+
function holdProcessLock(platform, key, path, options = {}) {
|
|
285
|
+
const attempt = (first) => Effect.acquireRelease(Effect.tryPromise({
|
|
286
|
+
try: () => tryProcessLock(platform, path, { first }),
|
|
287
|
+
catch: (cause) => storeFailure(key, "io", `cannot lock ${path}`, cause)
|
|
288
|
+
}).pipe(Effect.flatMap((result) => result.ok ? Effect.succeed(result.value) : Effect.fail(storeFailure(key, "busy", `${path} is locked`)))), (lock) => Effect.promise(() => lock.release()));
|
|
289
|
+
return Effect.gen(function* () {
|
|
290
|
+
const started = platform.clock.monotonic();
|
|
291
|
+
for (let turn = 0;; turn += 1) {
|
|
292
|
+
if (yield* attempt(turn === 0).pipe(Effect.as(true), Effect.catchIf((failure) => failure.reason === "busy", () => Effect.succeed(false)))) return;
|
|
293
|
+
if (options.timeoutMs !== void 0 && platform.clock.monotonic() - started >= options.timeoutMs) return yield* Effect.fail(storeFailure(key, "busy", `${path} is still locked after ${options.timeoutMs} ms`));
|
|
294
|
+
yield* Effect.sleep(backoffMs(RETRY_MS, turn));
|
|
295
|
+
}
|
|
296
|
+
});
|
|
297
|
+
}
|
|
298
|
+
//#endregion
|
|
299
|
+
//#region src/lease/adapters/file-lease-store.ts
|
|
300
|
+
const SCHEMA_VERSION$1 = 1;
|
|
301
|
+
/**
|
|
302
|
+
* A guard is held only for one read, compare and write, so waiting longer means a stalled holder: the write reports
|
|
303
|
+
* `busy` instead of waiting, and callers such as a heartbeat retry it later.
|
|
304
|
+
*/
|
|
305
|
+
const GUARD_TIMEOUT_MS = 1e3;
|
|
306
|
+
/**
|
|
307
|
+
* The fallback store for platforms without SQLite: one JSON record per key, `<key>.lease.json`, replaced with
|
|
308
|
+
* `writeAtomic`. Every `compareAndSet` holds the key's guard (`<key>.lease.guard`) while it reads, compares and
|
|
309
|
+
* writes, and reports `busy` when the guard stays held longer than a second; `fence` holds `<key>.lease.fence`.
|
|
310
|
+
* Both are process locks, so without SQLite they are lock files whose dead holders are reclaimed one reclaimer at a
|
|
311
|
+
* time, with the gaps `acquireProcessLock` documents. A record with an unknown `schemaVersion` or shape is refused
|
|
312
|
+
* and never overwritten.
|
|
313
|
+
*/
|
|
314
|
+
function fileLeaseStore(options) {
|
|
315
|
+
const { dir } = options;
|
|
316
|
+
return Layer.effect(LeaseStore, Effect.gen(function* () {
|
|
317
|
+
const platform = yield* PlatformService;
|
|
318
|
+
const pathOf = (key, suffix) => keyFileName(key).pipe(Effect.map((name) => `${dir}/${name}.lease${suffix}`));
|
|
319
|
+
const read = (key) => pathOf(key, ".json").pipe(Effect.flatMap((path) => readRecord(platform, key, path)));
|
|
320
|
+
return {
|
|
321
|
+
read,
|
|
322
|
+
compareAndSet: (key, expected, next) => Effect.scoped(Effect.gen(function* () {
|
|
323
|
+
yield* holdProcessLock(platform, key, yield* pathOf(key, ".guard"), { timeoutMs: GUARD_TIMEOUT_MS });
|
|
324
|
+
const path = yield* pathOf(key, ".json");
|
|
325
|
+
return yield* Effect.uninterruptible(Effect.gen(function* () {
|
|
326
|
+
if ((yield* readRecord(platform, key, path))?.revision !== expected) return false;
|
|
327
|
+
yield* Effect.tryPromise({
|
|
328
|
+
try: () => platform.fs.writeAtomic(path, JSON.stringify({
|
|
329
|
+
schemaVersion: SCHEMA_VERSION$1,
|
|
330
|
+
record: next
|
|
331
|
+
})),
|
|
332
|
+
catch: (cause) => storeFailure(key, "io", `cannot write ${path}`, cause)
|
|
333
|
+
});
|
|
334
|
+
return true;
|
|
335
|
+
}));
|
|
336
|
+
})),
|
|
337
|
+
fence: (key) => pathOf(key, ".fence").pipe(Effect.flatMap((path) => holdProcessLock(platform, key, path)))
|
|
338
|
+
};
|
|
339
|
+
}));
|
|
340
|
+
}
|
|
341
|
+
function readRecord(platform, key, path) {
|
|
342
|
+
return Effect.tryPromise({
|
|
343
|
+
try: () => readText(platform, path),
|
|
344
|
+
catch: (cause) => storeFailure(key, "io", `cannot read ${path}`, cause)
|
|
345
|
+
}).pipe(Effect.flatMap((text) => text === void 0 ? Effect.succeed(void 0) : decodeFile(key, path, text)));
|
|
346
|
+
}
|
|
347
|
+
function decodeFile(key, path, text) {
|
|
348
|
+
let json;
|
|
349
|
+
try {
|
|
350
|
+
json = JSON.parse(text);
|
|
351
|
+
} catch (cause) {
|
|
352
|
+
return Effect.fail(storeFailure(key, "invalid-record", `${path} is not JSON`, cause));
|
|
353
|
+
}
|
|
354
|
+
const { schemaVersion, record } = typeof json === "object" && json !== null ? json : {};
|
|
355
|
+
if (schemaVersion !== SCHEMA_VERSION$1) return Effect.fail(storeFailure(key, "unsupported-schema", `${path} has schemaVersion ${String(schemaVersion)}; expected 1`));
|
|
356
|
+
const snapshot = decodeSnapshot(record);
|
|
357
|
+
return snapshot === void 0 ? Effect.fail(storeFailure(key, "invalid-record", `${path} holds a record of an unexpected shape`)) : Effect.succeed(snapshot);
|
|
358
|
+
}
|
|
359
|
+
//#endregion
|
|
360
|
+
//#region src/lease/adapters/memory-lease-store.ts
|
|
361
|
+
/**
|
|
362
|
+
* A store for one process, such as tests or a host whose leases never cross a process boundary. Each Layer build
|
|
363
|
+
* starts empty.
|
|
364
|
+
*/
|
|
365
|
+
function memoryLeaseStore() {
|
|
366
|
+
return Layer.sync(LeaseStore, () => {
|
|
367
|
+
const records = /* @__PURE__ */ new Map();
|
|
368
|
+
const fences = /* @__PURE__ */ new Map();
|
|
369
|
+
const fenceOf = (key) => {
|
|
370
|
+
const fence = fences.get(key) ?? Semaphore.makeUnsafe(1);
|
|
371
|
+
fences.set(key, fence);
|
|
372
|
+
return fence;
|
|
373
|
+
};
|
|
374
|
+
return {
|
|
375
|
+
read: (key) => Effect.sync(() => records.get(key)),
|
|
376
|
+
compareAndSet: (key, expected, next) => Effect.sync(() => {
|
|
377
|
+
if (records.get(key)?.revision !== expected) return false;
|
|
378
|
+
records.set(key, next);
|
|
379
|
+
return true;
|
|
380
|
+
}),
|
|
381
|
+
fence: (key) => Effect.acquireRelease(fenceOf(key).take(1), () => fenceOf(key).release(1), { interruptible: true }).pipe(Effect.asVoid)
|
|
382
|
+
};
|
|
383
|
+
});
|
|
384
|
+
}
|
|
385
|
+
//#endregion
|
|
386
|
+
//#region src/lease/adapters/sqlite-lease-store.ts
|
|
387
|
+
const SCHEMA_VERSION = 1;
|
|
388
|
+
/** Long enough for another writer's single-row transaction; longer waits block the event loop, so retry instead. */
|
|
389
|
+
const BUSY_TIMEOUT_MS = 50;
|
|
390
|
+
const BUSY_RETRIES = 100;
|
|
391
|
+
const BUSY_RETRY_MS = 5;
|
|
392
|
+
/**
|
|
393
|
+
* The lease store for one machine: one row per key in a SQLite database. `compareAndSet` runs in a
|
|
394
|
+
* `BEGIN IMMEDIATE` transaction, which takes SQLite's write lock (an fcntl lock) before reading the revision, so
|
|
395
|
+
* processes sharing the file compare and write atomically. The lock is released when its process exits, so neither a
|
|
396
|
+
* crashed writer nor a reused pid can leave the store stuck. Fails to build with `unavailable` when the platform has
|
|
397
|
+
* no SQLite, and with `unsupported-schema` for a database written by a newer version.
|
|
398
|
+
*/
|
|
399
|
+
function sqliteLeaseStore(options) {
|
|
400
|
+
const { path } = options;
|
|
401
|
+
return Layer.effect(LeaseStore, Effect.gen(function* () {
|
|
402
|
+
const platform = yield* PlatformService;
|
|
403
|
+
const { sqlite } = platform;
|
|
404
|
+
if (sqlite === void 0) return yield* Effect.fail(storeFailure("", "unavailable", "the platform has no SQLite"));
|
|
405
|
+
const db = yield* Effect.acquireRelease(sqliteTry("", `cannot open ${path}`, () => sqlite.open(path)), (opened) => Effect.sync(() => opened.close()));
|
|
406
|
+
yield* retryBusy(sqliteTry("", `cannot prepare ${path}`, () => migrate(db)));
|
|
407
|
+
const statements = {
|
|
408
|
+
select: db.prepare("SELECT key, generation, revision, holder, holder_id, renewed_at FROM lease_record WHERE key = ?"),
|
|
409
|
+
selectRevision: db.prepare("SELECT revision FROM lease_record WHERE key = ?"),
|
|
410
|
+
upsert: db.prepare(`INSERT INTO lease_record (key, generation, revision, holder, holder_id, renewed_at) VALUES (?, ?, ?, ?, ?, ?)
|
|
411
|
+
ON CONFLICT (key) DO UPDATE SET generation = excluded.generation, revision = excluded.revision,
|
|
412
|
+
holder = excluded.holder, holder_id = excluded.holder_id, renewed_at = excluded.renewed_at`)
|
|
413
|
+
};
|
|
414
|
+
return {
|
|
415
|
+
read: (key) => retryBusy(sqliteTry(key, `cannot read ${path}`, () => statements.select.get(key))).pipe(Effect.flatMap((row) => decodeRow(key, row))),
|
|
416
|
+
compareAndSet: (key, expected, next) => retryBusy(sqliteTry(key, `cannot write ${path}`, () => compareAndSet(db, statements, expected, next))),
|
|
417
|
+
fence: (key) => keyFileName(key).pipe(Effect.flatMap((name) => holdProcessLock(platform, key, `${path}.${name}.fence`)))
|
|
418
|
+
};
|
|
419
|
+
}));
|
|
420
|
+
}
|
|
421
|
+
function migrate(db) {
|
|
422
|
+
db.exec(`PRAGMA busy_timeout = ${BUSY_TIMEOUT_MS}`);
|
|
423
|
+
db.exec("PRAGMA journal_mode = WAL");
|
|
424
|
+
transaction(db, () => {
|
|
425
|
+
const version = Number(db.prepare("PRAGMA user_version").get()?.user_version ?? 0);
|
|
426
|
+
if (version === 0) {
|
|
427
|
+
db.exec(`CREATE TABLE IF NOT EXISTS lease_record (
|
|
428
|
+
key TEXT PRIMARY KEY,
|
|
429
|
+
generation INTEGER NOT NULL,
|
|
430
|
+
revision INTEGER NOT NULL,
|
|
431
|
+
holder TEXT,
|
|
432
|
+
holder_id TEXT,
|
|
433
|
+
renewed_at INTEGER NOT NULL
|
|
434
|
+
) STRICT`);
|
|
435
|
+
db.exec(`PRAGMA user_version = ${SCHEMA_VERSION}`);
|
|
436
|
+
} else if (version !== SCHEMA_VERSION) throw new UnsupportedSchema(`the lease database has schema version ${version}; this version reads ${SCHEMA_VERSION}`);
|
|
437
|
+
});
|
|
438
|
+
}
|
|
439
|
+
function compareAndSet(db, statements, expected, next) {
|
|
440
|
+
return transaction(db, () => {
|
|
441
|
+
const stored = statements.selectRevision.get(next.key)?.revision;
|
|
442
|
+
if ((stored === void 0 ? void 0 : Number(stored)) !== expected) return false;
|
|
443
|
+
statements.upsert.run(next.key, next.generation, next.revision, encodeHolder(next.holder), next.holderId, next.renewedAt);
|
|
444
|
+
return true;
|
|
445
|
+
});
|
|
446
|
+
}
|
|
447
|
+
/** Runs `body` in a `BEGIN IMMEDIATE` transaction, committing when it returns and rolling back when it throws. */
|
|
448
|
+
function transaction(db, body) {
|
|
449
|
+
db.exec("BEGIN IMMEDIATE");
|
|
450
|
+
try {
|
|
451
|
+
const result = body();
|
|
452
|
+
db.exec("COMMIT");
|
|
453
|
+
return result;
|
|
454
|
+
} catch (error) {
|
|
455
|
+
try {
|
|
456
|
+
db.exec("ROLLBACK");
|
|
457
|
+
} catch {}
|
|
458
|
+
throw error;
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
var UnsupportedSchema = class extends Error {};
|
|
462
|
+
function sqliteTry(key, message, body) {
|
|
463
|
+
return Effect.try({
|
|
464
|
+
try: body,
|
|
465
|
+
catch: (cause) => {
|
|
466
|
+
if (cause instanceof UnsupportedSchema) return storeFailure(key, "unsupported-schema", cause.message);
|
|
467
|
+
return storeFailure(key, isSqliteBusy(cause) ? "busy" : "io", message, cause);
|
|
468
|
+
}
|
|
469
|
+
});
|
|
470
|
+
}
|
|
471
|
+
function retryBusy(effect) {
|
|
472
|
+
return effect.pipe(Effect.retry({
|
|
473
|
+
while: (failure) => failure.reason === "busy",
|
|
474
|
+
times: BUSY_RETRIES,
|
|
475
|
+
schedule: Schedule.spaced(BUSY_RETRY_MS)
|
|
476
|
+
}));
|
|
477
|
+
}
|
|
478
|
+
function decodeRow(key, row) {
|
|
479
|
+
if (row === void 0) return Effect.succeed(void 0);
|
|
480
|
+
const holder = typeof row.holder === "string" || row.holder === null ? decodeHolder(row.holder) : void 0;
|
|
481
|
+
const snapshot = holder === void 0 ? void 0 : decodeSnapshot({
|
|
482
|
+
key: row.key,
|
|
483
|
+
generation: row.generation,
|
|
484
|
+
revision: row.revision,
|
|
485
|
+
holder,
|
|
486
|
+
holderId: row.holder_id,
|
|
487
|
+
renewedAt: row.renewed_at
|
|
488
|
+
});
|
|
489
|
+
return snapshot === void 0 ? Effect.fail(storeFailure(key, "invalid-record", `the stored record for ${key} has an unexpected shape`)) : Effect.succeed(snapshot);
|
|
490
|
+
}
|
|
491
|
+
//#endregion
|
|
492
|
+
//#region src/lease/application/from-result.ts
|
|
493
|
+
/** Moves a plain `Result` into the typed error channel, where `catchTag` sees its error. */
|
|
494
|
+
const fromResult = (result) => result.ok ? Effect.succeed(result.value) : Effect.fail(result.error);
|
|
495
|
+
//#endregion
|
|
496
|
+
//#region src/lease/application/lease-manager.ts
|
|
497
|
+
/** CAS attempts within one acquisition before the store counts as busy; each lost race re-reads the record. */
|
|
498
|
+
const MAX_CAS_TURNS = 8;
|
|
499
|
+
/** Validates `config` and builds a manager over the `LeaseStore` and the platform in context. */
|
|
500
|
+
function createLeaseManager(config) {
|
|
501
|
+
return Effect.gen(function* () {
|
|
502
|
+
const problem = configProblem(config);
|
|
503
|
+
if (problem !== void 0) return yield* Effect.fail({
|
|
504
|
+
_tag: "LeaseConfigInvalid",
|
|
505
|
+
message: problem
|
|
506
|
+
});
|
|
507
|
+
return makeManager(config, yield* LeaseStore, yield* PlatformService);
|
|
508
|
+
});
|
|
509
|
+
}
|
|
510
|
+
function configProblem({ ttlMs, heartbeatMs, retryMs }) {
|
|
511
|
+
for (const [name, value] of [
|
|
512
|
+
["ttlMs", ttlMs],
|
|
513
|
+
["heartbeatMs", heartbeatMs],
|
|
514
|
+
["retryMs", retryMs ?? heartbeatMs]
|
|
515
|
+
]) if (!Number.isFinite(value) || value <= 0) return `${name} must be a positive number of milliseconds, got ${value}`;
|
|
516
|
+
if (heartbeatMs * 2 > ttlMs) return `heartbeatMs × 2 must not exceed ttlMs (${heartbeatMs} × 2 > ${ttlMs})`;
|
|
517
|
+
}
|
|
518
|
+
function makeManager(config, store, platform) {
|
|
519
|
+
const observations = /* @__PURE__ */ new Map();
|
|
520
|
+
const now = () => platform.clock.monotonic();
|
|
521
|
+
const self = () => {
|
|
522
|
+
const { host, bootId, pid, startTime } = platform.process.self;
|
|
523
|
+
return {
|
|
524
|
+
host,
|
|
525
|
+
bootId,
|
|
526
|
+
pid,
|
|
527
|
+
startTime
|
|
528
|
+
};
|
|
529
|
+
};
|
|
530
|
+
/** A holder that cannot be looked up (the platform failed to identify the pid) is judged by its TTL alone. */
|
|
531
|
+
const judge = (holder, observer) => Effect.try({
|
|
532
|
+
try: () => holderLiveness(holder, observer, platform.process.identify(holder.pid)),
|
|
533
|
+
catch: (cause) => cause
|
|
534
|
+
}).pipe(Effect.orElseSucceed(() => "unknown"));
|
|
535
|
+
/** One acquisition attempt: reads the record, applies the Lease rules, and writes over exactly that revision. */
|
|
536
|
+
const take = (key, holderId) => Effect.gen(function* () {
|
|
537
|
+
for (let turn = 0; turn < MAX_CAS_TURNS; turn += 1) {
|
|
538
|
+
const record = yield* store.read(key);
|
|
539
|
+
const claim = {
|
|
540
|
+
key,
|
|
541
|
+
holder: self(),
|
|
542
|
+
holderId,
|
|
543
|
+
now: platform.clock.now()
|
|
544
|
+
};
|
|
545
|
+
let transition;
|
|
546
|
+
if (record === void 0) transition = Lease.create(claim);
|
|
547
|
+
else {
|
|
548
|
+
const lease = yield* fromResult(Lease.restore(record)).pipe(Effect.mapError((invalid) => storeFailure(key, "invalid-record", invalid.message)));
|
|
549
|
+
const at = now();
|
|
550
|
+
const observation = observe(observations.get(key), record, at);
|
|
551
|
+
observations.set(key, observation);
|
|
552
|
+
const fresh = isFresh(record, observation, at, config.ttlMs);
|
|
553
|
+
const { holder } = record;
|
|
554
|
+
const liveness = fresh && holder !== null ? yield* judge(holder, claim.holder) : "unknown";
|
|
555
|
+
transition = yield* fromResult(lease.acquire(claim, {
|
|
556
|
+
fresh,
|
|
557
|
+
liveness
|
|
558
|
+
}));
|
|
559
|
+
}
|
|
560
|
+
const next = transition.state.toSnapshot();
|
|
561
|
+
if (yield* store.compareAndSet(key, record?.revision, next)) {
|
|
562
|
+
observations.delete(key);
|
|
563
|
+
return next;
|
|
564
|
+
}
|
|
565
|
+
}
|
|
566
|
+
return yield* Effect.fail(storeFailure(key, "busy", "the lease record kept changing during acquisition"));
|
|
567
|
+
});
|
|
568
|
+
const lostBy = (key, holding) => Effect.gen(function* () {
|
|
569
|
+
const read = yield* Effect.exit(store.read(key));
|
|
570
|
+
const record = Exit.isSuccess(read) ? read.value : void 0;
|
|
571
|
+
const reason = !Exit.isSuccess(read) ? "taken-over" : record === void 0 ? "missing" : record.holder === null ? "released" : "taken-over";
|
|
572
|
+
return {
|
|
573
|
+
_tag: "LeaseLost",
|
|
574
|
+
key,
|
|
575
|
+
generation: holding.generation,
|
|
576
|
+
reason
|
|
577
|
+
};
|
|
578
|
+
});
|
|
579
|
+
/**
|
|
580
|
+
* Renews every `heartbeatMs`. A refused write means another writer moved the record, so the lease is lost; a store
|
|
581
|
+
* failure is retried until no renewal has been confirmed for a TTL, after which an observer may take it over. The
|
|
582
|
+
* write stays interruptible while it waits for the store; `release` re-reads the record, so a renewal interrupted
|
|
583
|
+
* between its write and `Ref.set` cannot keep the tombstone from being written.
|
|
584
|
+
*/
|
|
585
|
+
const heartbeat = (key, holding, ref) => Effect.gen(function* () {
|
|
586
|
+
let confirmedAt = now();
|
|
587
|
+
for (;;) {
|
|
588
|
+
yield* Effect.sleep(config.heartbeatMs);
|
|
589
|
+
const current = yield* Ref.get(ref);
|
|
590
|
+
const restored = Lease.restore(current);
|
|
591
|
+
const transition = restored.ok ? restored.value.renew(holding, platform.clock.now()) : void 0;
|
|
592
|
+
if (transition?.ok !== true) return yield* Effect.fail(yield* lostBy(key, holding));
|
|
593
|
+
const next = transition.value.state.toSnapshot();
|
|
594
|
+
const written = yield* Effect.exit(store.compareAndSet(key, current.revision, next).pipe(Effect.tap((done) => done ? Ref.set(ref, next) : Effect.void)));
|
|
595
|
+
if (Exit.isSuccess(written)) {
|
|
596
|
+
if (!written.value) return yield* Effect.fail(yield* lostBy(key, holding));
|
|
597
|
+
confirmedAt = now();
|
|
598
|
+
} else if (now() - confirmedAt >= config.ttlMs) return yield* Effect.fail({
|
|
599
|
+
_tag: "LeaseLost",
|
|
600
|
+
key,
|
|
601
|
+
generation: holding.generation,
|
|
602
|
+
reason: "expired"
|
|
603
|
+
});
|
|
604
|
+
}
|
|
605
|
+
});
|
|
606
|
+
/**
|
|
607
|
+
* Writes the tombstone over the record as read now, if it is still this acquisition's. A failure leaves the lease
|
|
608
|
+
* to expire after the TTL; a finalizer cannot fail.
|
|
609
|
+
*/
|
|
610
|
+
const release = (key, holding) => Effect.gen(function* () {
|
|
611
|
+
const current = yield* store.read(key);
|
|
612
|
+
const restored = current === void 0 ? void 0 : Lease.restore(current);
|
|
613
|
+
const transition = restored?.ok === true ? restored.value.release(holding, platform.clock.now()) : void 0;
|
|
614
|
+
if (current !== void 0 && transition?.ok === true) yield* store.compareAndSet(key, current.revision, transition.value.state.toSnapshot());
|
|
615
|
+
}).pipe(Effect.ignore);
|
|
616
|
+
const handle = (key, holding, lost) => {
|
|
617
|
+
const token = {
|
|
618
|
+
key,
|
|
619
|
+
generation: holding.generation
|
|
620
|
+
};
|
|
621
|
+
return {
|
|
622
|
+
key,
|
|
623
|
+
token,
|
|
624
|
+
lost: Deferred.await(lost),
|
|
625
|
+
runFenced: (work) => Effect.scoped(Effect.gen(function* () {
|
|
626
|
+
if (yield* Deferred.isDone(lost)) return yield* Deferred.await(lost);
|
|
627
|
+
yield* store.fence(key).pipe(Effect.raceFirst(Deferred.await(lost)));
|
|
628
|
+
const record = yield* store.read(key);
|
|
629
|
+
if (record?.holderId !== holding.holderId || record.generation !== holding.generation) {
|
|
630
|
+
const current = record?.generation;
|
|
631
|
+
return yield* Effect.fail({
|
|
632
|
+
_tag: "FenceRejected",
|
|
633
|
+
key,
|
|
634
|
+
generation: token.generation,
|
|
635
|
+
current
|
|
636
|
+
});
|
|
637
|
+
}
|
|
638
|
+
return yield* Effect.raceFirst(work(token), Deferred.await(lost));
|
|
639
|
+
}))
|
|
640
|
+
};
|
|
641
|
+
};
|
|
642
|
+
return {
|
|
643
|
+
acquire: (key, options = {}) => {
|
|
644
|
+
if (key === "") return Effect.die(new AgentKitError("invalid-lease-key", "A lease key must not be empty"));
|
|
645
|
+
const holderId = crypto.randomUUID();
|
|
646
|
+
const attempt = Effect.acquireRelease(take(key, holderId).pipe(Effect.flatMap((snapshot) => Ref.make(snapshot))), (ref) => Effect.flatMap(Ref.get(ref), ({ generation }) => release(key, {
|
|
647
|
+
holderId,
|
|
648
|
+
generation
|
|
649
|
+
})));
|
|
650
|
+
const acquired = options.wait === true ? attempt.pipe(Effect.retry({
|
|
651
|
+
while: (error) => error._tag === "LeaseHeld" || error.reason === "busy",
|
|
652
|
+
schedule: Schedule.spaced(config.retryMs ?? config.heartbeatMs)
|
|
653
|
+
})) : attempt;
|
|
654
|
+
return Effect.gen(function* () {
|
|
655
|
+
const ref = yield* acquired;
|
|
656
|
+
const { generation } = yield* Ref.get(ref);
|
|
657
|
+
const holding = {
|
|
658
|
+
holderId,
|
|
659
|
+
generation
|
|
660
|
+
};
|
|
661
|
+
const lost = yield* Deferred.make();
|
|
662
|
+
yield* Effect.forkScoped(heartbeat(key, holding, ref).pipe(Effect.catchCause((cause) => Cause.hasInterruptsOnly(cause) ? Effect.failCause(cause) : Deferred.failCause(lost, cause))));
|
|
663
|
+
return handle(key, holding, lost);
|
|
664
|
+
});
|
|
665
|
+
},
|
|
666
|
+
read: (key) => store.read(key)
|
|
667
|
+
};
|
|
668
|
+
}
|
|
669
|
+
//#endregion
|
|
670
|
+
export { LeaseStore, canAcquire, checkFence, createLeaseManager, fileLeaseStore, holderLiveness, isFresh, memoryLeaseStore, nextFencingToken, sqliteLeaseStore };
|