@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/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 PerfectPan
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
# @rivus/agent-kit-collab
|
|
2
|
+
|
|
3
|
+
Collaboration primitives for processes that run coding agents side by side: a lease with fencing tokens, so that
|
|
4
|
+
one process at a time owns a task and a stale owner's writes are refused, a single-instance process lock, and lanes
|
|
5
|
+
that run work per key, one activation at a time, under a global concurrency cap. The package knows no particular
|
|
6
|
+
agent; it reaches files, processes, clocks and SQLite through the `Platform` of
|
|
7
|
+
[`@rivus/agent-kit`](https://www.npmjs.com/package/@rivus/agent-kit).
|
|
8
|
+
|
|
9
|
+
Status: 0.x, with one lockstep release policy for this package and `@rivus/agent-kit` (same version). A minor release may contain breaking
|
|
10
|
+
changes.
|
|
11
|
+
|
|
12
|
+
## Install
|
|
13
|
+
|
|
14
|
+
```bash
|
|
15
|
+
npm install @rivus/agent-kit @rivus/agent-kit-collab
|
|
16
|
+
# for /lease and /lanes, which are Effect entries:
|
|
17
|
+
npm install effect@4.0.1
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
Select a verified non-placeholder release that exports the entries you need, with both packages at the same
|
|
21
|
+
version. See [Adopting agent-kit](https://github.com/PerfectPan/agent-kit/blob/main/docs/development/adoption.md) for migration checks and rollback.
|
|
22
|
+
|
|
23
|
+
`@rivus/agent-kit` is a peer dependency, so the process holds one copy of the platform types. `effect` 4.0.1 is an
|
|
24
|
+
optional peer that only `/lease` and `/lanes` need. ESM only, no side effects, Node.js 22.13 or later on darwin or
|
|
25
|
+
linux (the locks identify processes by boot id, pid and start time, which the platform does not support on win32).
|
|
26
|
+
Every lock supports local directories only; neither SQLite's locks nor lock files are reliable on NFS. Holders are
|
|
27
|
+
judged by host name, boot id, pid and start time, so all processes that share a lock must see one process table:
|
|
28
|
+
containers that share the host's name and kernel but not its PID namespace are not supported, and a host renamed
|
|
29
|
+
while it holds locks makes its holders look remote (judged by the TTL alone; a lock file of a dead holder then stays
|
|
30
|
+
until removed).
|
|
31
|
+
|
|
32
|
+
## Entries
|
|
33
|
+
|
|
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`, `LeaseStore`, `sqliteLeaseStore`, `fileLeaseStore`, `memoryLeaseStore`; rules `isFresh`, `canAcquire`, `nextFencingToken`, `checkFence`, `holderLiveness` | Effect |
|
|
38
|
+
| `@rivus/agent-kit-collab/lanes` | `createLanes` | Effect |
|
|
39
|
+
|
|
40
|
+
### Process lock
|
|
41
|
+
|
|
42
|
+
```ts
|
|
43
|
+
import { createNodePlatform } from "@rivus/agent-kit/node";
|
|
44
|
+
import { acquireProcessLock } from "@rivus/agent-kit-collab/process-lock";
|
|
45
|
+
|
|
46
|
+
const lock = await acquireProcessLock(createNodePlatform(), "/var/run/my-daemon/daemon.lock");
|
|
47
|
+
if (!lock.ok) {
|
|
48
|
+
console.error(`already running (pid ${lock.error.holder?.pid ?? "unknown"})`);
|
|
49
|
+
process.exit(1);
|
|
50
|
+
}
|
|
51
|
+
// ... run; the kernel releases the lock if the process dies, or call:
|
|
52
|
+
await lock.value.release();
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
With `platform.sqlite` the lock is a SQLite database held with `locking_mode=EXCLUSIVE`; the kernel drops it the
|
|
56
|
+
moment the holder exits or crashes, and nothing is reclaimed. The holder's identity goes into `<path>.holder` for
|
|
57
|
+
diagnostics only. Without SQLite the lock is a file that holds the identity; a lock file whose holder died on this
|
|
58
|
+
host is reclaimed on the next attempt, one reclaimer at a time. Pass `{ wait: true, signal }` to wait for the holder
|
|
59
|
+
instead of getting `ProcessLockHeld` at once.
|
|
60
|
+
|
|
61
|
+
### Lease
|
|
62
|
+
|
|
63
|
+
```ts
|
|
64
|
+
import { NodePlatformLive } from "@rivus/agent-kit/node/effect";
|
|
65
|
+
import { createLeaseManager, sqliteLeaseStore } from "@rivus/agent-kit-collab/lease";
|
|
66
|
+
import { Effect, Layer } from "effect";
|
|
67
|
+
|
|
68
|
+
const LeaseLive = sqliteLeaseStore({ path: "/var/lib/my-app/leases.db" }).pipe(Layer.provideMerge(NodePlatformLive));
|
|
69
|
+
|
|
70
|
+
const program = Effect.scoped(
|
|
71
|
+
Effect.gen(function* () {
|
|
72
|
+
const leases = yield* createLeaseManager({ ttlMs: 60_000, heartbeatMs: 15_000 });
|
|
73
|
+
const lease = yield* leases.acquire("task:42"); // fails with LeaseHeld while another live holder renews it
|
|
74
|
+
// Writes carry the fencing token; when the lease is lost, the fenced work is interrupted.
|
|
75
|
+
yield* lease.runFenced((token) => saveResult(token));
|
|
76
|
+
// Stop other work on loss by racing it against lease.lost.
|
|
77
|
+
yield* longRunningWork.pipe(Effect.raceFirst(lease.lost));
|
|
78
|
+
})
|
|
79
|
+
);
|
|
80
|
+
|
|
81
|
+
await Effect.runPromiseExit(program.pipe(Effect.provide(LeaseLive)));
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
- A lease has one holder. Its generation, the fencing token, grows on every acquisition and never goes back: a
|
|
85
|
+
release keeps the record as a tombstone. Losing the lease and getting it again gives a new generation, so the old
|
|
86
|
+
handle's token is refused.
|
|
87
|
+
- The holder renews every `heartbeatMs` in the Scope that acquired it; `heartbeatMs × 2` must not exceed `ttlMs`.
|
|
88
|
+
Closing the Scope stops the heartbeat and releases the lease.
|
|
89
|
+
- A holder on the same host whose process is gone (or whose pid now belongs to another process) is taken over at
|
|
90
|
+
once. A holder that is alive but stopped renewing is taken over after the TTL, measured by the observer's own
|
|
91
|
+
monotonic clock from the moment it first saw the current record.
|
|
92
|
+
- `runFenced` holds the store's per-key fence and re-reads the record before the work starts; waiting for the fence
|
|
93
|
+
stops when the lease is lost. The fence is released when the work's fiber ends, so a successor's fenced work starts
|
|
94
|
+
after that. An interrupted fiber ends at once, but a Promise it started keeps running: put a write that cannot be
|
|
95
|
+
cancelled in `Effect.uninterruptible` (the fence then waits for it to settle), or pass it the AbortSignal that
|
|
96
|
+
`Effect.tryPromise` provides and let it settle only once the write has stopped.
|
|
97
|
+
- Guard plus re-read is as strong as a check by the protected resource itself only when every writer goes through
|
|
98
|
+
the same store on the same machine; a resource that can compare atomically should keep the highest token it has
|
|
99
|
+
seen and call `checkFence`.
|
|
100
|
+
- `sqliteLeaseStore` compares revisions inside `BEGIN IMMEDIATE` transactions. `fileLeaseStore` is the fallback
|
|
101
|
+
without SQLite: one JSON file per key, written under a per-key lock file. `memoryLeaseStore` serves one process.
|
|
102
|
+
|
|
103
|
+
Expected failures are typed values with a `_tag`: `LeaseHeld`, `LeaseLost`, `FenceRejected`, `LeaseConfigInvalid`
|
|
104
|
+
and `LeaseStoreFailure`. The kit runs no Effect itself; run the program at your application's assembly root.
|
|
105
|
+
|
|
106
|
+
### Lanes
|
|
107
|
+
|
|
108
|
+
```ts
|
|
109
|
+
import { createLanes } from "@rivus/agent-kit-collab/lanes";
|
|
110
|
+
import { Effect } from "effect";
|
|
111
|
+
|
|
112
|
+
const program = Effect.scoped(
|
|
113
|
+
Effect.gen(function* () {
|
|
114
|
+
const lanes = yield* createLanes({
|
|
115
|
+
maxConcurrent: 4, // activations running at once across all keys
|
|
116
|
+
maxQueued: 32, // keys waiting for a free slot
|
|
117
|
+
turnTimeoutMs: 600_000, // an activation running longer is interrupted
|
|
118
|
+
activate: (key) => runTurn(key), // the work for one key, in a Scope of its own
|
|
119
|
+
onExit: (exit) => report(exit) // ActivationSucceeded | ActivationFailed | ActivationInterrupted
|
|
120
|
+
});
|
|
121
|
+
yield* lanes.wake("room:1"); // "started", "queued" or "coalesced"; never waits for the activation
|
|
122
|
+
// ... closing the Scope interrupts the running activations and waits for them.
|
|
123
|
+
})
|
|
124
|
+
);
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
- A lane runs at most one activation at a time. Every wake that arrives before an activation starts is served by
|
|
128
|
+
it: waking a running lane any number of times leaves one activation to follow the running one, and waking a
|
|
129
|
+
queued lane changes nothing.
|
|
130
|
+
- An idle lane starts while a slot is free and nobody waits; otherwise it waits at the end of the queue, and when
|
|
131
|
+
`maxQueued` lanes already wait, `wake` fails with `LaneQueueFull`. A freed slot goes to the lane that has waited
|
|
132
|
+
longest, so a lane woken again while it ran waits behind the lanes queued meanwhile. Without `maxQueued`, the queue
|
|
133
|
+
holds at most one entry per key.
|
|
134
|
+
- `cancel(key)` drops the wakes the key owes and interrupts its running activation, and returns once the activation
|
|
135
|
+
has ended (its finalizers and `onExit` included). `close` refuses later wakes with `LanesClosed`, drops the queue,
|
|
136
|
+
interrupts every running activation and waits for them; closing the Scope that created the lanes runs it.
|
|
137
|
+
- An activation runs with the context `createLanes` ran in. Its Scope closes when it ends, and the lane moves on only
|
|
138
|
+
after that: an interrupted activation's finalizers have run before the next activation of any key takes its slot.
|
|
139
|
+
- `onExit` hears how each activation ended. One that succeeded or failed by itself is reported so even when a cancel,
|
|
140
|
+
close or timeout arrives while its Scope closes; `ActivationInterrupted` means the lanes cut it short, or stopped it
|
|
141
|
+
before it started, in which case `activate` was never called. `onExit` runs uninterruptibly, so `cancel` and `close`
|
|
142
|
+
wait for it; it must not wait for them in turn, and a defect it raises is logged as a warning.
|
|
143
|
+
|
|
144
|
+
Nothing is persisted: lanes live in one process. Expected failures are typed values with a `_tag`:
|
|
145
|
+
`LaneQueueFull`, `LanesClosed` and `LanesConfigInvalid`.
|
|
146
|
+
|
|
147
|
+
## License
|
|
148
|
+
|
|
149
|
+
MIT
|
|
@@ -0,0 +1,311 @@
|
|
|
1
|
+
import * as z from "zod/mini";
|
|
2
|
+
import { err, ok } from "@rivus/agent-kit/catalog";
|
|
3
|
+
//#region src/process-lock/application/holder.ts
|
|
4
|
+
const stampSchema = z.object({
|
|
5
|
+
host: z.string(),
|
|
6
|
+
bootId: z.string(),
|
|
7
|
+
pid: z.number(),
|
|
8
|
+
startTime: z.number(),
|
|
9
|
+
acquiredAt: z.number(),
|
|
10
|
+
nonce: z.string()
|
|
11
|
+
});
|
|
12
|
+
function newStamp(platform) {
|
|
13
|
+
const { host, bootId, pid, startTime } = platform.process.self;
|
|
14
|
+
return {
|
|
15
|
+
host,
|
|
16
|
+
bootId,
|
|
17
|
+
pid,
|
|
18
|
+
startTime,
|
|
19
|
+
acquiredAt: platform.clock.now(),
|
|
20
|
+
nonce: crypto.randomUUID()
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
function holderOf(stamp) {
|
|
24
|
+
const { host, bootId, pid, startTime, acquiredAt } = stamp;
|
|
25
|
+
return {
|
|
26
|
+
host,
|
|
27
|
+
bootId,
|
|
28
|
+
pid,
|
|
29
|
+
startTime,
|
|
30
|
+
acquiredAt
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
async function readStamp(platform, path) {
|
|
34
|
+
const text = await readText(platform, path);
|
|
35
|
+
if (text === void 0) return;
|
|
36
|
+
let json;
|
|
37
|
+
try {
|
|
38
|
+
json = JSON.parse(text);
|
|
39
|
+
} catch {
|
|
40
|
+
return {
|
|
41
|
+
text,
|
|
42
|
+
stamp: void 0
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
const parsed = stampSchema.safeParse(json);
|
|
46
|
+
return {
|
|
47
|
+
text,
|
|
48
|
+
stamp: parsed.success ? parsed.data : void 0
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
/** The text of a small file, `undefined` when it does not exist. */
|
|
52
|
+
async function readText(platform, path) {
|
|
53
|
+
const decoder = new TextDecoder();
|
|
54
|
+
let text = "";
|
|
55
|
+
try {
|
|
56
|
+
for await (const chunk of platform.fs.read(path)) text += decoder.decode(chunk, { stream: true });
|
|
57
|
+
} catch (error) {
|
|
58
|
+
if (typeof error === "object" && error !== null && error.code === "ENOENT") return;
|
|
59
|
+
throw error;
|
|
60
|
+
}
|
|
61
|
+
return text + decoder.decode();
|
|
62
|
+
}
|
|
63
|
+
//#endregion
|
|
64
|
+
//#region src/lease/domain/lease/policies/holder-liveness.ts
|
|
65
|
+
/**
|
|
66
|
+
* Judges a recorded holder from the observer's machine. `current` is what the observer's platform reports for the
|
|
67
|
+
* holder's pid now (`identify(holder.pid)`), `undefined` when no such process exists. A process of an earlier boot is
|
|
68
|
+
* dead; a pid now owned by a process with another start time is a reused pid, so the holder is dead too.
|
|
69
|
+
*
|
|
70
|
+
* Holder and observer must see the same processes: same host name and boot id are taken to mean one process table.
|
|
71
|
+
* Containers that share the host's name and kernel but have their own PID namespaces break that, and a live holder
|
|
72
|
+
* in another namespace looks dead. A host whose name changed makes its earlier holders look remote (`unknown`), so
|
|
73
|
+
* they are judged by the TTL alone.
|
|
74
|
+
*/
|
|
75
|
+
function holderLiveness(holder, observer, current) {
|
|
76
|
+
if (holder.host !== observer.host) return "unknown";
|
|
77
|
+
if (holder.bootId !== observer.bootId || current === void 0) return "dead";
|
|
78
|
+
return current.bootId === holder.bootId && current.startTime === holder.startTime ? "alive" : "dead";
|
|
79
|
+
}
|
|
80
|
+
//#endregion
|
|
81
|
+
//#region src/process-lock/application/file-lock.ts
|
|
82
|
+
/**
|
|
83
|
+
* How long a lock file may stay empty or unreadable before it counts as abandoned. A holder writes its stamp right
|
|
84
|
+
* after creating the file, so only a process that died in between leaves it empty. A holder stalled for longer than
|
|
85
|
+
* this between the two steps loses its file to a reclaimer and may then overwrite the reclaimer's stamp with its own;
|
|
86
|
+
* the read-back below catches the overwrite only when it lands before the reclaimer reads back.
|
|
87
|
+
*/
|
|
88
|
+
const UNSTAMPED_GRACE_MS = 3e4;
|
|
89
|
+
/** `.stale`, `.stale.stale`, …: each level serializes the reclaim of the level below. */
|
|
90
|
+
const MAX_RECLAIM_DEPTH = 3;
|
|
91
|
+
/**
|
|
92
|
+
* Tries once to take the lock file at `path`; it never waits for a live holder. The file holds the holder's stamp. A
|
|
93
|
+
* file whose holder is dead (same host, and an earlier boot, no such pid or a reused pid) is reclaimed, as
|
|
94
|
+
* npm/lockfile does with its `.STALE` lock: only the reclaimer that takes `<path>.stale` may remove it, and only after
|
|
95
|
+
* reading the same dead stamp again, so two reclaimers cannot each remove the other's fresh lock.
|
|
96
|
+
*
|
|
97
|
+
* Weaker than the SQLite lock: a holder on another host is never judged dead, and neither is a dead holder's stamp
|
|
98
|
+
* written before this host's name changed, so such a file stays until it is removed by hand; a stamp is written after
|
|
99
|
+
* the file is created, a crash between the two leaves an empty file for `UNSTAMPED_GRACE_MS`, and a holder stalled
|
|
100
|
+
* longer than that between the two can end up holding the lock together with its reclaimer.
|
|
101
|
+
*/
|
|
102
|
+
async function tryFileLock(platform, path, stamp, depth = 0) {
|
|
103
|
+
let reclaimed = false;
|
|
104
|
+
for (let turn = 0; turn < 8; turn += 1) {
|
|
105
|
+
if (await platform.fs.createExclusive(path)) {
|
|
106
|
+
try {
|
|
107
|
+
await platform.fs.writeAtomic(path, JSON.stringify(stamp));
|
|
108
|
+
} catch (error) {
|
|
109
|
+
await platform.fs.remove(path);
|
|
110
|
+
throw error;
|
|
111
|
+
}
|
|
112
|
+
const written = await readStamp(platform, path);
|
|
113
|
+
if (written?.stamp?.nonce !== stamp.nonce) return {
|
|
114
|
+
acquired: false,
|
|
115
|
+
stamp: written?.stamp
|
|
116
|
+
};
|
|
117
|
+
return {
|
|
118
|
+
acquired: true,
|
|
119
|
+
stamp,
|
|
120
|
+
release: () => releaseFileLock(platform, path, stamp.nonce)
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
const observed = await readStamp(platform, path);
|
|
124
|
+
if (observed === void 0) continue;
|
|
125
|
+
if (reclaimed || depth >= MAX_RECLAIM_DEPTH || !await abandoned(platform, path, observed)) return {
|
|
126
|
+
acquired: false,
|
|
127
|
+
stamp: observed.stamp
|
|
128
|
+
};
|
|
129
|
+
reclaimed = await reclaim(platform, path, observed, stamp, depth);
|
|
130
|
+
if (!reclaimed) return {
|
|
131
|
+
acquired: false,
|
|
132
|
+
stamp: observed.stamp
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
return {
|
|
136
|
+
acquired: false,
|
|
137
|
+
stamp: (await readStamp(platform, path))?.stamp
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
async function abandoned(platform, path, observed) {
|
|
141
|
+
const { stamp } = observed;
|
|
142
|
+
if (stamp === void 0) {
|
|
143
|
+
const stat = await platform.fs.stat(path);
|
|
144
|
+
return stat !== void 0 && platform.clock.now() - stat.mtimeMs > UNSTAMPED_GRACE_MS;
|
|
145
|
+
}
|
|
146
|
+
const { self } = platform.process;
|
|
147
|
+
return holderLiveness(stamp, self, platform.process.identify(stamp.pid)) === "dead";
|
|
148
|
+
}
|
|
149
|
+
/** Removes the abandoned lock file while holding `<path>.stale`; `false` when another reclaimer holds that. */
|
|
150
|
+
async function reclaim(platform, path, observed, stamp, depth) {
|
|
151
|
+
const guard = await tryFileLock(platform, `${path}.stale`, {
|
|
152
|
+
...stamp,
|
|
153
|
+
nonce: crypto.randomUUID()
|
|
154
|
+
}, depth + 1);
|
|
155
|
+
if (!guard.acquired) return false;
|
|
156
|
+
try {
|
|
157
|
+
const current = await readStamp(platform, path);
|
|
158
|
+
if (current !== void 0 && current.text === observed.text && await abandoned(platform, path, current)) await platform.fs.remove(path);
|
|
159
|
+
return true;
|
|
160
|
+
} finally {
|
|
161
|
+
await guard.release();
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
async function releaseFileLock(platform, path, nonce) {
|
|
165
|
+
if ((await readStamp(platform, path))?.stamp?.nonce === nonce) await platform.fs.remove(path);
|
|
166
|
+
}
|
|
167
|
+
//#endregion
|
|
168
|
+
//#region src/process-lock/application/sqlite-lock.ts
|
|
169
|
+
/** SQLITE_BUSY and SQLITE_LOCKED, the primary codes of their extended codes: another connection holds the lock. */
|
|
170
|
+
const BUSY_CODES = /* @__PURE__ */ new Set([5, 6]);
|
|
171
|
+
/**
|
|
172
|
+
* Takes an exclusive lock on the SQLite database at `path`, creating the file, or returns `undefined` when another
|
|
173
|
+
* connection holds it. The lock is an fcntl lock on the file: it lasts until `ROLLBACK` and `close`, and the kernel
|
|
174
|
+
* drops it when the process exits or crashes, so no holder can be left behind and nothing is ever reclaimed.
|
|
175
|
+
*/
|
|
176
|
+
function trySqliteLock(sqlite, path, busyTimeoutMs) {
|
|
177
|
+
const db = sqlite.open(path);
|
|
178
|
+
try {
|
|
179
|
+
db.exec(`PRAGMA busy_timeout = ${busyTimeoutMs}`);
|
|
180
|
+
db.exec("PRAGMA journal_mode = MEMORY");
|
|
181
|
+
db.exec("PRAGMA locking_mode = EXCLUSIVE");
|
|
182
|
+
db.exec("BEGIN EXCLUSIVE");
|
|
183
|
+
return db;
|
|
184
|
+
} catch (error) {
|
|
185
|
+
db.close();
|
|
186
|
+
if (isSqliteBusy(error)) return;
|
|
187
|
+
throw error;
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
function unlockSqlite(db) {
|
|
191
|
+
try {
|
|
192
|
+
db.exec("ROLLBACK");
|
|
193
|
+
} finally {
|
|
194
|
+
db.close();
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
function isSqliteBusy(error) {
|
|
198
|
+
if (typeof error !== "object" || error === null) return false;
|
|
199
|
+
const { errcode, message } = error;
|
|
200
|
+
return typeof errcode === "number" && BUSY_CODES.has(errcode & 255) || message === "database is locked";
|
|
201
|
+
}
|
|
202
|
+
//#endregion
|
|
203
|
+
//#region src/process-lock/application/acquire-process-lock.ts
|
|
204
|
+
/**
|
|
205
|
+
* A single-instance lock on `path`, which must be in an existing local directory (not NFS). With `platform.sqlite`,
|
|
206
|
+
* `path` is a SQLite database held with `locking_mode=EXCLUSIVE`: the kernel releases it when the process exits or
|
|
207
|
+
* crashes, at once, and nothing is ever reclaimed. The holder's identity goes into `<path>.holder` for diagnostics
|
|
208
|
+
* only. Without SQLite, `path` is a lock file that holds the identity itself; a lock file whose holder died is
|
|
209
|
+
* reclaimed on the next attempt, which needs the holder on this host (see `tryFileLock` for the remaining gaps).
|
|
210
|
+
*
|
|
211
|
+
* Supports darwin and linux, where the platform can identify processes.
|
|
212
|
+
*/
|
|
213
|
+
async function acquireProcessLock(platform, path, options = {}) {
|
|
214
|
+
const { signal, wait = false, retryMs = 50 } = options;
|
|
215
|
+
for (let attempt = 0;; attempt += 1) {
|
|
216
|
+
signal?.throwIfAborted();
|
|
217
|
+
const result = await tryProcessLock(platform, path, { first: attempt === 0 });
|
|
218
|
+
if (result.ok) {
|
|
219
|
+
if (signal?.aborted === true) {
|
|
220
|
+
await result.value.release();
|
|
221
|
+
signal.throwIfAborted();
|
|
222
|
+
}
|
|
223
|
+
return result;
|
|
224
|
+
}
|
|
225
|
+
if (!wait) return result;
|
|
226
|
+
await delay(backoffMs(retryMs, attempt), signal);
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
/** Retries of a waiting caller double from `retryMs` up to 16 times it, with jitter so that waiters drift apart. */
|
|
230
|
+
function backoffMs(retryMs, attempt) {
|
|
231
|
+
return Math.min(retryMs * 2 ** attempt, retryMs * 16) * (.75 + Math.random() * .5);
|
|
232
|
+
}
|
|
233
|
+
const SETTLE_RETRIES = 4;
|
|
234
|
+
/**
|
|
235
|
+
* One attempt without waiting for a holder. The `first` attempt of an acquisition settles a race between processes
|
|
236
|
+
* that start together (see `SETTLE_MS`); later attempts of a waiting caller do not block the thread at all.
|
|
237
|
+
*/
|
|
238
|
+
async function tryProcessLock(platform, path, options) {
|
|
239
|
+
const stamp = newStamp(platform);
|
|
240
|
+
const { sqlite } = platform;
|
|
241
|
+
if (sqlite === void 0) {
|
|
242
|
+
const attempt = await tryFileLock(platform, path, stamp);
|
|
243
|
+
if (!attempt.acquired) return err({
|
|
244
|
+
_tag: "ProcessLockHeld",
|
|
245
|
+
path,
|
|
246
|
+
holder: attempt.stamp && holderOf(attempt.stamp)
|
|
247
|
+
});
|
|
248
|
+
return ok({
|
|
249
|
+
path,
|
|
250
|
+
mechanism: "file",
|
|
251
|
+
holder: holderOf(attempt.stamp),
|
|
252
|
+
release: once(attempt.release)
|
|
253
|
+
});
|
|
254
|
+
}
|
|
255
|
+
const holderFile = `${path}.holder`;
|
|
256
|
+
const busyTimeoutMs = options.first ? 10 : 0;
|
|
257
|
+
let db = trySqliteLock(sqlite, path, busyTimeoutMs);
|
|
258
|
+
for (let retry = 0; db === void 0 && options.first && retry < SETTLE_RETRIES; retry += 1) {
|
|
259
|
+
await delay(5 + Math.random() * 20, void 0);
|
|
260
|
+
db = trySqliteLock(sqlite, path, busyTimeoutMs);
|
|
261
|
+
}
|
|
262
|
+
if (db === void 0) {
|
|
263
|
+
const recorded = await readStamp(platform, holderFile);
|
|
264
|
+
return err({
|
|
265
|
+
_tag: "ProcessLockHeld",
|
|
266
|
+
path,
|
|
267
|
+
holder: recorded?.stamp && holderOf(recorded.stamp)
|
|
268
|
+
});
|
|
269
|
+
}
|
|
270
|
+
try {
|
|
271
|
+
await platform.fs.writeAtomic(holderFile, JSON.stringify(stamp));
|
|
272
|
+
} catch (error) {
|
|
273
|
+
unlockSqlite(db);
|
|
274
|
+
throw error;
|
|
275
|
+
}
|
|
276
|
+
const release = async () => {
|
|
277
|
+
try {
|
|
278
|
+
await platform.fs.remove(holderFile);
|
|
279
|
+
} finally {
|
|
280
|
+
unlockSqlite(db);
|
|
281
|
+
}
|
|
282
|
+
};
|
|
283
|
+
return ok({
|
|
284
|
+
path,
|
|
285
|
+
mechanism: "sqlite",
|
|
286
|
+
holder: holderOf(stamp),
|
|
287
|
+
release: once(release)
|
|
288
|
+
});
|
|
289
|
+
}
|
|
290
|
+
function once(release) {
|
|
291
|
+
let released;
|
|
292
|
+
return () => {
|
|
293
|
+
released ??= release();
|
|
294
|
+
return released;
|
|
295
|
+
};
|
|
296
|
+
}
|
|
297
|
+
function delay(ms, signal) {
|
|
298
|
+
return new Promise((resolve, reject) => {
|
|
299
|
+
const onAbort = () => {
|
|
300
|
+
clearTimeout(timer);
|
|
301
|
+
reject(signal?.reason);
|
|
302
|
+
};
|
|
303
|
+
const timer = setTimeout(() => {
|
|
304
|
+
signal?.removeEventListener("abort", onAbort);
|
|
305
|
+
resolve();
|
|
306
|
+
}, ms);
|
|
307
|
+
signal?.addEventListener("abort", onAbort, { once: true });
|
|
308
|
+
});
|
|
309
|
+
}
|
|
310
|
+
//#endregion
|
|
311
|
+
export { holderLiveness as a, isSqliteBusy as i, backoffMs as n, readText as o, tryProcessLock as r, acquireProcessLock as t };
|
package/dist/lanes.d.ts
ADDED
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import * as Effect from "effect/Effect";
|
|
2
|
+
import { Result } from "@rivus/agent-kit/catalog";
|
|
3
|
+
import * as Cause from "effect/Cause";
|
|
4
|
+
import * as Scope from "effect/Scope";
|
|
5
|
+
//#region src/lanes/domain/lane/errors/lane-queue-full.d.ts
|
|
6
|
+
/** An idle lane was woken while every slot was taken and `maxQueued` lanes were already waiting. */
|
|
7
|
+
interface LaneQueueFull {
|
|
8
|
+
readonly _tag: "LaneQueueFull";
|
|
9
|
+
readonly key: string;
|
|
10
|
+
readonly maxQueued: number;
|
|
11
|
+
}
|
|
12
|
+
//#endregion
|
|
13
|
+
//#region src/lanes/domain/lane/errors/lanes-config-invalid.d.ts
|
|
14
|
+
/** `maxConcurrent`, `maxQueued` or `turnTimeoutMs` is out of range. */
|
|
15
|
+
interface LanesConfigInvalid {
|
|
16
|
+
readonly _tag: "LanesConfigInvalid";
|
|
17
|
+
readonly message: string;
|
|
18
|
+
}
|
|
19
|
+
//#endregion
|
|
20
|
+
//#region src/lanes/domain/lane/value-objects/lane-snapshot.d.ts
|
|
21
|
+
/**
|
|
22
|
+
* `idle`: nothing runs and nothing is owed. `queued`: woken while every slot was taken, waiting for one. `running`: one
|
|
23
|
+
* activation runs.
|
|
24
|
+
*/
|
|
25
|
+
type LaneState = "idle" | "queued" | "running";
|
|
26
|
+
interface LaneSnapshot {
|
|
27
|
+
readonly key: string;
|
|
28
|
+
readonly state: LaneState;
|
|
29
|
+
/**
|
|
30
|
+
* A wake that no started activation has served yet: always set while `queued`, and set while `running` when a wake
|
|
31
|
+
* arrived after the activation started, so one more activation follows it.
|
|
32
|
+
*/
|
|
33
|
+
readonly pending: boolean;
|
|
34
|
+
}
|
|
35
|
+
//#endregion
|
|
36
|
+
//#region src/lanes/application/create-lanes.d.ts
|
|
37
|
+
interface LanesConfig<E, R> {
|
|
38
|
+
/** Activations that may run at once across all keys; a positive integer. */
|
|
39
|
+
readonly maxConcurrent: number;
|
|
40
|
+
/**
|
|
41
|
+
* Lanes that may wait for a free slot; a non-negative integer. A lane waits at most once however often it is woken,
|
|
42
|
+
* so without a bound the queue holds at most one entry per key.
|
|
43
|
+
*/
|
|
44
|
+
readonly maxQueued?: number;
|
|
45
|
+
/** The longest one activation may run, in milliseconds, before it is interrupted; no limit when absent. */
|
|
46
|
+
readonly turnTimeoutMs?: number;
|
|
47
|
+
/**
|
|
48
|
+
* The work for one key. It runs in a Scope of its own, closed when it ends, with the context `createLanes` ran in.
|
|
49
|
+
* `cancel`, `close` and the turn timeout interrupt it, and the lane moves on only after its finalizers have run.
|
|
50
|
+
*/
|
|
51
|
+
readonly activate: (key: string) => Effect.Effect<unknown, E, R>;
|
|
52
|
+
/**
|
|
53
|
+
* Hears how each activation ended, before the lane can start its next activation. It runs uninterruptibly, and
|
|
54
|
+
* `cancel` and `close` wait for it, so keep it short and do not wait for either of them in it. A defect it raises is
|
|
55
|
+
* logged as a warning.
|
|
56
|
+
*/
|
|
57
|
+
readonly onExit?: (exit: ActivationExit<E>) => Effect.Effect<void, never, R>;
|
|
58
|
+
}
|
|
59
|
+
/** Why the lanes interrupted an activation. */
|
|
60
|
+
type ActivationInterruptReason = "cancel" | "close" | "timeout";
|
|
61
|
+
/**
|
|
62
|
+
* How an activation ended. An activation that succeeded or failed by itself is reported so even when a cancel, close
|
|
63
|
+
* or timeout comes while its Scope closes; `ActivationInterrupted` means the lanes cut it short or it never started.
|
|
64
|
+
*/
|
|
65
|
+
type ActivationExit<E> = {
|
|
66
|
+
readonly _tag: "ActivationSucceeded";
|
|
67
|
+
readonly key: string;
|
|
68
|
+
} | {
|
|
69
|
+
readonly _tag: "ActivationFailed";
|
|
70
|
+
readonly key: string;
|
|
71
|
+
readonly cause: Cause.Cause<E>;
|
|
72
|
+
} | {
|
|
73
|
+
readonly _tag: "ActivationInterrupted";
|
|
74
|
+
readonly key: string;
|
|
75
|
+
readonly reason: ActivationInterruptReason;
|
|
76
|
+
};
|
|
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
|
+
/** `close` ran, or the Scope that created the lanes closed. */
|
|
83
|
+
interface LanesClosed {
|
|
84
|
+
readonly _tag: "LanesClosed";
|
|
85
|
+
readonly key: string;
|
|
86
|
+
}
|
|
87
|
+
interface LanesStatus {
|
|
88
|
+
readonly running: number;
|
|
89
|
+
readonly queued: number;
|
|
90
|
+
readonly closed: boolean;
|
|
91
|
+
/** Every lane that is not idle: the running ones, then the queued ones in queue order. */
|
|
92
|
+
readonly lanes: readonly LaneSnapshot[];
|
|
93
|
+
}
|
|
94
|
+
/** Lanes created in a caller's Scope; closing that Scope runs `close`. */
|
|
95
|
+
interface Lanes {
|
|
96
|
+
/**
|
|
97
|
+
* Asks for an activation of `key` without waiting for it. An idle lane starts when a slot is free and nobody waits
|
|
98
|
+
* for one, otherwise it joins the end of the queue, or fails with `LaneQueueFull` when `maxQueued` lanes already
|
|
99
|
+
* wait. A queued or running lane coalesces the wake: at most one more activation follows the running one.
|
|
100
|
+
*/
|
|
101
|
+
wake(key: string): Effect.Effect<WakeResult, LaneQueueFull | LanesClosed>;
|
|
102
|
+
/**
|
|
103
|
+
* Drops the wakes `key` owes and interrupts its running activation, then waits until that activation, `onExit`
|
|
104
|
+
* included, has ended. Wakes that arrive after the cancel are served as usual.
|
|
105
|
+
*/
|
|
106
|
+
cancel(key: string): Effect.Effect<void>;
|
|
107
|
+
readonly status: Effect.Effect<LanesStatus>;
|
|
108
|
+
/**
|
|
109
|
+
* Refuses later wakes with `LanesClosed`, drops the queue and every pending wake, interrupts the running
|
|
110
|
+
* activations and waits until they have ended. Idempotent.
|
|
111
|
+
*/
|
|
112
|
+
readonly close: Effect.Effect<void>;
|
|
113
|
+
}
|
|
114
|
+
/**
|
|
115
|
+
* Creates lanes in the caller's Scope. The lanes capture the context they are created in and give it to every
|
|
116
|
+
* activation; fails with `LanesConfigInvalid` for a limit out of range.
|
|
117
|
+
*/
|
|
118
|
+
export declare function createLanes<E = never, R = never>(config: LanesConfig<E, R>): Effect.Effect<Lanes, LanesConfigInvalid, R | Scope.Scope>;
|
|
119
|
+
//#endregion
|
|
120
|
+
export type { ActivationExit, ActivationInterruptReason, LaneQueueFull, LaneSnapshot, LaneState, Lanes, LanesClosed, LanesConfig, LanesConfigInvalid, LanesStatus, WakeResult };
|