@rivus/agent-kit-collab 0.3.0 → 0.4.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/README.md +21 -14
- package/dist/{acquire-process-lock-A0oYDN9m.js → acquire-process-lock-BCvK1ehZ.js} +6 -5
- package/dist/lanes.d.ts +8 -6
- package/dist/lanes.js +25 -7
- package/dist/lease.d.ts +72 -54
- package/dist/lease.js +152 -111
- package/dist/process-lock.d.ts +2 -2
- package/dist/process-lock.js +1 -1
- package/package.json +4 -4
package/README.md
CHANGED
|
@@ -31,11 +31,11 @@ until removed).
|
|
|
31
31
|
|
|
32
32
|
## Entries
|
|
33
33
|
|
|
34
|
-
| Entry | Main exports
|
|
35
|
-
| -------------------------------------- |
|
|
36
|
-
| `@rivus/agent-kit-collab/process-lock` | `acquireProcessLock`, `ProcessLock`, `ProcessLockHeld`
|
|
37
|
-
| `@rivus/agent-kit-collab/lease` | `createLeaseManager`, `
|
|
38
|
-
| `@rivus/agent-kit-collab/lanes` | `createLanes`
|
|
34
|
+
| Entry | Main exports | Kind |
|
|
35
|
+
| -------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------- |
|
|
36
|
+
| `@rivus/agent-kit-collab/process-lock` | `acquireProcessLock`, `ProcessLock`, `ProcessLockHeld` | plain TS |
|
|
37
|
+
| `@rivus/agent-kit-collab/lease` | `createLeaseManager`, `LeaseRepository`, `sqliteLeaseRepository`, `fileLeaseRepository`, `memoryLeaseRepository`; rules `isFresh`, `canAcquire`, `nextFencingToken`, `checkFence`, `holderLiveness` | Effect |
|
|
38
|
+
| `@rivus/agent-kit-collab/lanes` | `createLanes` | Effect |
|
|
39
39
|
|
|
40
40
|
### Process lock
|
|
41
41
|
|
|
@@ -62,10 +62,12 @@ instead of getting `ProcessLockHeld` at once.
|
|
|
62
62
|
|
|
63
63
|
```ts
|
|
64
64
|
import { NodePlatformLive } from "@rivus/agent-kit/node/effect";
|
|
65
|
-
import { createLeaseManager,
|
|
65
|
+
import { createLeaseManager, sqliteLeaseRepository } from "@rivus/agent-kit-collab/lease";
|
|
66
66
|
import { Effect, Layer } from "effect";
|
|
67
67
|
|
|
68
|
-
const LeaseLive =
|
|
68
|
+
const LeaseLive = sqliteLeaseRepository({ path: "/var/lib/my-app/leases.db" }).pipe(
|
|
69
|
+
Layer.provideMerge(NodePlatformLive)
|
|
70
|
+
);
|
|
69
71
|
|
|
70
72
|
const program = Effect.scoped(
|
|
71
73
|
Effect.gen(function* () {
|
|
@@ -89,19 +91,24 @@ await Effect.runPromiseExit(program.pipe(Effect.provide(LeaseLive)));
|
|
|
89
91
|
- A holder on the same host whose process is gone (or whose pid now belongs to another process) is taken over at
|
|
90
92
|
once. A holder that is alive but stopped renewing is taken over after the TTL, measured by the observer's own
|
|
91
93
|
monotonic clock from the moment it first saw the current record.
|
|
92
|
-
- `runFenced` holds the
|
|
94
|
+
- `runFenced` holds the repository's per-key fence and re-reads the record before the work starts; waiting for the fence
|
|
93
95
|
stops when the lease is lost. The fence is released when the work's fiber ends, so a successor's fenced work starts
|
|
94
96
|
after that. An interrupted fiber ends at once, but a Promise it started keeps running: put a write that cannot be
|
|
95
97
|
cancelled in `Effect.uninterruptible` (the fence then waits for it to settle), or pass it the AbortSignal that
|
|
96
98
|
`Effect.tryPromise` provides and let it settle only once the write has stopped.
|
|
97
99
|
- Guard plus re-read is as strong as a check by the protected resource itself only when every writer goes through
|
|
98
|
-
the same
|
|
100
|
+
the same repository on the same machine; a resource that can compare atomically should keep the highest token it has
|
|
99
101
|
seen and call `checkFence`.
|
|
100
|
-
- `
|
|
101
|
-
without SQLite: one JSON file per key, written under a per-key lock file. `
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
and `
|
|
102
|
+
- `sqliteLeaseRepository` compares revisions inside `BEGIN IMMEDIATE` transactions. `fileLeaseRepository` is the
|
|
103
|
+
fallback without SQLite: one JSON file per key, written under a per-key lock file. `memoryLeaseRepository` serves
|
|
104
|
+
one process.
|
|
105
|
+
- The repository port follows the kit's repository shape: `load(key)` is the stored `LeaseSnapshot` or `undefined`,
|
|
106
|
+
and `save(key, snapshot, expectedRevision)` writes only over that revision and fails with a `RevisionConflict`
|
|
107
|
+
when another writer moved the record first.
|
|
108
|
+
|
|
109
|
+
Expected failures are typed values with a `_tag`: `LeaseHeld`, `LeaseLost`, `FenceRejected`, `LeaseConfigInvalid`,
|
|
110
|
+
`LeaseRepositoryFailure` and the repository's `RevisionConflict`. The kit runs no Effect itself; run the program at
|
|
111
|
+
your application's assembly root.
|
|
105
112
|
|
|
106
113
|
### Lanes
|
|
107
114
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import * as z from "zod/mini";
|
|
2
2
|
import { err, ok } from "@rivus/agent-kit/catalog";
|
|
3
|
-
//#region src/process-lock/application/holder.ts
|
|
3
|
+
//#region src/process-lock/application/services/holder.ts
|
|
4
4
|
const stampSchema = z.object({
|
|
5
5
|
host: z.string(),
|
|
6
6
|
bootId: z.string(),
|
|
@@ -78,7 +78,7 @@ function holderLiveness(holder, observer, current) {
|
|
|
78
78
|
return current.bootId === holder.bootId && current.startTime === holder.startTime ? "alive" : "dead";
|
|
79
79
|
}
|
|
80
80
|
//#endregion
|
|
81
|
-
//#region src/process-lock/application/file-lock.ts
|
|
81
|
+
//#region src/process-lock/application/services/file-lock.ts
|
|
82
82
|
/**
|
|
83
83
|
* How long a lock file may stay empty or unreadable before it counts as abandoned. A holder writes its stamp right
|
|
84
84
|
* after creating the file, so only a process that died in between leaves it empty. A holder stalled for longer than
|
|
@@ -165,7 +165,7 @@ async function releaseFileLock(platform, path, nonce) {
|
|
|
165
165
|
if ((await readStamp(platform, path))?.stamp?.nonce === nonce) await platform.fs.remove(path);
|
|
166
166
|
}
|
|
167
167
|
//#endregion
|
|
168
|
-
//#region src/process-lock/application/sqlite-lock.ts
|
|
168
|
+
//#region src/process-lock/application/services/sqlite-lock.ts
|
|
169
169
|
/** SQLITE_BUSY and SQLITE_LOCKED, the primary codes of their extended codes: another connection holds the lock. */
|
|
170
170
|
const BUSY_CODES = /* @__PURE__ */ new Set([5, 6]);
|
|
171
171
|
/**
|
|
@@ -200,7 +200,7 @@ function isSqliteBusy(error) {
|
|
|
200
200
|
return typeof errcode === "number" && BUSY_CODES.has(errcode & 255) || message === "database is locked";
|
|
201
201
|
}
|
|
202
202
|
//#endregion
|
|
203
|
-
//#region src/process-lock/application/acquire-process-lock.ts
|
|
203
|
+
//#region src/process-lock/application/use-cases/acquire-process-lock.ts
|
|
204
204
|
/**
|
|
205
205
|
* A single-instance lock on `path`, which must be in an existing local directory (not NFS). With `platform.sqlite`,
|
|
206
206
|
* `path` is a SQLite database held with `locking_mode=EXCLUSIVE`: the kernel releases it when the process exits or
|
|
@@ -296,11 +296,12 @@ function once(release) {
|
|
|
296
296
|
}
|
|
297
297
|
function delay(ms, signal) {
|
|
298
298
|
return new Promise((resolve, reject) => {
|
|
299
|
+
let timer;
|
|
299
300
|
const onAbort = () => {
|
|
300
301
|
clearTimeout(timer);
|
|
301
302
|
reject(signal?.reason);
|
|
302
303
|
};
|
|
303
|
-
|
|
304
|
+
timer = setTimeout(() => {
|
|
304
305
|
signal?.removeEventListener("abort", onAbort);
|
|
305
306
|
resolve();
|
|
306
307
|
}, ms);
|
package/dist/lanes.d.ts
CHANGED
|
@@ -17,6 +17,13 @@ interface LanesConfigInvalid {
|
|
|
17
17
|
readonly message: string;
|
|
18
18
|
}
|
|
19
19
|
//#endregion
|
|
20
|
+
//#region src/lanes/domain/lane/policies/admission.d.ts
|
|
21
|
+
/**
|
|
22
|
+
* `started`: an activation started; `queued`: the lane waits for a free slot; `coalesced`: an activation that has not
|
|
23
|
+
* started yet serves this wake (the queued one, or the one that follows the running one).
|
|
24
|
+
*/
|
|
25
|
+
type WakeResult = "started" | "queued" | "coalesced";
|
|
26
|
+
//#endregion
|
|
20
27
|
//#region src/lanes/domain/lane/value-objects/lane-snapshot.d.ts
|
|
21
28
|
/**
|
|
22
29
|
* `idle`: nothing runs and nothing is owed. `queued`: woken while every slot was taken, waiting for one. `running`: one
|
|
@@ -33,7 +40,7 @@ interface LaneSnapshot {
|
|
|
33
40
|
readonly pending: boolean;
|
|
34
41
|
}
|
|
35
42
|
//#endregion
|
|
36
|
-
//#region src/lanes/application/create-lanes.d.ts
|
|
43
|
+
//#region src/lanes/application/use-cases/create-lanes.d.ts
|
|
37
44
|
interface LanesConfig<E, R> {
|
|
38
45
|
/** Activations that may run at once across all keys; a positive integer. */
|
|
39
46
|
readonly maxConcurrent: number;
|
|
@@ -74,11 +81,6 @@ type ActivationExit<E> = {
|
|
|
74
81
|
readonly key: string;
|
|
75
82
|
readonly reason: ActivationInterruptReason;
|
|
76
83
|
};
|
|
77
|
-
/**
|
|
78
|
-
* `started`: an activation started; `queued`: the lane waits for a free slot; `coalesced`: an activation that has not
|
|
79
|
-
* started yet serves this wake (the queued one, or the one that follows the running one).
|
|
80
|
-
*/
|
|
81
|
-
type WakeResult = "started" | "queued" | "coalesced";
|
|
82
84
|
/** `close` ran, or the Scope that created the lanes closed. */
|
|
83
85
|
interface LanesClosed {
|
|
84
86
|
readonly _tag: "LanesClosed";
|
package/dist/lanes.js
CHANGED
|
@@ -12,8 +12,21 @@ function admit(load, limits) {
|
|
|
12
12
|
if (load.running < limits.maxConcurrent && load.queued === 0) return "start";
|
|
13
13
|
return load.queued < limits.maxQueued ? "queue" : "full";
|
|
14
14
|
}
|
|
15
|
+
/**
|
|
16
|
+
* Which queued lane may start now, or `undefined` when none may: the lanes are closed, the queue is empty, or every
|
|
17
|
+
* slot is running. The queue is FIFO: a free slot goes to the lane that has waited longest, and the slot a finishing
|
|
18
|
+
* activation frees goes to the head of the queue first.
|
|
19
|
+
*/
|
|
20
|
+
function nextToStart(queue, load, limits, closed) {
|
|
21
|
+
if (closed || load.running >= limits.maxConcurrent) return;
|
|
22
|
+
return queue[0];
|
|
23
|
+
}
|
|
24
|
+
/** Reads how a wake ended from the events its transition produced. */
|
|
25
|
+
function wakeResult(events) {
|
|
26
|
+
return events.some(({ _tag }) => _tag === "ActivationStarted") ? "started" : events.some(({ _tag }) => _tag === "LaneQueued") ? "queued" : "coalesced";
|
|
27
|
+
}
|
|
15
28
|
//#endregion
|
|
16
|
-
//#region src/lanes/domain/lane/
|
|
29
|
+
//#region src/lanes/domain/lane/aggregates/lane.ts
|
|
17
30
|
/**
|
|
18
31
|
* One key's lane, held in memory only. At most one activation runs at a time, and every wake that arrives before an
|
|
19
32
|
* activation starts is served by that activation: wakes coalesce instead of queueing one run each. Only an idle lane
|
|
@@ -168,11 +181,11 @@ function laneLimits(input) {
|
|
|
168
181
|
});
|
|
169
182
|
}
|
|
170
183
|
//#endregion
|
|
171
|
-
//#region src/lanes/application/from-result.ts
|
|
184
|
+
//#region src/lanes/application/services/from-result.ts
|
|
172
185
|
/** Moves a plain `Result` into the typed error channel, where `catchTag` sees its error. */
|
|
173
186
|
const fromResult = (result) => result.ok ? Effect.succeed(result.value) : Effect.fail(result.error);
|
|
174
187
|
//#endregion
|
|
175
|
-
//#region src/lanes/application/create-lanes.ts
|
|
188
|
+
//#region src/lanes/application/use-cases/create-lanes.ts
|
|
176
189
|
/** An end the activation did not reach by itself: an interruption, by the lanes or its own. */
|
|
177
190
|
const cutShort = (exit) => Exit.isFailure(exit) && Cause.hasInterruptsOnly(exit.cause);
|
|
178
191
|
/**
|
|
@@ -220,10 +233,14 @@ function createLanes(config) {
|
|
|
220
233
|
}
|
|
221
234
|
return started;
|
|
222
235
|
};
|
|
223
|
-
/**
|
|
236
|
+
/** Hands the free slots to the lanes that `nextToStart` picks: the ones that have waited longest. */
|
|
224
237
|
const pump = () => {
|
|
225
238
|
const started = [];
|
|
226
|
-
|
|
239
|
+
const load = () => ({
|
|
240
|
+
running: activations.size,
|
|
241
|
+
queued: queue.length
|
|
242
|
+
});
|
|
243
|
+
for (let key = nextToStart(queue, load(), limits, closed); key !== void 0; key = nextToStart(queue, load(), limits, closed)) started.push(...apply(laneOf(key).start()));
|
|
227
244
|
return started;
|
|
228
245
|
};
|
|
229
246
|
const interrupted = (key, reason) => ({
|
|
@@ -276,8 +293,9 @@ function createLanes(config) {
|
|
|
276
293
|
return fork(started);
|
|
277
294
|
});
|
|
278
295
|
}).pipe(Effect.provideContext(context));
|
|
279
|
-
|
|
280
|
-
|
|
296
|
+
function fork(started) {
|
|
297
|
+
return Effect.forEach(started, (activation) => Effect.forkDetach(run(activation), { uninterruptible: true }), { discard: true });
|
|
298
|
+
}
|
|
281
299
|
const close = Effect.suspend(() => {
|
|
282
300
|
if (!closed) {
|
|
283
301
|
closed = true;
|
package/dist/lease.d.ts
CHANGED
|
@@ -80,6 +80,13 @@ interface FenceRejected {
|
|
|
80
80
|
readonly current: number | undefined;
|
|
81
81
|
}
|
|
82
82
|
//#endregion
|
|
83
|
+
//#region src/lease/domain/lease/errors/lease-config-invalid.d.ts
|
|
84
|
+
/** A lease duration is out of range, or the heartbeat does not fit twice into the TTL. */
|
|
85
|
+
interface LeaseConfigInvalid {
|
|
86
|
+
readonly _tag: "LeaseConfigInvalid";
|
|
87
|
+
readonly message: string;
|
|
88
|
+
}
|
|
89
|
+
//#endregion
|
|
83
90
|
//#region src/lease/domain/lease/value-objects/fencing-token.d.ts
|
|
84
91
|
/**
|
|
85
92
|
* Proof of holding a lease, passed with every fenced write. A resource that can compare atomically keeps the highest
|
|
@@ -131,14 +138,26 @@ export declare function isFresh(lease: LeaseSnapshot, observation: LeaseObservat
|
|
|
131
138
|
export declare function holderLiveness(holder: Holder, observer: Holder, current: Holder | undefined): HolderLiveness;
|
|
132
139
|
//#endregion
|
|
133
140
|
//#region src/lease/application/ports.d.ts
|
|
134
|
-
/**
|
|
135
|
-
|
|
136
|
-
|
|
141
|
+
/**
|
|
142
|
+
* Another writer wrote over the revision a `save` was conditioned on, so it wrote nothing. Declared here, next to
|
|
143
|
+
* the port; other contexts declare their own.
|
|
144
|
+
*/
|
|
145
|
+
interface RevisionConflict {
|
|
146
|
+
readonly _tag: "RevisionConflict";
|
|
147
|
+
readonly key: string;
|
|
148
|
+
/** The revision the save was conditioned on; `undefined` when it required the record not to exist. */
|
|
149
|
+
readonly expectedRevision: number | undefined;
|
|
150
|
+
/** The revision the stored record had when the save compared it; `undefined` when there was none. */
|
|
151
|
+
readonly storedRevision: number | undefined;
|
|
152
|
+
}
|
|
153
|
+
/** The repository could not read or write a record; the lease's state is unknown, not changed. */
|
|
154
|
+
interface LeaseRepositoryFailure {
|
|
155
|
+
readonly _tag: "LeaseRepositoryFailure";
|
|
137
156
|
readonly key: string;
|
|
138
157
|
/**
|
|
139
|
-
* `busy`: other writers kept the
|
|
140
|
-
* invariants and is left as it is; `unsupported-schema`: the
|
|
141
|
-
* the platform lacks what the
|
|
158
|
+
* `busy`: other writers kept the repository locked; `invalid-record`: a stored record is unreadable or breaks the
|
|
159
|
+
* invariants and is left as it is; `unsupported-schema`: the repository was written by a newer version;
|
|
160
|
+
* `unavailable`: the platform lacks what the repository needs; `io`: any other failure.
|
|
142
161
|
*/
|
|
143
162
|
readonly reason: "busy" | "io" | "invalid-record" | "unsupported-schema" | "unavailable";
|
|
144
163
|
readonly message: string;
|
|
@@ -147,8 +166,10 @@ interface LeaseStoreFailure {
|
|
|
147
166
|
/**
|
|
148
167
|
* Where lease records live. Calling convention for every implementation:
|
|
149
168
|
*
|
|
150
|
-
* - `
|
|
151
|
-
*
|
|
169
|
+
* - `load(key)` is the stored record, or `undefined` when there is none.
|
|
170
|
+
* - `save(key, snapshot, expectedRevision)` writes atomically across every process that uses the same repository
|
|
171
|
+
* location: it writes only when the stored record's revision equals `expectedRevision` (`undefined`: no record),
|
|
172
|
+
* and fails with a `RevisionConflict` instead of writing when another writer moved the record first.
|
|
152
173
|
* - Records are never deleted. A release writes a tombstone, so a key's generation never goes back.
|
|
153
174
|
* - `fence(key)` holds one guard per key across those processes until its Scope closes; waiting for it can be
|
|
154
175
|
* interrupted, and a guard whose holder process exited is freed without waiting for a TTL.
|
|
@@ -156,58 +177,59 @@ interface LeaseStoreFailure {
|
|
|
156
177
|
*
|
|
157
178
|
* The lease's `runFenced` takes the guard and re-reads the holder before the work starts. That is equivalent to the
|
|
158
179
|
* protected resource checking the fencing token itself only when every writer of the resource goes through the same
|
|
159
|
-
*
|
|
180
|
+
* repository on the same machine; a resource that can compare atomically should also run `checkFence`.
|
|
160
181
|
*/
|
|
161
|
-
interface
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
fence(key: string): Effect.Effect<void,
|
|
182
|
+
interface LeaseRepositoryShape {
|
|
183
|
+
load(key: string): Effect.Effect<LeaseSnapshot | undefined, LeaseRepositoryFailure>;
|
|
184
|
+
save(key: string, snapshot: LeaseSnapshot, expectedRevision: number | undefined): Effect.Effect<void, LeaseRepositoryFailure | RevisionConflict>;
|
|
185
|
+
fence(key: string): Effect.Effect<void, LeaseRepositoryFailure, Scope.Scope>;
|
|
165
186
|
}
|
|
166
|
-
declare const KEY = "@rivus/agent-kit-collab/lease/
|
|
167
|
-
declare const
|
|
168
|
-
/** The lease
|
|
169
|
-
export declare class
|
|
187
|
+
declare const KEY = "@rivus/agent-kit-collab/lease/LeaseRepository/v1";
|
|
188
|
+
declare const LeaseRepositoryBase: Context.ServiceClass<LeaseRepository, typeof KEY, LeaseRepositoryShape>;
|
|
189
|
+
/** The lease repository port; `sqliteLeaseRepository`, `fileLeaseRepository` and `memoryLeaseRepository` provide it. */
|
|
190
|
+
export declare class LeaseRepository extends LeaseRepositoryBase {}
|
|
170
191
|
//#endregion
|
|
171
|
-
//#region src/lease/
|
|
172
|
-
interface
|
|
192
|
+
//#region src/lease/infra/repository/file-lease-repository.d.ts
|
|
193
|
+
interface FileLeaseRepositoryOptions {
|
|
173
194
|
/** An existing local directory that holds one record file and its lock files per key. */
|
|
174
195
|
readonly dir: string;
|
|
175
196
|
}
|
|
176
197
|
/**
|
|
177
|
-
* The fallback
|
|
178
|
-
* `writeAtomic`. Every `
|
|
179
|
-
*
|
|
180
|
-
*
|
|
181
|
-
*
|
|
182
|
-
*
|
|
198
|
+
* The fallback repository for platforms without SQLite: one JSON record per key, `<key>.lease.json`, replaced with
|
|
199
|
+
* `writeAtomic`. Every `save` holds the key's guard (`<key>.lease.guard`) while it reads, compares and writes, and
|
|
200
|
+
* reports `busy` when the guard stays held longer than a second; `fence` holds `<key>.lease.fence`. Both are process
|
|
201
|
+
* locks, so without SQLite they are lock files whose dead holders are reclaimed one reclaimer at a time, with the
|
|
202
|
+
* gaps `acquireProcessLock` documents. A record with an unknown `schemaVersion` or shape is refused and never
|
|
203
|
+
* overwritten.
|
|
183
204
|
*/
|
|
184
|
-
export declare function
|
|
205
|
+
export declare function fileLeaseRepository(options: FileLeaseRepositoryOptions): Layer.Layer<LeaseRepository, never, PlatformService>;
|
|
185
206
|
//#endregion
|
|
186
|
-
//#region src/lease/
|
|
207
|
+
//#region src/lease/infra/repository/memory-lease-repository.d.ts
|
|
187
208
|
/**
|
|
188
|
-
* A
|
|
189
|
-
* starts empty.
|
|
209
|
+
* A repository for one process, such as tests or a host whose leases never cross a process boundary. Each Layer
|
|
210
|
+
* build starts empty.
|
|
190
211
|
*/
|
|
191
|
-
export declare function
|
|
212
|
+
export declare function memoryLeaseRepository(): Layer.Layer<LeaseRepository>;
|
|
192
213
|
//#endregion
|
|
193
|
-
//#region src/lease/
|
|
194
|
-
interface
|
|
214
|
+
//#region src/lease/infra/repository/sqlite-lease-repository.d.ts
|
|
215
|
+
interface SqliteLeaseRepositoryOptions {
|
|
195
216
|
/**
|
|
196
217
|
* The database file, in an existing local directory. The per-key fence locks of `runFenced` live next to it as
|
|
197
|
-
* `<path>.<key>.fence` (plus a `.holder` file each), so give the
|
|
218
|
+
* `<path>.<key>.fence` (plus a `.holder` file each), so give the repository a directory of its own or a distinct
|
|
219
|
+
* name.
|
|
198
220
|
*/
|
|
199
221
|
readonly path: string;
|
|
200
222
|
}
|
|
201
223
|
/**
|
|
202
|
-
* The lease
|
|
203
|
-
*
|
|
204
|
-
*
|
|
205
|
-
*
|
|
206
|
-
*
|
|
224
|
+
* The lease repository for one machine: one row per key in a SQLite database. `save` runs in a `BEGIN IMMEDIATE`
|
|
225
|
+
* transaction, which takes SQLite's write lock (an fcntl lock) before reading the revision, so processes sharing the
|
|
226
|
+
* file compare and write atomically. The lock is released when its process exits, so neither a crashed writer nor a
|
|
227
|
+
* reused pid can leave the repository stuck. Fails to build with `unavailable` when the platform has no SQLite, and
|
|
228
|
+
* with `unsupported-schema` for a database written by a newer version.
|
|
207
229
|
*/
|
|
208
|
-
export declare function
|
|
230
|
+
export declare function sqliteLeaseRepository(options: SqliteLeaseRepositoryOptions): Layer.Layer<LeaseRepository, LeaseRepositoryFailure, PlatformService>;
|
|
209
231
|
//#endregion
|
|
210
|
-
//#region src/lease/application/lease-manager.d.ts
|
|
232
|
+
//#region src/lease/application/use-cases/lease-manager.d.ts
|
|
211
233
|
interface LeaseConfig {
|
|
212
234
|
/** How long a lease stays valid without a renewal, as each observer's monotonic clock measures it. */
|
|
213
235
|
readonly ttlMs: number;
|
|
@@ -216,15 +238,11 @@ interface LeaseConfig {
|
|
|
216
238
|
/** How often `acquire({ wait: true })` tries again; `heartbeatMs` by default. */
|
|
217
239
|
readonly retryMs?: number;
|
|
218
240
|
}
|
|
219
|
-
interface LeaseConfigInvalid {
|
|
220
|
-
readonly _tag: "LeaseConfigInvalid";
|
|
221
|
-
readonly message: string;
|
|
222
|
-
}
|
|
223
241
|
interface AcquireOptions {
|
|
224
242
|
/**
|
|
225
|
-
* Keep trying every `retryMs` while the lease is held or the
|
|
226
|
-
* failing with `LeaseHeld` or a `busy` `
|
|
227
|
-
* failure, or when the fiber is interrupted.
|
|
243
|
+
* Keep trying every `retryMs` while the lease is held or the repository is busy (a guard that stays locked),
|
|
244
|
+
* instead of failing with `LeaseHeld` or a `busy` `LeaseRepositoryFailure`. The wait ends when the lease is taken,
|
|
245
|
+
* on another repository failure, or when the fiber is interrupted.
|
|
228
246
|
*/
|
|
229
247
|
readonly wait?: boolean;
|
|
230
248
|
}
|
|
@@ -238,7 +256,7 @@ interface LeaseHandle {
|
|
|
238
256
|
*/
|
|
239
257
|
readonly lost: Effect.Effect<never, LeaseLost>;
|
|
240
258
|
/**
|
|
241
|
-
* Runs `work` under the
|
|
259
|
+
* Runs `work` under the repository's per-key fence, after re-reading that this acquisition still holds the lease, and
|
|
242
260
|
* passes it the fencing token for the writes it makes. Waiting for the fence stops when the lease is lost. When the
|
|
243
261
|
* lease is lost while `work` runs, `work` is interrupted and the result fails with `LeaseLost`.
|
|
244
262
|
*
|
|
@@ -248,7 +266,7 @@ interface LeaseHandle {
|
|
|
248
266
|
* it the AbortSignal `tryPromise` provides and settle only after the write has stopped. A resource that can compare
|
|
249
267
|
* atomically should also keep the highest token it has seen and refuse older ones with `checkFence`.
|
|
250
268
|
*/
|
|
251
|
-
runFenced<A, E, R>(work: (token: FencingToken) => Effect.Effect<A, E, R>): Effect.Effect<A, E | LeaseLost | FenceRejected |
|
|
269
|
+
runFenced<A, E, R>(work: (token: FencingToken) => Effect.Effect<A, E, R>): Effect.Effect<A, E | LeaseLost | FenceRejected | LeaseRepositoryFailure, Exclude<R, Scope.Scope>>;
|
|
252
270
|
}
|
|
253
271
|
interface LeaseManager {
|
|
254
272
|
/**
|
|
@@ -256,10 +274,10 @@ interface LeaseManager {
|
|
|
256
274
|
* holder keeps it fresh; a dead holder (same host) is taken over at once, a silent one after the TTL. Throws an
|
|
257
275
|
* `AgentKitError` (as a defect) for an empty key.
|
|
258
276
|
*/
|
|
259
|
-
acquire(key: string, options?: AcquireOptions): Effect.Effect<LeaseHandle, LeaseHeld |
|
|
260
|
-
read(key: string): Effect.Effect<LeaseSnapshot | undefined,
|
|
277
|
+
acquire(key: string, options?: AcquireOptions): Effect.Effect<LeaseHandle, LeaseHeld | LeaseRepositoryFailure, Scope.Scope>;
|
|
278
|
+
read(key: string): Effect.Effect<LeaseSnapshot | undefined, LeaseRepositoryFailure>;
|
|
261
279
|
}
|
|
262
|
-
/** Validates `config` and builds a manager over the `
|
|
263
|
-
export declare function createLeaseManager(config: LeaseConfig): Effect.Effect<LeaseManager, LeaseConfigInvalid,
|
|
280
|
+
/** Validates `config` and builds a manager over the `LeaseRepository` and the platform in context. */
|
|
281
|
+
export declare function createLeaseManager(config: LeaseConfig): Effect.Effect<LeaseManager, LeaseConfigInvalid, LeaseRepository | PlatformService>;
|
|
264
282
|
//#endregion
|
|
265
|
-
export type { AcquireOptions, AcquisitionView, FenceRejected, FencingToken,
|
|
283
|
+
export type { AcquireOptions, AcquisitionView, FenceRejected, FencingToken, FileLeaseRepositoryOptions, Holder, HolderLiveness, LeaseConfig, LeaseConfigInvalid, LeaseHandle, LeaseHeld, LeaseLost, LeaseManager, LeaseObservation, LeaseRepositoryFailure, LeaseRepositoryShape, LeaseSnapshot, RevisionConflict, SqliteLeaseRepositoryOptions };
|
package/dist/lease.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { a as holderLiveness, i as isSqliteBusy, n as backoffMs, o as readText, r as tryProcessLock } from "./acquire-process-lock-
|
|
1
|
+
import { a as holderLiveness, i as isSqliteBusy, n as backoffMs, o as readText, r as tryProcessLock } from "./acquire-process-lock-BCvK1ehZ.js";
|
|
2
2
|
import { PlatformService } from "@rivus/agent-kit/platform/effect";
|
|
3
3
|
import * as Effect from "effect/Effect";
|
|
4
4
|
import * as Layer from "effect/Layer";
|
|
@@ -12,27 +12,35 @@ import * as Deferred from "effect/Deferred";
|
|
|
12
12
|
import * as Exit from "effect/Exit";
|
|
13
13
|
import * as Ref from "effect/Ref";
|
|
14
14
|
//#region src/lease/application/ports.ts
|
|
15
|
-
const
|
|
16
|
-
/** The lease
|
|
17
|
-
var
|
|
15
|
+
const LeaseRepositoryBase = Context.Service()("@rivus/agent-kit-collab/lease/LeaseRepository/v1");
|
|
16
|
+
/** The lease repository port; `sqliteLeaseRepository`, `fileLeaseRepository` and `memoryLeaseRepository` provide it. */
|
|
17
|
+
var LeaseRepository = class extends LeaseRepositoryBase {};
|
|
18
18
|
//#endregion
|
|
19
|
-
//#region src/lease/application/
|
|
20
|
-
function
|
|
19
|
+
//#region src/lease/application/services/repository-failure.ts
|
|
20
|
+
function repositoryFailure(key, reason, message, cause) {
|
|
21
21
|
return cause === void 0 ? {
|
|
22
|
-
_tag: "
|
|
22
|
+
_tag: "LeaseRepositoryFailure",
|
|
23
23
|
key,
|
|
24
24
|
reason,
|
|
25
25
|
message
|
|
26
26
|
} : {
|
|
27
|
-
_tag: "
|
|
27
|
+
_tag: "LeaseRepositoryFailure",
|
|
28
28
|
key,
|
|
29
29
|
reason,
|
|
30
30
|
message,
|
|
31
31
|
cause
|
|
32
32
|
};
|
|
33
33
|
}
|
|
34
|
+
function revisionConflict(key, expectedRevision, storedRevision) {
|
|
35
|
+
return {
|
|
36
|
+
_tag: "RevisionConflict",
|
|
37
|
+
key,
|
|
38
|
+
expectedRevision,
|
|
39
|
+
storedRevision
|
|
40
|
+
};
|
|
41
|
+
}
|
|
34
42
|
//#endregion
|
|
35
|
-
//#region src/lease/
|
|
43
|
+
//#region src/lease/infra/models/key-file-name.ts
|
|
36
44
|
/** File names stay well under the usual 255-byte limit even with a suffix such as `.lease.json.stale`. */
|
|
37
45
|
const MAX_LENGTH = 160;
|
|
38
46
|
/**
|
|
@@ -48,7 +56,7 @@ function keyFileName(key) {
|
|
|
48
56
|
}));
|
|
49
57
|
}
|
|
50
58
|
//#endregion
|
|
51
|
-
//#region src/lease/
|
|
59
|
+
//#region src/lease/infra/models/lease-record-codec.ts
|
|
52
60
|
const holderSchema = z.object({
|
|
53
61
|
host: z.string(),
|
|
54
62
|
bootId: z.string(),
|
|
@@ -121,7 +129,19 @@ function checkFence(lastSeen, token) {
|
|
|
121
129
|
return ok(token);
|
|
122
130
|
}
|
|
123
131
|
//#endregion
|
|
124
|
-
//#region src/lease/domain/lease/
|
|
132
|
+
//#region src/lease/domain/lease/policies/loss.ts
|
|
133
|
+
/**
|
|
134
|
+
* Why an acquisition that could not renew or write no longer holds, judged from the record it sees: the record is
|
|
135
|
+
* gone (`missing`), a tombstone says it was released, or another holder wrote it (`taken-over`). A tombstone is a
|
|
136
|
+
* record with no holder, so the holder field decides, whatever `holderId` says. How a read that failed counts is
|
|
137
|
+
* the caller's error mapping, not a property of a record.
|
|
138
|
+
*/
|
|
139
|
+
function lossReason(record) {
|
|
140
|
+
if (record === void 0) return "missing";
|
|
141
|
+
return record.holder === null ? "released" : "taken-over";
|
|
142
|
+
}
|
|
143
|
+
//#endregion
|
|
144
|
+
//#region src/lease/domain/lease/aggregates/lease.ts
|
|
125
145
|
/**
|
|
126
146
|
* One key's lease. It has one holder at a time; its generation never decreases, release included, because release
|
|
127
147
|
* keeps the record as a tombstone; every write increases its revision. Stores persist the snapshot and compare
|
|
@@ -183,7 +203,7 @@ var Lease = class Lease {
|
|
|
183
203
|
}
|
|
184
204
|
/** A heartbeat: only the holding acquisition renews, and the generation stays. */
|
|
185
205
|
renew(holding, now) {
|
|
186
|
-
const lost = this.
|
|
206
|
+
const lost = this.lossOf(holding);
|
|
187
207
|
if (lost !== void 0) return err(lost);
|
|
188
208
|
const state = new Lease({
|
|
189
209
|
...this.snapshot,
|
|
@@ -203,7 +223,7 @@ var Lease = class Lease {
|
|
|
203
223
|
}
|
|
204
224
|
/** Leaves a tombstone: no holder, the same generation, so the next acquisition still moves forward. */
|
|
205
225
|
release(holding, now) {
|
|
206
|
-
const lost = this.
|
|
226
|
+
const lost = this.lossOf(holding);
|
|
207
227
|
if (lost !== void 0) return err(lost);
|
|
208
228
|
const state = new Lease({
|
|
209
229
|
...this.snapshot,
|
|
@@ -226,15 +246,19 @@ var Lease = class Lease {
|
|
|
226
246
|
toSnapshot() {
|
|
227
247
|
return this.snapshot;
|
|
228
248
|
}
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
249
|
+
/** Whether this record is still the given acquisition's: the same holder id and generation. */
|
|
250
|
+
holds(holding) {
|
|
251
|
+
const { holderId, generation } = this.snapshot;
|
|
252
|
+
return holderId === holding.holderId && generation === holding.generation;
|
|
253
|
+
}
|
|
254
|
+
/** Why this acquisition no longer holds, or `undefined` when it still does. */
|
|
255
|
+
lossOf(holding) {
|
|
256
|
+
if (this.holds(holding)) return;
|
|
233
257
|
return {
|
|
234
258
|
_tag: "LeaseLost",
|
|
235
|
-
key,
|
|
259
|
+
key: this.snapshot.key,
|
|
236
260
|
generation: holding.generation,
|
|
237
|
-
reason
|
|
261
|
+
reason: lossReason(this.snapshot)
|
|
238
262
|
};
|
|
239
263
|
}
|
|
240
264
|
};
|
|
@@ -272,8 +296,38 @@ function observe(previous, lease, now) {
|
|
|
272
296
|
function isFresh(lease, observation, now, ttlMs) {
|
|
273
297
|
return lease.holder !== null && (observation.revision !== lease.revision || now - observation.observedAt < ttlMs);
|
|
274
298
|
}
|
|
299
|
+
/**
|
|
300
|
+
* Whether the holder itself must give up: it has not confirmed a renewal for a full TTL, so an observer's clock may
|
|
301
|
+
* already have expired the lease and a takeover can have happened. Both times come from the holder's own monotonic
|
|
302
|
+
* clock; `confirmedAt` is when its last renewal write was accepted.
|
|
303
|
+
*/
|
|
304
|
+
function holderExpired(confirmedAt, now, ttlMs) {
|
|
305
|
+
return now - confirmedAt >= ttlMs;
|
|
306
|
+
}
|
|
307
|
+
//#endregion
|
|
308
|
+
//#region src/lease/domain/lease/value-objects/lease-timing.ts
|
|
309
|
+
/** Accepts a timing where every duration is positive and one late heartbeat cannot lose the lease. */
|
|
310
|
+
function leaseTiming(input) {
|
|
311
|
+
const { ttlMs, heartbeatMs } = input;
|
|
312
|
+
const retryMs = input.retryMs ?? heartbeatMs;
|
|
313
|
+
const invalid = (message) => err({
|
|
314
|
+
_tag: "LeaseConfigInvalid",
|
|
315
|
+
message
|
|
316
|
+
});
|
|
317
|
+
for (const [name, value] of [
|
|
318
|
+
["ttlMs", ttlMs],
|
|
319
|
+
["heartbeatMs", heartbeatMs],
|
|
320
|
+
["retryMs", retryMs]
|
|
321
|
+
]) if (!Number.isFinite(value) || value <= 0) return invalid(`${name} must be a positive number of milliseconds, got ${value}`);
|
|
322
|
+
if (heartbeatMs * 2 > ttlMs) return invalid(`heartbeatMs × 2 must not exceed ttlMs (${heartbeatMs} × 2 > ${ttlMs})`);
|
|
323
|
+
return ok({
|
|
324
|
+
ttlMs,
|
|
325
|
+
heartbeatMs,
|
|
326
|
+
retryMs
|
|
327
|
+
});
|
|
328
|
+
}
|
|
275
329
|
//#endregion
|
|
276
|
-
//#region src/lease/adapters/process-fence.ts
|
|
330
|
+
//#region src/lease/infra/adapters/process-fence.ts
|
|
277
331
|
/** The first retry of a waiting fence or guard; later ones back off from it. */
|
|
278
332
|
const RETRY_MS = 10;
|
|
279
333
|
/**
|
|
@@ -284,19 +338,19 @@ const RETRY_MS = 10;
|
|
|
284
338
|
function holdProcessLock(platform, key, path, options = {}) {
|
|
285
339
|
const attempt = (first) => Effect.acquireRelease(Effect.tryPromise({
|
|
286
340
|
try: () => tryProcessLock(platform, path, { first }),
|
|
287
|
-
catch: (cause) =>
|
|
288
|
-
}).pipe(Effect.flatMap((result) => result.ok ? Effect.succeed(result.value) : Effect.fail(
|
|
341
|
+
catch: (cause) => repositoryFailure(key, "io", `cannot lock ${path}`, cause)
|
|
342
|
+
}).pipe(Effect.flatMap((result) => result.ok ? Effect.succeed(result.value) : Effect.fail(repositoryFailure(key, "busy", `${path} is locked`)))), (lock) => Effect.promise(() => lock.release()));
|
|
289
343
|
return Effect.gen(function* () {
|
|
290
344
|
const started = platform.clock.monotonic();
|
|
291
345
|
for (let turn = 0;; turn += 1) {
|
|
292
346
|
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(
|
|
347
|
+
if (options.timeoutMs !== void 0 && platform.clock.monotonic() - started >= options.timeoutMs) return yield* Effect.fail(repositoryFailure(key, "busy", `${path} is still locked after ${options.timeoutMs} ms`));
|
|
294
348
|
yield* Effect.sleep(backoffMs(RETRY_MS, turn));
|
|
295
349
|
}
|
|
296
350
|
});
|
|
297
351
|
}
|
|
298
352
|
//#endregion
|
|
299
|
-
//#region src/lease/
|
|
353
|
+
//#region src/lease/infra/repository/file-lease-repository.ts
|
|
300
354
|
const SCHEMA_VERSION$1 = 1;
|
|
301
355
|
/**
|
|
302
356
|
* A guard is held only for one read, compare and write, so waiting longer means a stalled holder: the write reports
|
|
@@ -304,34 +358,34 @@ const SCHEMA_VERSION$1 = 1;
|
|
|
304
358
|
*/
|
|
305
359
|
const GUARD_TIMEOUT_MS = 1e3;
|
|
306
360
|
/**
|
|
307
|
-
* The fallback
|
|
308
|
-
* `writeAtomic`. Every `
|
|
309
|
-
*
|
|
310
|
-
*
|
|
311
|
-
*
|
|
312
|
-
*
|
|
361
|
+
* The fallback repository for platforms without SQLite: one JSON record per key, `<key>.lease.json`, replaced with
|
|
362
|
+
* `writeAtomic`. Every `save` holds the key's guard (`<key>.lease.guard`) while it reads, compares and writes, and
|
|
363
|
+
* reports `busy` when the guard stays held longer than a second; `fence` holds `<key>.lease.fence`. Both are process
|
|
364
|
+
* locks, so without SQLite they are lock files whose dead holders are reclaimed one reclaimer at a time, with the
|
|
365
|
+
* gaps `acquireProcessLock` documents. A record with an unknown `schemaVersion` or shape is refused and never
|
|
366
|
+
* overwritten.
|
|
313
367
|
*/
|
|
314
|
-
function
|
|
368
|
+
function fileLeaseRepository(options) {
|
|
315
369
|
const { dir } = options;
|
|
316
|
-
return Layer.effect(
|
|
370
|
+
return Layer.effect(LeaseRepository, Effect.gen(function* () {
|
|
317
371
|
const platform = yield* PlatformService;
|
|
318
372
|
const pathOf = (key, suffix) => keyFileName(key).pipe(Effect.map((name) => `${dir}/${name}.lease${suffix}`));
|
|
319
|
-
const
|
|
373
|
+
const load = (key) => pathOf(key, ".json").pipe(Effect.flatMap((path) => readRecord(platform, key, path)));
|
|
320
374
|
return {
|
|
321
|
-
|
|
322
|
-
|
|
375
|
+
load,
|
|
376
|
+
save: (key, next, expectedRevision) => Effect.scoped(Effect.gen(function* () {
|
|
323
377
|
yield* holdProcessLock(platform, key, yield* pathOf(key, ".guard"), { timeoutMs: GUARD_TIMEOUT_MS });
|
|
324
378
|
const path = yield* pathOf(key, ".json");
|
|
325
379
|
return yield* Effect.uninterruptible(Effect.gen(function* () {
|
|
326
|
-
|
|
380
|
+
const current = yield* readRecord(platform, key, path);
|
|
381
|
+
if (current?.revision !== expectedRevision) return yield* Effect.fail(revisionConflict(key, expectedRevision, current?.revision));
|
|
327
382
|
yield* Effect.tryPromise({
|
|
328
383
|
try: () => platform.fs.writeAtomic(path, JSON.stringify({
|
|
329
384
|
schemaVersion: SCHEMA_VERSION$1,
|
|
330
385
|
record: next
|
|
331
386
|
})),
|
|
332
|
-
catch: (cause) =>
|
|
387
|
+
catch: (cause) => repositoryFailure(key, "io", `cannot write ${path}`, cause)
|
|
333
388
|
});
|
|
334
|
-
return true;
|
|
335
389
|
}));
|
|
336
390
|
})),
|
|
337
391
|
fence: (key) => pathOf(key, ".fence").pipe(Effect.flatMap((path) => holdProcessLock(platform, key, path)))
|
|
@@ -341,7 +395,7 @@ function fileLeaseStore(options) {
|
|
|
341
395
|
function readRecord(platform, key, path) {
|
|
342
396
|
return Effect.tryPromise({
|
|
343
397
|
try: () => readText(platform, path),
|
|
344
|
-
catch: (cause) =>
|
|
398
|
+
catch: (cause) => repositoryFailure(key, "io", `cannot read ${path}`, cause)
|
|
345
399
|
}).pipe(Effect.flatMap((text) => text === void 0 ? Effect.succeed(void 0) : decodeFile(key, path, text)));
|
|
346
400
|
}
|
|
347
401
|
function decodeFile(key, path, text) {
|
|
@@ -349,21 +403,21 @@ function decodeFile(key, path, text) {
|
|
|
349
403
|
try {
|
|
350
404
|
json = JSON.parse(text);
|
|
351
405
|
} catch (cause) {
|
|
352
|
-
return Effect.fail(
|
|
406
|
+
return Effect.fail(repositoryFailure(key, "invalid-record", `${path} is not JSON`, cause));
|
|
353
407
|
}
|
|
354
408
|
const { schemaVersion, record } = typeof json === "object" && json !== null ? json : {};
|
|
355
|
-
if (schemaVersion !== SCHEMA_VERSION$1) return Effect.fail(
|
|
409
|
+
if (schemaVersion !== SCHEMA_VERSION$1) return Effect.fail(repositoryFailure(key, "unsupported-schema", `${path} has schemaVersion ${String(schemaVersion)}; expected 1`));
|
|
356
410
|
const snapshot = decodeSnapshot(record);
|
|
357
|
-
return snapshot === void 0 ? Effect.fail(
|
|
411
|
+
return snapshot === void 0 ? Effect.fail(repositoryFailure(key, "invalid-record", `${path} holds a record of an unexpected shape`)) : Effect.succeed(snapshot);
|
|
358
412
|
}
|
|
359
413
|
//#endregion
|
|
360
|
-
//#region src/lease/
|
|
414
|
+
//#region src/lease/infra/repository/memory-lease-repository.ts
|
|
361
415
|
/**
|
|
362
|
-
* A
|
|
363
|
-
* starts empty.
|
|
416
|
+
* A repository for one process, such as tests or a host whose leases never cross a process boundary. Each Layer
|
|
417
|
+
* build starts empty.
|
|
364
418
|
*/
|
|
365
|
-
function
|
|
366
|
-
return Layer.sync(
|
|
419
|
+
function memoryLeaseRepository() {
|
|
420
|
+
return Layer.sync(LeaseRepository, () => {
|
|
367
421
|
const records = /* @__PURE__ */ new Map();
|
|
368
422
|
const fences = /* @__PURE__ */ new Map();
|
|
369
423
|
const fenceOf = (key) => {
|
|
@@ -372,36 +426,37 @@ function memoryLeaseStore() {
|
|
|
372
426
|
return fence;
|
|
373
427
|
};
|
|
374
428
|
return {
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
429
|
+
load: (key) => Effect.sync(() => records.get(key)),
|
|
430
|
+
save: (key, next, expectedRevision) => Effect.suspend(() => {
|
|
431
|
+
const stored = records.get(key);
|
|
432
|
+
if (stored?.revision !== expectedRevision) return Effect.fail(revisionConflict(key, expectedRevision, stored?.revision));
|
|
378
433
|
records.set(key, next);
|
|
379
|
-
return
|
|
434
|
+
return Effect.void;
|
|
380
435
|
}),
|
|
381
436
|
fence: (key) => Effect.acquireRelease(fenceOf(key).take(1), () => fenceOf(key).release(1), { interruptible: true }).pipe(Effect.asVoid)
|
|
382
437
|
};
|
|
383
438
|
});
|
|
384
439
|
}
|
|
385
440
|
//#endregion
|
|
386
|
-
//#region src/lease/
|
|
441
|
+
//#region src/lease/infra/repository/sqlite-lease-repository.ts
|
|
387
442
|
const SCHEMA_VERSION = 1;
|
|
388
443
|
/** Long enough for another writer's single-row transaction; longer waits block the event loop, so retry instead. */
|
|
389
444
|
const BUSY_TIMEOUT_MS = 50;
|
|
390
445
|
const BUSY_RETRIES = 100;
|
|
391
446
|
const BUSY_RETRY_MS = 5;
|
|
392
447
|
/**
|
|
393
|
-
* The lease
|
|
394
|
-
*
|
|
395
|
-
*
|
|
396
|
-
*
|
|
397
|
-
*
|
|
448
|
+
* The lease repository for one machine: one row per key in a SQLite database. `save` runs in a `BEGIN IMMEDIATE`
|
|
449
|
+
* transaction, which takes SQLite's write lock (an fcntl lock) before reading the revision, so processes sharing the
|
|
450
|
+
* file compare and write atomically. The lock is released when its process exits, so neither a crashed writer nor a
|
|
451
|
+
* reused pid can leave the repository stuck. Fails to build with `unavailable` when the platform has no SQLite, and
|
|
452
|
+
* with `unsupported-schema` for a database written by a newer version.
|
|
398
453
|
*/
|
|
399
|
-
function
|
|
454
|
+
function sqliteLeaseRepository(options) {
|
|
400
455
|
const { path } = options;
|
|
401
|
-
return Layer.effect(
|
|
456
|
+
return Layer.effect(LeaseRepository, Effect.gen(function* () {
|
|
402
457
|
const platform = yield* PlatformService;
|
|
403
458
|
const { sqlite } = platform;
|
|
404
|
-
if (sqlite === void 0) return yield* Effect.fail(
|
|
459
|
+
if (sqlite === void 0) return yield* Effect.fail(repositoryFailure("", "unavailable", "the platform has no SQLite"));
|
|
405
460
|
const db = yield* Effect.acquireRelease(sqliteTry("", `cannot open ${path}`, () => sqlite.open(path)), (opened) => Effect.sync(() => opened.close()));
|
|
406
461
|
yield* retryBusy(sqliteTry("", `cannot prepare ${path}`, () => migrate(db)));
|
|
407
462
|
const statements = {
|
|
@@ -412,12 +467,13 @@ function sqliteLeaseStore(options) {
|
|
|
412
467
|
holder = excluded.holder, holder_id = excluded.holder_id, renewed_at = excluded.renewed_at`)
|
|
413
468
|
};
|
|
414
469
|
return {
|
|
415
|
-
|
|
416
|
-
|
|
470
|
+
load: (key) => retryBusy(sqliteTry(key, `cannot read ${path}`, () => statements.select.get(key))).pipe(Effect.flatMap((row) => decodeRow(key, row))),
|
|
471
|
+
save: (key, next, expectedRevision) => retryBusy(sqliteTry(key, `cannot write ${path}`, () => saveInTransaction(db, statements, key, next, expectedRevision))).pipe(Effect.flatMap((outcome) => outcome === void 0 ? Effect.void : Effect.fail(outcome))),
|
|
417
472
|
fence: (key) => keyFileName(key).pipe(Effect.flatMap((name) => holdProcessLock(platform, key, `${path}.${name}.fence`)))
|
|
418
473
|
};
|
|
419
474
|
}));
|
|
420
475
|
}
|
|
476
|
+
var UnsupportedSchema = class extends Error {};
|
|
421
477
|
function migrate(db) {
|
|
422
478
|
db.exec(`PRAGMA busy_timeout = ${BUSY_TIMEOUT_MS}`);
|
|
423
479
|
db.exec("PRAGMA journal_mode = WAL");
|
|
@@ -436,12 +492,12 @@ function migrate(db) {
|
|
|
436
492
|
} else if (version !== SCHEMA_VERSION) throw new UnsupportedSchema(`the lease database has schema version ${version}; this version reads ${SCHEMA_VERSION}`);
|
|
437
493
|
});
|
|
438
494
|
}
|
|
439
|
-
function
|
|
495
|
+
function saveInTransaction(db, statements, key, next, expectedRevision) {
|
|
440
496
|
return transaction(db, () => {
|
|
441
|
-
const
|
|
442
|
-
|
|
497
|
+
const storedRow = statements.selectRevision.get(next.key);
|
|
498
|
+
const stored = storedRow === void 0 ? void 0 : Number(storedRow.revision);
|
|
499
|
+
if (stored !== expectedRevision) return revisionConflict(key, expectedRevision, stored);
|
|
443
500
|
statements.upsert.run(next.key, next.generation, next.revision, encodeHolder(next.holder), next.holderId, next.renewedAt);
|
|
444
|
-
return true;
|
|
445
501
|
});
|
|
446
502
|
}
|
|
447
503
|
/** Runs `body` in a `BEGIN IMMEDIATE` transaction, committing when it returns and rolling back when it throws. */
|
|
@@ -458,13 +514,12 @@ function transaction(db, body) {
|
|
|
458
514
|
throw error;
|
|
459
515
|
}
|
|
460
516
|
}
|
|
461
|
-
var UnsupportedSchema = class extends Error {};
|
|
462
517
|
function sqliteTry(key, message, body) {
|
|
463
518
|
return Effect.try({
|
|
464
519
|
try: body,
|
|
465
520
|
catch: (cause) => {
|
|
466
|
-
if (cause instanceof UnsupportedSchema) return
|
|
467
|
-
return
|
|
521
|
+
if (cause instanceof UnsupportedSchema) return repositoryFailure(key, "unsupported-schema", cause.message);
|
|
522
|
+
return repositoryFailure(key, isSqliteBusy(cause) ? "busy" : "io", message, cause);
|
|
468
523
|
}
|
|
469
524
|
});
|
|
470
525
|
}
|
|
@@ -486,36 +541,23 @@ function decodeRow(key, row) {
|
|
|
486
541
|
holderId: row.holder_id,
|
|
487
542
|
renewedAt: row.renewed_at
|
|
488
543
|
});
|
|
489
|
-
return snapshot === void 0 ? Effect.fail(
|
|
544
|
+
return snapshot === void 0 ? Effect.fail(repositoryFailure(key, "invalid-record", `the stored record for ${key} has an unexpected shape`)) : Effect.succeed(snapshot);
|
|
490
545
|
}
|
|
491
546
|
//#endregion
|
|
492
|
-
//#region src/lease/application/from-result.ts
|
|
547
|
+
//#region src/lease/application/services/from-result.ts
|
|
493
548
|
/** Moves a plain `Result` into the typed error channel, where `catchTag` sees its error. */
|
|
494
549
|
const fromResult = (result) => result.ok ? Effect.succeed(result.value) : Effect.fail(result.error);
|
|
495
550
|
//#endregion
|
|
496
|
-
//#region src/lease/application/lease-manager.ts
|
|
497
|
-
/** CAS attempts within one acquisition before the
|
|
551
|
+
//#region src/lease/application/use-cases/lease-manager.ts
|
|
552
|
+
/** CAS attempts within one acquisition before the repository counts as busy; each lost race re-reads the record. */
|
|
498
553
|
const MAX_CAS_TURNS = 8;
|
|
499
|
-
/** Validates `config` and builds a manager over the `
|
|
554
|
+
/** Validates `config` and builds a manager over the `LeaseRepository` and the platform in context. */
|
|
500
555
|
function createLeaseManager(config) {
|
|
501
556
|
return Effect.gen(function* () {
|
|
502
|
-
|
|
503
|
-
if (problem !== void 0) return yield* Effect.fail({
|
|
504
|
-
_tag: "LeaseConfigInvalid",
|
|
505
|
-
message: problem
|
|
506
|
-
});
|
|
507
|
-
return makeManager(config, yield* LeaseStore, yield* PlatformService);
|
|
557
|
+
return makeManager(yield* fromResult(leaseTiming(config)), yield* LeaseRepository, yield* PlatformService);
|
|
508
558
|
});
|
|
509
559
|
}
|
|
510
|
-
function
|
|
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) {
|
|
560
|
+
function makeManager(timing, store, platform) {
|
|
519
561
|
const observations = /* @__PURE__ */ new Map();
|
|
520
562
|
const now = () => platform.clock.monotonic();
|
|
521
563
|
const self = () => {
|
|
@@ -535,7 +577,7 @@ function makeManager(config, store, platform) {
|
|
|
535
577
|
/** One acquisition attempt: reads the record, applies the Lease rules, and writes over exactly that revision. */
|
|
536
578
|
const take = (key, holderId) => Effect.gen(function* () {
|
|
537
579
|
for (let turn = 0; turn < MAX_CAS_TURNS; turn += 1) {
|
|
538
|
-
const record = yield* store.
|
|
580
|
+
const record = yield* store.load(key);
|
|
539
581
|
const claim = {
|
|
540
582
|
key,
|
|
541
583
|
holder: self(),
|
|
@@ -545,11 +587,11 @@ function makeManager(config, store, platform) {
|
|
|
545
587
|
let transition;
|
|
546
588
|
if (record === void 0) transition = Lease.create(claim);
|
|
547
589
|
else {
|
|
548
|
-
const lease = yield* fromResult(Lease.restore(record)).pipe(Effect.mapError((invalid) =>
|
|
590
|
+
const lease = yield* fromResult(Lease.restore(record)).pipe(Effect.mapError((invalid) => repositoryFailure(key, "invalid-record", invalid.message)));
|
|
549
591
|
const at = now();
|
|
550
592
|
const observation = observe(observations.get(key), record, at);
|
|
551
593
|
observations.set(key, observation);
|
|
552
|
-
const fresh = isFresh(record, observation, at,
|
|
594
|
+
const fresh = isFresh(record, observation, at, timing.ttlMs);
|
|
553
595
|
const { holder } = record;
|
|
554
596
|
const liveness = fresh && holder !== null ? yield* judge(holder, claim.holder) : "unknown";
|
|
555
597
|
transition = yield* fromResult(lease.acquire(claim, {
|
|
@@ -558,17 +600,16 @@ function makeManager(config, store, platform) {
|
|
|
558
600
|
}));
|
|
559
601
|
}
|
|
560
602
|
const next = transition.state.toSnapshot();
|
|
561
|
-
if (yield* store.
|
|
603
|
+
if (yield* store.save(key, next, record?.revision).pipe(Effect.as(true), Effect.catchTag("RevisionConflict", () => Effect.succeed(false)))) {
|
|
562
604
|
observations.delete(key);
|
|
563
605
|
return next;
|
|
564
606
|
}
|
|
565
607
|
}
|
|
566
|
-
return yield* Effect.fail(
|
|
608
|
+
return yield* Effect.fail(repositoryFailure(key, "busy", "the lease record kept changing during acquisition"));
|
|
567
609
|
});
|
|
568
610
|
const lostBy = (key, holding) => Effect.gen(function* () {
|
|
569
|
-
const read = yield* Effect.exit(store.
|
|
570
|
-
const
|
|
571
|
-
const reason = !Exit.isSuccess(read) ? "taken-over" : record === void 0 ? "missing" : record.holder === null ? "released" : "taken-over";
|
|
611
|
+
const read = yield* Effect.exit(store.load(key));
|
|
612
|
+
const reason = Exit.isSuccess(read) ? lossReason(read.value) : "taken-over";
|
|
572
613
|
return {
|
|
573
614
|
_tag: "LeaseLost",
|
|
574
615
|
key,
|
|
@@ -577,25 +618,25 @@ function makeManager(config, store, platform) {
|
|
|
577
618
|
};
|
|
578
619
|
});
|
|
579
620
|
/**
|
|
580
|
-
* Renews every `heartbeatMs`. A refused write means another writer moved the record, so the lease is lost; a
|
|
581
|
-
* failure is retried until no renewal has been confirmed for a TTL, after which an observer may take it
|
|
582
|
-
* write stays interruptible while it waits for the
|
|
583
|
-
* between its write and `Ref.set` cannot keep the tombstone from being written.
|
|
621
|
+
* Renews every `heartbeatMs`. A refused write means another writer moved the record, so the lease is lost; a
|
|
622
|
+
* repository failure is retried until no renewal has been confirmed for a TTL, after which an observer may take it
|
|
623
|
+
* over. The write stays interruptible while it waits for the repository; `release` re-reads the record, so a
|
|
624
|
+
* renewal interrupted between its write and `Ref.set` cannot keep the tombstone from being written.
|
|
584
625
|
*/
|
|
585
626
|
const heartbeat = (key, holding, ref) => Effect.gen(function* () {
|
|
586
627
|
let confirmedAt = now();
|
|
587
628
|
for (;;) {
|
|
588
|
-
yield* Effect.sleep(
|
|
629
|
+
yield* Effect.sleep(timing.heartbeatMs);
|
|
589
630
|
const current = yield* Ref.get(ref);
|
|
590
631
|
const restored = Lease.restore(current);
|
|
591
632
|
const transition = restored.ok ? restored.value.renew(holding, platform.clock.now()) : void 0;
|
|
592
633
|
if (transition?.ok !== true) return yield* Effect.fail(yield* lostBy(key, holding));
|
|
593
634
|
const next = transition.value.state.toSnapshot();
|
|
594
|
-
const written = yield* Effect.exit(store.
|
|
635
|
+
const written = yield* Effect.exit(store.save(key, next, current.revision).pipe(Effect.tap(() => Ref.set(ref, next)), Effect.as(true), Effect.catchTag("RevisionConflict", () => Effect.succeed(false))));
|
|
595
636
|
if (Exit.isSuccess(written)) {
|
|
596
637
|
if (!written.value) return yield* Effect.fail(yield* lostBy(key, holding));
|
|
597
638
|
confirmedAt = now();
|
|
598
|
-
} else if (now()
|
|
639
|
+
} else if (holderExpired(confirmedAt, now(), timing.ttlMs)) return yield* Effect.fail({
|
|
599
640
|
_tag: "LeaseLost",
|
|
600
641
|
key,
|
|
601
642
|
generation: holding.generation,
|
|
@@ -608,10 +649,10 @@ function makeManager(config, store, platform) {
|
|
|
608
649
|
* to expire after the TTL; a finalizer cannot fail.
|
|
609
650
|
*/
|
|
610
651
|
const release = (key, holding) => Effect.gen(function* () {
|
|
611
|
-
const current = yield* store.
|
|
652
|
+
const current = yield* store.load(key);
|
|
612
653
|
const restored = current === void 0 ? void 0 : Lease.restore(current);
|
|
613
654
|
const transition = restored?.ok === true ? restored.value.release(holding, platform.clock.now()) : void 0;
|
|
614
|
-
if (current !== void 0 && transition?.ok === true) yield* store.
|
|
655
|
+
if (current !== void 0 && transition?.ok === true) yield* store.save(key, transition.value.state.toSnapshot(), current.revision);
|
|
615
656
|
}).pipe(Effect.ignore);
|
|
616
657
|
const handle = (key, holding, lost) => {
|
|
617
658
|
const token = {
|
|
@@ -625,8 +666,8 @@ function makeManager(config, store, platform) {
|
|
|
625
666
|
runFenced: (work) => Effect.scoped(Effect.gen(function* () {
|
|
626
667
|
if (yield* Deferred.isDone(lost)) return yield* Deferred.await(lost);
|
|
627
668
|
yield* store.fence(key).pipe(Effect.raceFirst(Deferred.await(lost)));
|
|
628
|
-
const record = yield* store.
|
|
629
|
-
if (record
|
|
669
|
+
const record = yield* store.load(key);
|
|
670
|
+
if ((record === void 0 ? void 0 : yield* fromResult(Lease.restore(record)).pipe(Effect.mapError((invalid) => repositoryFailure(key, "invalid-record", invalid.message))))?.holds(holding) !== true) {
|
|
630
671
|
const current = record?.generation;
|
|
631
672
|
return yield* Effect.fail({
|
|
632
673
|
_tag: "FenceRejected",
|
|
@@ -649,7 +690,7 @@ function makeManager(config, store, platform) {
|
|
|
649
690
|
})));
|
|
650
691
|
const acquired = options.wait === true ? attempt.pipe(Effect.retry({
|
|
651
692
|
while: (error) => error._tag === "LeaseHeld" || error.reason === "busy",
|
|
652
|
-
schedule: Schedule.spaced(
|
|
693
|
+
schedule: Schedule.spaced(timing.retryMs)
|
|
653
694
|
})) : attempt;
|
|
654
695
|
return Effect.gen(function* () {
|
|
655
696
|
const ref = yield* acquired;
|
|
@@ -663,8 +704,8 @@ function makeManager(config, store, platform) {
|
|
|
663
704
|
return handle(key, holding, lost);
|
|
664
705
|
});
|
|
665
706
|
},
|
|
666
|
-
read: (key) => store.
|
|
707
|
+
read: (key) => store.load(key)
|
|
667
708
|
};
|
|
668
709
|
}
|
|
669
710
|
//#endregion
|
|
670
|
-
export {
|
|
711
|
+
export { LeaseRepository, canAcquire, checkFence, createLeaseManager, fileLeaseRepository, holderLiveness, isFresh, memoryLeaseRepository, nextFencingToken, sqliteLeaseRepository };
|
package/dist/process-lock.d.ts
CHANGED
|
@@ -8,7 +8,7 @@ import { Platform } from "@rivus/agent-kit/platform";
|
|
|
8
8
|
*/
|
|
9
9
|
type ProcessLockPlatform = Pick<Platform, "fs" | "process" | "clock" | "sqlite">;
|
|
10
10
|
//#endregion
|
|
11
|
-
//#region src/process-lock/application/holder.d.ts
|
|
11
|
+
//#region src/process-lock/application/services/holder.d.ts
|
|
12
12
|
/** Who holds or held a process lock, as recorded when it was acquired; it may be stale. */
|
|
13
13
|
interface ProcessLockHolder {
|
|
14
14
|
readonly host: string;
|
|
@@ -19,7 +19,7 @@ interface ProcessLockHolder {
|
|
|
19
19
|
readonly acquiredAt: number;
|
|
20
20
|
}
|
|
21
21
|
//#endregion
|
|
22
|
-
//#region src/process-lock/application/acquire-process-lock.d.ts
|
|
22
|
+
//#region src/process-lock/application/use-cases/acquire-process-lock.d.ts
|
|
23
23
|
interface ProcessLockOptions {
|
|
24
24
|
/** Keep trying while another process holds the lock, instead of returning `ProcessLockHeld` at once. */
|
|
25
25
|
readonly wait?: boolean;
|
package/dist/process-lock.js
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
import { t as acquireProcessLock } from "./acquire-process-lock-
|
|
1
|
+
import { t as acquireProcessLock } from "./acquire-process-lock-BCvK1ehZ.js";
|
|
2
2
|
export { acquireProcessLock };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@rivus/agent-kit-collab",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
4
4
|
"description": "Collaboration primitives between coding agents: fenced leases, single-instance process locks and keyed scheduling lanes.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -40,8 +40,8 @@
|
|
|
40
40
|
"devDependencies": {
|
|
41
41
|
"@arethetypeswrong/cli": "0.18.5",
|
|
42
42
|
"@effect/vitest": "4.0.1",
|
|
43
|
-
"@perfectpan/lint-config": "github:PerfectPan/lint-config#v0.
|
|
44
|
-
"@rivus/agent-kit": "0.
|
|
43
|
+
"@perfectpan/lint-config": "github:PerfectPan/lint-config#v0.4.0",
|
|
44
|
+
"@rivus/agent-kit": "0.4.0",
|
|
45
45
|
"@size-limit/preset-small-lib": "14.1.0",
|
|
46
46
|
"@types/node": "24.19.1",
|
|
47
47
|
"effect": "4.0.1",
|
|
@@ -56,7 +56,7 @@
|
|
|
56
56
|
"vitest": "5.0.3"
|
|
57
57
|
},
|
|
58
58
|
"peerDependencies": {
|
|
59
|
-
"@rivus/agent-kit": "^0.
|
|
59
|
+
"@rivus/agent-kit": "^0.4.0",
|
|
60
60
|
"effect": "4.0.1"
|
|
61
61
|
},
|
|
62
62
|
"peerDependenciesMeta": {
|