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