@rivus/agent-kit-collab 0.0.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/LICENSE +21 -0
- package/README.md +156 -0
- package/dist/acquire-process-lock-BCvK1ehZ.js +312 -0
- package/dist/lanes.d.ts +122 -0
- package/dist/lanes.js +341 -0
- package/dist/lease.d.ts +283 -0
- package/dist/lease.js +711 -0
- package/dist/process-lock.d.ts +58 -0
- package/dist/process-lock.js +2 -0
- package/package.json +66 -6
package/dist/lanes.js
ADDED
|
@@ -0,0 +1,341 @@
|
|
|
1
|
+
import * as Effect from "effect/Effect";
|
|
2
|
+
import { err, ok } from "@rivus/agent-kit/catalog";
|
|
3
|
+
import * as Cause from "effect/Cause";
|
|
4
|
+
import * as Deferred from "effect/Deferred";
|
|
5
|
+
import * as Exit from "effect/Exit";
|
|
6
|
+
//#region src/lanes/domain/lane/policies/admission.ts
|
|
7
|
+
/**
|
|
8
|
+
* Where a wake of an idle lane goes: it starts while a slot is free and nobody waits for one, waits at the end of the
|
|
9
|
+
* queue while the queue has room, and is refused otherwise. A newcomer never passes a lane that is already waiting.
|
|
10
|
+
*/
|
|
11
|
+
function admit(load, limits) {
|
|
12
|
+
if (load.running < limits.maxConcurrent && load.queued === 0) return "start";
|
|
13
|
+
return load.queued < limits.maxQueued ? "queue" : "full";
|
|
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
|
+
}
|
|
28
|
+
//#endregion
|
|
29
|
+
//#region src/lanes/domain/lane/aggregates/lane.ts
|
|
30
|
+
/**
|
|
31
|
+
* One key's lane, held in memory only. At most one activation runs at a time, and every wake that arrives before an
|
|
32
|
+
* activation starts is served by that activation: wakes coalesce instead of queueing one run each. Only an idle lane
|
|
33
|
+
* asks for a slot, so a lane occupies at most one place in the queue.
|
|
34
|
+
*/
|
|
35
|
+
var Lane = class Lane {
|
|
36
|
+
static create(key) {
|
|
37
|
+
return new Lane({
|
|
38
|
+
key,
|
|
39
|
+
state: "idle",
|
|
40
|
+
pending: false
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
snapshot;
|
|
44
|
+
constructor(snapshot) {
|
|
45
|
+
this.snapshot = snapshot;
|
|
46
|
+
Object.freeze(this);
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* An idle lane starts or joins the queue as `admit` decides, and is refused with `LaneQueueFull` when the queue has
|
|
50
|
+
* no room. A queued or running lane takes the wake as pending: it never asks for another slot.
|
|
51
|
+
*/
|
|
52
|
+
wake(load, limits) {
|
|
53
|
+
const { key, state } = this.snapshot;
|
|
54
|
+
if (state !== "idle") return ok(this.to({
|
|
55
|
+
key,
|
|
56
|
+
state,
|
|
57
|
+
pending: true
|
|
58
|
+
}, {
|
|
59
|
+
_tag: "WakeCoalesced",
|
|
60
|
+
key
|
|
61
|
+
}));
|
|
62
|
+
switch (admit(load, limits)) {
|
|
63
|
+
case "start": return ok(this.to({
|
|
64
|
+
key,
|
|
65
|
+
state: "running",
|
|
66
|
+
pending: false
|
|
67
|
+
}, {
|
|
68
|
+
_tag: "ActivationStarted",
|
|
69
|
+
key
|
|
70
|
+
}));
|
|
71
|
+
case "queue": return ok(this.to({
|
|
72
|
+
key,
|
|
73
|
+
state: "queued",
|
|
74
|
+
pending: true
|
|
75
|
+
}, {
|
|
76
|
+
_tag: "LaneQueued",
|
|
77
|
+
key
|
|
78
|
+
}));
|
|
79
|
+
case "full": return err({
|
|
80
|
+
_tag: "LaneQueueFull",
|
|
81
|
+
key,
|
|
82
|
+
maxQueued: limits.maxQueued
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
/** A queued lane got a slot: its activation starts and serves every wake so far. */
|
|
87
|
+
start() {
|
|
88
|
+
const { key } = this.expect("queued");
|
|
89
|
+
return this.to({
|
|
90
|
+
key,
|
|
91
|
+
state: "running",
|
|
92
|
+
pending: false
|
|
93
|
+
}, {
|
|
94
|
+
_tag: "LaneDequeued",
|
|
95
|
+
key
|
|
96
|
+
}, {
|
|
97
|
+
_tag: "ActivationStarted",
|
|
98
|
+
key
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
/**
|
|
102
|
+
* The running activation ended. A wake it did not serve sends the lane to the end of the queue, behind the lanes
|
|
103
|
+
* that waited while it ran; the slot it frees goes to the head of the queue first.
|
|
104
|
+
*/
|
|
105
|
+
finish() {
|
|
106
|
+
const { key, pending } = this.expect("running");
|
|
107
|
+
const ended = {
|
|
108
|
+
_tag: "ActivationEnded",
|
|
109
|
+
key
|
|
110
|
+
};
|
|
111
|
+
return pending ? this.to({
|
|
112
|
+
key,
|
|
113
|
+
state: "queued",
|
|
114
|
+
pending: true
|
|
115
|
+
}, ended, {
|
|
116
|
+
_tag: "LaneQueued",
|
|
117
|
+
key
|
|
118
|
+
}) : this.to({
|
|
119
|
+
key,
|
|
120
|
+
state: "idle",
|
|
121
|
+
pending: false
|
|
122
|
+
}, ended);
|
|
123
|
+
}
|
|
124
|
+
/**
|
|
125
|
+
* Drops the wakes the lane owes: a queued lane leaves the queue, a running one keeps running without a follow-up.
|
|
126
|
+
* Ending the running activation is the caller's part; wakes after the cancel are owed again.
|
|
127
|
+
*/
|
|
128
|
+
cancel() {
|
|
129
|
+
const { key, state, pending } = this.snapshot;
|
|
130
|
+
if (state === "queued") return this.to({
|
|
131
|
+
key,
|
|
132
|
+
state: "idle",
|
|
133
|
+
pending: false
|
|
134
|
+
}, {
|
|
135
|
+
_tag: "LaneDequeued",
|
|
136
|
+
key
|
|
137
|
+
});
|
|
138
|
+
if (state === "running" && pending) return this.to({
|
|
139
|
+
key,
|
|
140
|
+
state,
|
|
141
|
+
pending: false
|
|
142
|
+
}, {
|
|
143
|
+
_tag: "PendingDropped",
|
|
144
|
+
key
|
|
145
|
+
});
|
|
146
|
+
return {
|
|
147
|
+
state: this,
|
|
148
|
+
events: []
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
toSnapshot() {
|
|
152
|
+
return this.snapshot;
|
|
153
|
+
}
|
|
154
|
+
to(snapshot, ...events) {
|
|
155
|
+
return {
|
|
156
|
+
state: new Lane(snapshot),
|
|
157
|
+
events
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
/** Starting a lane that is not queued, or finishing one that is not running, is a scheduler bug, not an outcome. */
|
|
161
|
+
expect(state) {
|
|
162
|
+
if (this.snapshot.state !== state) throw new Error(`lane ${this.snapshot.key} is ${this.snapshot.state}, not ${state}`);
|
|
163
|
+
return this.snapshot;
|
|
164
|
+
}
|
|
165
|
+
};
|
|
166
|
+
//#endregion
|
|
167
|
+
//#region src/lanes/domain/lane/value-objects/lane-limits.ts
|
|
168
|
+
function laneLimits(input) {
|
|
169
|
+
const { maxConcurrent, maxQueued = Number.POSITIVE_INFINITY, turnTimeoutMs } = input;
|
|
170
|
+
const invalid = (message) => err({
|
|
171
|
+
_tag: "LanesConfigInvalid",
|
|
172
|
+
message
|
|
173
|
+
});
|
|
174
|
+
if (!Number.isSafeInteger(maxConcurrent) || maxConcurrent < 1) return invalid(`maxConcurrent must be a positive integer, got ${maxConcurrent}`);
|
|
175
|
+
if (maxQueued !== Number.POSITIVE_INFINITY && (!Number.isSafeInteger(maxQueued) || maxQueued < 0)) return invalid(`maxQueued must be a non-negative integer, got ${maxQueued}`);
|
|
176
|
+
if (turnTimeoutMs !== void 0 && (!Number.isFinite(turnTimeoutMs) || turnTimeoutMs <= 0)) return invalid(`turnTimeoutMs must be a positive number of milliseconds, got ${turnTimeoutMs}`);
|
|
177
|
+
return ok({
|
|
178
|
+
maxConcurrent,
|
|
179
|
+
maxQueued,
|
|
180
|
+
turnTimeoutMs
|
|
181
|
+
});
|
|
182
|
+
}
|
|
183
|
+
//#endregion
|
|
184
|
+
//#region src/lanes/application/services/from-result.ts
|
|
185
|
+
/** Moves a plain `Result` into the typed error channel, where `catchTag` sees its error. */
|
|
186
|
+
const fromResult = (result) => result.ok ? Effect.succeed(result.value) : Effect.fail(result.error);
|
|
187
|
+
//#endregion
|
|
188
|
+
//#region src/lanes/application/use-cases/create-lanes.ts
|
|
189
|
+
/** An end the activation did not reach by itself: an interruption, by the lanes or its own. */
|
|
190
|
+
const cutShort = (exit) => Exit.isFailure(exit) && Cause.hasInterruptsOnly(exit.cause);
|
|
191
|
+
/**
|
|
192
|
+
* Creates lanes in the caller's Scope. The lanes capture the context they are created in and give it to every
|
|
193
|
+
* activation; fails with `LanesConfigInvalid` for a limit out of range.
|
|
194
|
+
*/
|
|
195
|
+
function createLanes(config) {
|
|
196
|
+
return Effect.gen(function* () {
|
|
197
|
+
const limits = yield* fromResult(laneLimits(config));
|
|
198
|
+
const context = yield* Effect.context();
|
|
199
|
+
const { activate, onExit } = config;
|
|
200
|
+
const lanes = /* @__PURE__ */ new Map();
|
|
201
|
+
const activations = /* @__PURE__ */ new Map();
|
|
202
|
+
const queue = [];
|
|
203
|
+
let closed = false;
|
|
204
|
+
const laneOf = (key) => {
|
|
205
|
+
const lane = lanes.get(key);
|
|
206
|
+
if (lane === void 0) throw new Error(`no lane for ${key}`);
|
|
207
|
+
return lane;
|
|
208
|
+
};
|
|
209
|
+
/** Records a transition and returns the activations it started; the caller forks them. */
|
|
210
|
+
const apply = ({ state, events }) => {
|
|
211
|
+
const { key, state: laneState } = state.toSnapshot();
|
|
212
|
+
if (laneState === "idle") lanes.delete(key);
|
|
213
|
+
else lanes.set(key, state);
|
|
214
|
+
const started = [];
|
|
215
|
+
for (const event of events) switch (event._tag) {
|
|
216
|
+
case "LaneQueued":
|
|
217
|
+
queue.push(key);
|
|
218
|
+
break;
|
|
219
|
+
case "LaneDequeued":
|
|
220
|
+
queue.splice(queue.indexOf(key), 1);
|
|
221
|
+
break;
|
|
222
|
+
case "ActivationStarted": {
|
|
223
|
+
const activation = {
|
|
224
|
+
key,
|
|
225
|
+
stop: Deferred.makeUnsafe(),
|
|
226
|
+
ended: Deferred.makeUnsafe()
|
|
227
|
+
};
|
|
228
|
+
activations.set(key, activation);
|
|
229
|
+
started.push(activation);
|
|
230
|
+
break;
|
|
231
|
+
}
|
|
232
|
+
case "ActivationEnded": activations.delete(key);
|
|
233
|
+
}
|
|
234
|
+
return started;
|
|
235
|
+
};
|
|
236
|
+
/** Hands the free slots to the lanes that `nextToStart` picks: the ones that have waited longest. */
|
|
237
|
+
const pump = () => {
|
|
238
|
+
const started = [];
|
|
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()));
|
|
244
|
+
return started;
|
|
245
|
+
};
|
|
246
|
+
const interrupted = (key, reason) => ({
|
|
247
|
+
_tag: "ActivationInterrupted",
|
|
248
|
+
key,
|
|
249
|
+
reason
|
|
250
|
+
});
|
|
251
|
+
const exitOf = (key, exit) => Exit.isSuccess(exit) ? {
|
|
252
|
+
_tag: "ActivationSucceeded",
|
|
253
|
+
key
|
|
254
|
+
} : {
|
|
255
|
+
_tag: "ActivationFailed",
|
|
256
|
+
key,
|
|
257
|
+
cause: exit.cause
|
|
258
|
+
};
|
|
259
|
+
/**
|
|
260
|
+
* Runs the work, raced against `stop`; the loser is interrupted and awaited, which for the work includes closing
|
|
261
|
+
* its Scope. `stop` comes first in the race, so an activation stopped before its fiber started never calls
|
|
262
|
+
* `activate`: the race forks its contenders in order and stops at the first that has ended. The turn timer only
|
|
263
|
+
* completes `stop`, so a cancel or close that came first keeps its reason while the cleanup outlasts the deadline.
|
|
264
|
+
*/
|
|
265
|
+
const settle = ({ key, stop }) => Effect.suspend(() => {
|
|
266
|
+
let body;
|
|
267
|
+
let work;
|
|
268
|
+
const record = Effect.scoped(Effect.suspend(() => activate(key)).pipe(Effect.onExit((exit) => Effect.sync(() => {
|
|
269
|
+
body = exit;
|
|
270
|
+
})))).pipe(Effect.onExit((exit) => Effect.sync(() => {
|
|
271
|
+
work = exit;
|
|
272
|
+
})), Effect.exit);
|
|
273
|
+
const raced = Effect.raceFirst(Deferred.await(stop), record);
|
|
274
|
+
const { turnTimeoutMs } = limits;
|
|
275
|
+
const timed = turnTimeoutMs === void 0 ? raced : Effect.raceFirst(raced, Effect.sleep(turnTimeoutMs).pipe(Effect.andThen(Deferred.succeed(stop, "timeout")), Effect.andThen(Effect.never)));
|
|
276
|
+
return Effect.map(timed, (result) => {
|
|
277
|
+
if (typeof result !== "string") return exitOf(key, result);
|
|
278
|
+
if (body === void 0 || cutShort(body)) return interrupted(key, result);
|
|
279
|
+
const own = (work === void 0 || Exit.isSuccess(work) ? [] : work.cause.reasons).filter((reason) => !Cause.isInterruptReason(reason));
|
|
280
|
+
return exitOf(key, own.length === 0 ? body : Exit.failCause(Cause.fromReasons(own)));
|
|
281
|
+
});
|
|
282
|
+
});
|
|
283
|
+
/**
|
|
284
|
+
* One activation's fiber. It is forked uninterruptible and nothing else holds it, so it always reaches the end of
|
|
285
|
+
* the activation, which frees the slot; only the raced work inside can be interrupted.
|
|
286
|
+
*/
|
|
287
|
+
const run = (activation) => Effect.gen(function* () {
|
|
288
|
+
const exit = yield* settle(activation);
|
|
289
|
+
if (onExit !== void 0) yield* Effect.scoped(Effect.suspend(() => onExit(exit))).pipe(Effect.catchCause((cause) => Effect.logWarning(`lanes: onExit failed for ${exit.key}`, cause)));
|
|
290
|
+
yield* Effect.suspend(() => {
|
|
291
|
+
const started = [...apply(laneOf(activation.key).finish()), ...pump()];
|
|
292
|
+
Deferred.doneUnsafe(activation.ended, Exit.void);
|
|
293
|
+
return fork(started);
|
|
294
|
+
});
|
|
295
|
+
}).pipe(Effect.provideContext(context));
|
|
296
|
+
function fork(started) {
|
|
297
|
+
return Effect.forEach(started, (activation) => Effect.forkDetach(run(activation), { uninterruptible: true }), { discard: true });
|
|
298
|
+
}
|
|
299
|
+
const close = Effect.suspend(() => {
|
|
300
|
+
if (!closed) {
|
|
301
|
+
closed = true;
|
|
302
|
+
for (const lane of lanes.values()) apply(lane.cancel());
|
|
303
|
+
}
|
|
304
|
+
const running = [...activations.values()];
|
|
305
|
+
for (const { stop } of running) Deferred.doneUnsafe(stop, Exit.succeed("close"));
|
|
306
|
+
return Effect.forEach(running, ({ ended }) => Deferred.await(ended), { discard: true });
|
|
307
|
+
});
|
|
308
|
+
yield* Effect.addFinalizer(() => close);
|
|
309
|
+
return {
|
|
310
|
+
wake: (key) => Effect.uninterruptible(Effect.suspend(() => {
|
|
311
|
+
if (closed) return Effect.fail({
|
|
312
|
+
_tag: "LanesClosed",
|
|
313
|
+
key
|
|
314
|
+
});
|
|
315
|
+
const woken = (lanes.get(key) ?? Lane.create(key)).wake({
|
|
316
|
+
running: activations.size,
|
|
317
|
+
queued: queue.length
|
|
318
|
+
}, limits);
|
|
319
|
+
if (!woken.ok) return Effect.fail(woken.error);
|
|
320
|
+
return Effect.as(fork(apply(woken.value)), wakeResult(woken.value.events));
|
|
321
|
+
})),
|
|
322
|
+
cancel: (key) => Effect.suspend(() => {
|
|
323
|
+
const lane = lanes.get(key);
|
|
324
|
+
if (lane !== void 0) apply(lane.cancel());
|
|
325
|
+
const activation = activations.get(key);
|
|
326
|
+
if (activation === void 0) return Effect.void;
|
|
327
|
+
Deferred.doneUnsafe(activation.stop, Exit.succeed("cancel"));
|
|
328
|
+
return Deferred.await(activation.ended);
|
|
329
|
+
}),
|
|
330
|
+
status: Effect.sync(() => ({
|
|
331
|
+
running: activations.size,
|
|
332
|
+
queued: queue.length,
|
|
333
|
+
closed,
|
|
334
|
+
lanes: [...activations.keys(), ...queue].map((key) => laneOf(key).toSnapshot())
|
|
335
|
+
})),
|
|
336
|
+
close
|
|
337
|
+
};
|
|
338
|
+
});
|
|
339
|
+
}
|
|
340
|
+
//#endregion
|
|
341
|
+
export { createLanes };
|
package/dist/lease.d.ts
ADDED
|
@@ -0,0 +1,283 @@
|
|
|
1
|
+
import { PlatformService } from "@rivus/agent-kit/platform/effect";
|
|
2
|
+
import * as Effect from "effect/Effect";
|
|
3
|
+
import * as Layer from "effect/Layer";
|
|
4
|
+
import * as Context from "effect/Context";
|
|
5
|
+
import { Result } from "@rivus/agent-kit/catalog";
|
|
6
|
+
import * as Scope from "effect/Scope";
|
|
7
|
+
//#region src/lease/domain/lease/value-objects/holder.d.ts
|
|
8
|
+
/**
|
|
9
|
+
* The process that holds a lease, with the same shape as the Platform's `ProcessIdentity`. The start time tells a
|
|
10
|
+
* reused pid apart from the holder; the boot id tells a process of an earlier boot apart.
|
|
11
|
+
*/
|
|
12
|
+
interface Holder {
|
|
13
|
+
readonly host: string;
|
|
14
|
+
readonly bootId: string;
|
|
15
|
+
readonly pid: number;
|
|
16
|
+
readonly startTime: number;
|
|
17
|
+
}
|
|
18
|
+
/** `unknown` when the holder runs on another host, where the observer cannot look it up. */
|
|
19
|
+
type HolderLiveness = "alive" | "dead" | "unknown";
|
|
20
|
+
//#endregion
|
|
21
|
+
//#region src/lease/domain/lease/errors/lease-held.d.ts
|
|
22
|
+
/** Another acquisition holds the lease and is alive and renewing, as far as the observer can tell. */
|
|
23
|
+
interface LeaseHeld {
|
|
24
|
+
readonly _tag: "LeaseHeld";
|
|
25
|
+
readonly key: string;
|
|
26
|
+
readonly generation: number;
|
|
27
|
+
readonly holder: Holder;
|
|
28
|
+
readonly holderId: string;
|
|
29
|
+
}
|
|
30
|
+
//#endregion
|
|
31
|
+
//#region src/lease/domain/lease/errors/lease-lost.d.ts
|
|
32
|
+
/**
|
|
33
|
+
* This acquisition no longer holds the lease: another one took it over or released it, the record vanished, or no
|
|
34
|
+
* renewal could be confirmed within the TTL.
|
|
35
|
+
*/
|
|
36
|
+
interface LeaseLost {
|
|
37
|
+
readonly _tag: "LeaseLost";
|
|
38
|
+
readonly key: string;
|
|
39
|
+
readonly generation: number;
|
|
40
|
+
readonly reason: "taken-over" | "released" | "missing" | "expired";
|
|
41
|
+
}
|
|
42
|
+
//#endregion
|
|
43
|
+
//#region src/lease/domain/lease/value-objects/lease-snapshot.d.ts
|
|
44
|
+
/** A lease record as stores keep it. A released lease stays as a tombstone with no holder. */
|
|
45
|
+
interface LeaseSnapshot {
|
|
46
|
+
readonly key: string;
|
|
47
|
+
/** The fencing token: +1 on every acquisition, unchanged by renewals and release. */
|
|
48
|
+
readonly generation: number;
|
|
49
|
+
/** +1 on every write; stores compare it to write only over the record a caller read. */
|
|
50
|
+
readonly revision: number;
|
|
51
|
+
readonly holder: Holder | null;
|
|
52
|
+
/** Names one acquisition, so that two acquisitions by the same process are told apart. */
|
|
53
|
+
readonly holderId: string | null;
|
|
54
|
+
/** Wall-clock time of the last write, for diagnostics; expiry is judged with the observer's monotonic clock. */
|
|
55
|
+
readonly renewedAt: number;
|
|
56
|
+
}
|
|
57
|
+
//#endregion
|
|
58
|
+
//#region src/lease/domain/lease/policies/acquisition.d.ts
|
|
59
|
+
/** What the observer found out about the current holder before trying to acquire. */
|
|
60
|
+
interface AcquisitionView {
|
|
61
|
+
/** `isFresh` for the record as read. */
|
|
62
|
+
readonly fresh: boolean;
|
|
63
|
+
/** `holderLiveness` for its holder; irrelevant for a tombstone. */
|
|
64
|
+
readonly liveness: HolderLiveness;
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* A lease can be taken when there is no record, the record is a tombstone, its holder is dead, or it has not been
|
|
68
|
+
* renewed within the TTL as the observer saw it. A live or unknown holder that keeps renewing keeps the lease.
|
|
69
|
+
*/
|
|
70
|
+
export declare function canAcquire(lease: LeaseSnapshot | undefined, view: AcquisitionView): boolean;
|
|
71
|
+
//#endregion
|
|
72
|
+
//#region src/lease/domain/lease/errors/fence-rejected.d.ts
|
|
73
|
+
/** A fenced write carried an older generation than the lease record or the protected resource has seen. */
|
|
74
|
+
interface FenceRejected {
|
|
75
|
+
readonly _tag: "FenceRejected";
|
|
76
|
+
readonly key: string;
|
|
77
|
+
/** The generation of the rejected token. */
|
|
78
|
+
readonly generation: number;
|
|
79
|
+
/** The generation that wins, or `undefined` when the record is missing. */
|
|
80
|
+
readonly current: number | undefined;
|
|
81
|
+
}
|
|
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
|
|
90
|
+
//#region src/lease/domain/lease/value-objects/fencing-token.d.ts
|
|
91
|
+
/**
|
|
92
|
+
* Proof of holding a lease, passed with every fenced write. A resource that can compare atomically keeps the highest
|
|
93
|
+
* generation it has seen for the key and refuses a smaller one (`checkFence`).
|
|
94
|
+
*/
|
|
95
|
+
interface FencingToken {
|
|
96
|
+
readonly key: string;
|
|
97
|
+
readonly generation: number;
|
|
98
|
+
}
|
|
99
|
+
//#endregion
|
|
100
|
+
//#region src/lease/domain/lease/policies/fence-check.d.ts
|
|
101
|
+
/** The token the next acquisition of `key` receives: one generation above the record, 1 for a new key. */
|
|
102
|
+
export declare function nextFencingToken(current: LeaseSnapshot | undefined, key: string): FencingToken;
|
|
103
|
+
/**
|
|
104
|
+
* The check a protected resource runs inside its own atomic write: `lastSeen` is the highest token it has accepted
|
|
105
|
+
* for the key. A smaller generation is refused; an equal one is the same holder writing again. On success, store the
|
|
106
|
+
* returned token as the new `lastSeen`.
|
|
107
|
+
*/
|
|
108
|
+
export declare function checkFence(lastSeen: FencingToken | undefined, token: FencingToken): Result<FencingToken, FenceRejected>;
|
|
109
|
+
//#endregion
|
|
110
|
+
//#region src/lease/domain/lease/policies/freshness.d.ts
|
|
111
|
+
/**
|
|
112
|
+
* When an observer last saw a lease record change. Monotonic clocks of two processes cannot be compared, so each
|
|
113
|
+
* observer times a revision from the moment it first read it, as client-go's leader election does.
|
|
114
|
+
*/
|
|
115
|
+
interface LeaseObservation {
|
|
116
|
+
readonly revision: number;
|
|
117
|
+
/** The observer's monotonic time when it first read `revision`. */
|
|
118
|
+
readonly observedAt: number;
|
|
119
|
+
}
|
|
120
|
+
/**
|
|
121
|
+
* Whether a held lease is still valid for this observer: its record changed less than `ttlMs` ago by the observer's
|
|
122
|
+
* monotonic clock. A record whose revision the observation has not seen has just changed, so it is fresh. A
|
|
123
|
+
* tombstone is never fresh.
|
|
124
|
+
*/
|
|
125
|
+
export declare function isFresh(lease: LeaseSnapshot, observation: LeaseObservation, now: number, ttlMs: number): boolean;
|
|
126
|
+
//#endregion
|
|
127
|
+
//#region src/lease/domain/lease/policies/holder-liveness.d.ts
|
|
128
|
+
/**
|
|
129
|
+
* Judges a recorded holder from the observer's machine. `current` is what the observer's platform reports for the
|
|
130
|
+
* holder's pid now (`identify(holder.pid)`), `undefined` when no such process exists. A process of an earlier boot is
|
|
131
|
+
* dead; a pid now owned by a process with another start time is a reused pid, so the holder is dead too.
|
|
132
|
+
*
|
|
133
|
+
* Holder and observer must see the same processes: same host name and boot id are taken to mean one process table.
|
|
134
|
+
* Containers that share the host's name and kernel but have their own PID namespaces break that, and a live holder
|
|
135
|
+
* in another namespace looks dead. A host whose name changed makes its earlier holders look remote (`unknown`), so
|
|
136
|
+
* they are judged by the TTL alone.
|
|
137
|
+
*/
|
|
138
|
+
export declare function holderLiveness(holder: Holder, observer: Holder, current: Holder | undefined): HolderLiveness;
|
|
139
|
+
//#endregion
|
|
140
|
+
//#region src/lease/application/ports.d.ts
|
|
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";
|
|
156
|
+
readonly key: string;
|
|
157
|
+
/**
|
|
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.
|
|
161
|
+
*/
|
|
162
|
+
readonly reason: "busy" | "io" | "invalid-record" | "unsupported-schema" | "unavailable";
|
|
163
|
+
readonly message: string;
|
|
164
|
+
readonly cause?: unknown;
|
|
165
|
+
}
|
|
166
|
+
/**
|
|
167
|
+
* Where lease records live. Calling convention for every implementation:
|
|
168
|
+
*
|
|
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.
|
|
173
|
+
* - Records are never deleted. A release writes a tombstone, so a key's generation never goes back.
|
|
174
|
+
* - `fence(key)` holds one guard per key across those processes until its Scope closes; waiting for it can be
|
|
175
|
+
* interrupted, and a guard whose holder process exited is freed without waiting for a TTL.
|
|
176
|
+
* - Only local directories are supported; neither SQLite's locks nor lock files are reliable on NFS.
|
|
177
|
+
*
|
|
178
|
+
* The lease's `runFenced` takes the guard and re-reads the holder before the work starts. That is equivalent to the
|
|
179
|
+
* protected resource checking the fencing token itself only when every writer of the resource goes through the same
|
|
180
|
+
* repository on the same machine; a resource that can compare atomically should also run `checkFence`.
|
|
181
|
+
*/
|
|
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>;
|
|
186
|
+
}
|
|
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 {}
|
|
191
|
+
//#endregion
|
|
192
|
+
//#region src/lease/infra/repository/file-lease-repository.d.ts
|
|
193
|
+
interface FileLeaseRepositoryOptions {
|
|
194
|
+
/** An existing local directory that holds one record file and its lock files per key. */
|
|
195
|
+
readonly dir: string;
|
|
196
|
+
}
|
|
197
|
+
/**
|
|
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.
|
|
204
|
+
*/
|
|
205
|
+
export declare function fileLeaseRepository(options: FileLeaseRepositoryOptions): Layer.Layer<LeaseRepository, never, PlatformService>;
|
|
206
|
+
//#endregion
|
|
207
|
+
//#region src/lease/infra/repository/memory-lease-repository.d.ts
|
|
208
|
+
/**
|
|
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.
|
|
211
|
+
*/
|
|
212
|
+
export declare function memoryLeaseRepository(): Layer.Layer<LeaseRepository>;
|
|
213
|
+
//#endregion
|
|
214
|
+
//#region src/lease/infra/repository/sqlite-lease-repository.d.ts
|
|
215
|
+
interface SqliteLeaseRepositoryOptions {
|
|
216
|
+
/**
|
|
217
|
+
* The database file, in an existing local directory. The per-key fence locks of `runFenced` live next to it as
|
|
218
|
+
* `<path>.<key>.fence` (plus a `.holder` file each), so give the repository a directory of its own or a distinct
|
|
219
|
+
* name.
|
|
220
|
+
*/
|
|
221
|
+
readonly path: string;
|
|
222
|
+
}
|
|
223
|
+
/**
|
|
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.
|
|
229
|
+
*/
|
|
230
|
+
export declare function sqliteLeaseRepository(options: SqliteLeaseRepositoryOptions): Layer.Layer<LeaseRepository, LeaseRepositoryFailure, PlatformService>;
|
|
231
|
+
//#endregion
|
|
232
|
+
//#region src/lease/application/use-cases/lease-manager.d.ts
|
|
233
|
+
interface LeaseConfig {
|
|
234
|
+
/** How long a lease stays valid without a renewal, as each observer's monotonic clock measures it. */
|
|
235
|
+
readonly ttlMs: number;
|
|
236
|
+
/** How often the holder renews. Two heartbeats must fit in the TTL, so one late heartbeat does not lose it. */
|
|
237
|
+
readonly heartbeatMs: number;
|
|
238
|
+
/** How often `acquire({ wait: true })` tries again; `heartbeatMs` by default. */
|
|
239
|
+
readonly retryMs?: number;
|
|
240
|
+
}
|
|
241
|
+
interface AcquireOptions {
|
|
242
|
+
/**
|
|
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.
|
|
246
|
+
*/
|
|
247
|
+
readonly wait?: boolean;
|
|
248
|
+
}
|
|
249
|
+
/** A held lease. It belongs to the Scope `acquire` ran in: closing the Scope stops the heartbeat and releases it. */
|
|
250
|
+
interface LeaseHandle {
|
|
251
|
+
readonly key: string;
|
|
252
|
+
readonly token: FencingToken;
|
|
253
|
+
/**
|
|
254
|
+
* Fails with `LeaseLost` once the heartbeat finds the lease taken over, released or missing, or cannot confirm a
|
|
255
|
+
* renewal within the TTL; it never succeeds. Race other work against it to stop that work on loss.
|
|
256
|
+
*/
|
|
257
|
+
readonly lost: Effect.Effect<never, LeaseLost>;
|
|
258
|
+
/**
|
|
259
|
+
* Runs `work` under the repository's per-key fence, after re-reading that this acquisition still holds the lease, and
|
|
260
|
+
* passes it the fencing token for the writes it makes. Waiting for the fence stops when the lease is lost. When the
|
|
261
|
+
* lease is lost while `work` runs, `work` is interrupted and the result fails with `LeaseLost`.
|
|
262
|
+
*
|
|
263
|
+
* The fence is released when `work`'s fiber has ended, so a successor's fenced work starts only after that. An
|
|
264
|
+
* interrupted fiber ends at once, though, while a Promise it started (`Effect.tryPromise`) keeps running: put a
|
|
265
|
+
* write that cannot be cancelled in `Effect.uninterruptible`, which keeps the fence until the write settles, or pass
|
|
266
|
+
* it the AbortSignal `tryPromise` provides and settle only after the write has stopped. A resource that can compare
|
|
267
|
+
* atomically should also keep the highest token it has seen and refuse older ones with `checkFence`.
|
|
268
|
+
*/
|
|
269
|
+
runFenced<A, E, R>(work: (token: FencingToken) => Effect.Effect<A, E, R>): Effect.Effect<A, E | LeaseLost | FenceRejected | LeaseRepositoryFailure, Exclude<R, Scope.Scope>>;
|
|
270
|
+
}
|
|
271
|
+
interface LeaseManager {
|
|
272
|
+
/**
|
|
273
|
+
* Takes the lease for `key` in the caller's Scope and keeps renewing it there. Fails with `LeaseHeld` while a live
|
|
274
|
+
* holder keeps it fresh; a dead holder (same host) is taken over at once, a silent one after the TTL. Throws an
|
|
275
|
+
* `AgentKitError` (as a defect) for an empty key.
|
|
276
|
+
*/
|
|
277
|
+
acquire(key: string, options?: AcquireOptions): Effect.Effect<LeaseHandle, LeaseHeld | LeaseRepositoryFailure, Scope.Scope>;
|
|
278
|
+
read(key: string): Effect.Effect<LeaseSnapshot | undefined, LeaseRepositoryFailure>;
|
|
279
|
+
}
|
|
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>;
|
|
282
|
+
//#endregion
|
|
283
|
+
export type { AcquireOptions, AcquisitionView, FenceRejected, FencingToken, FileLeaseRepositoryOptions, Holder, HolderLiveness, LeaseConfig, LeaseConfigInvalid, LeaseHandle, LeaseHeld, LeaseLost, LeaseManager, LeaseObservation, LeaseRepositoryFailure, LeaseRepositoryShape, LeaseSnapshot, RevisionConflict, SqliteLeaseRepositoryOptions };
|