@aikoql/client 0.0.0-stage → 0.2.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +20 -2
- package/package.json +30 -4
- package/src/client.ts +500 -0
- package/src/error.ts +92 -0
- package/src/index.ts +20 -0
- package/src/pool.ts +294 -0
- package/src/sdk-conformance.ts +496 -0
- package/src/tcp.ts +135 -0
- package/src/tools.ts +72 -0
- package/src/tx.ts +83 -0
- package/tests/conformance.test.ts +60 -0
- package/tests/corpus-pin.test.ts +96 -0
- package/tests/fault-matrix.test.ts +298 -0
- package/tests/fuzz-estate-pin.test.ts +32 -0
- package/tests/fuzz-estate.test.ts +514 -0
- package/tests/helpers.ts +69 -0
- package/tests/identity.test.ts +26 -0
- package/tests/pool.test.ts +278 -0
- package/tests/wire.test.ts +191 -0
- package/tsconfig.json +16 -0
package/src/pool.ts
ADDED
|
@@ -0,0 +1,294 @@
|
|
|
1
|
+
// The §3.4 connection pool: a client-side abstraction over dial (no
|
|
2
|
+
// protocol surface — api-v1.json is frozen). The factory is the seam:
|
|
3
|
+
// each call must return one fully established session (dialed +
|
|
4
|
+
// initialized), which is also the auth reset on every reconnect. Release
|
|
5
|
+
// rolls back any open transaction (session reset — the next borrower
|
|
6
|
+
// never inherits it, §21), reaps lifetime-expired connections, and hands
|
|
7
|
+
// the connection to a waiter; acquire waits up to acquireTimeoutMs and
|
|
8
|
+
// throws the retryable RESOURCE_EXHAUSTED when the pool stays exhausted.
|
|
9
|
+
// A failed health ping drops the connection and dials a fresh one; a dead
|
|
10
|
+
// server makes the dial path retry until the deadline (the reconnect leg
|
|
11
|
+
// of §21). Mirrors crates/sdk/go/pool.go.
|
|
12
|
+
//
|
|
13
|
+
// ponytail: one idle stack and a FIFO waiter queue (wake one waiter per
|
|
14
|
+
// return, not Go's broadcast herd) — per-waiter priorities only if a
|
|
15
|
+
// profile ever shows contention.
|
|
16
|
+
|
|
17
|
+
import type { Client } from "./client.ts";
|
|
18
|
+
import { withDeadline } from "./client.ts";
|
|
19
|
+
import { McpError } from "./error.ts";
|
|
20
|
+
import type { Tx } from "./tx.ts";
|
|
21
|
+
|
|
22
|
+
export interface PoolConfig {
|
|
23
|
+
/** Returns one fully established session per call. */
|
|
24
|
+
factory: () => Promise<Client>;
|
|
25
|
+
/** Bounds the pool (default 10). */
|
|
26
|
+
maxConns?: number;
|
|
27
|
+
/** Honored by fillMinIdle (default 0; no background refill). */
|
|
28
|
+
minIdle?: number;
|
|
29
|
+
/** Bounds a waiter (default 30s; RESOURCE_EXHAUSTED). */
|
|
30
|
+
acquireTimeoutMs?: number;
|
|
31
|
+
/** Reaps a connection idle longer than this (default 60s). */
|
|
32
|
+
idleTimeoutMs?: number;
|
|
33
|
+
/** Reaps a connection older than this (default 1h). */
|
|
34
|
+
maxLifetimeMs?: number;
|
|
35
|
+
/** Pings a borrowed connection when it sat idle longer than this
|
|
36
|
+
* (default 30s; a near-zero value pings every borrow). */
|
|
37
|
+
healthCheckIntervalMs?: number;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface PoolStats {
|
|
41
|
+
total: number;
|
|
42
|
+
idle: number;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
interface Waiter {
|
|
46
|
+
resolve: () => void;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function sleep(ms: number): Promise<void> {
|
|
50
|
+
return new Promise((r) => setTimeout(r, ms));
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** One checked-out connection. Client calls go through client(); begin
|
|
54
|
+
* pins the transaction so release can reset the session. */
|
|
55
|
+
export class PooledConn {
|
|
56
|
+
private p: Pool;
|
|
57
|
+
private c: Client;
|
|
58
|
+
private tx: Tx | null = null;
|
|
59
|
+
createdAt: number;
|
|
60
|
+
lastUsed: number;
|
|
61
|
+
broken = false;
|
|
62
|
+
|
|
63
|
+
constructor(p: Pool, c: Client, now: number) {
|
|
64
|
+
this.p = p;
|
|
65
|
+
this.c = c;
|
|
66
|
+
this.createdAt = now;
|
|
67
|
+
this.lastUsed = now;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** The underlying client (transport-level access). */
|
|
71
|
+
client(): Client {
|
|
72
|
+
return this.c;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Opens a transaction pinned to this connection; release rolls it back
|
|
76
|
+
* if it is still open. */
|
|
77
|
+
async begin(txnId?: string): Promise<Tx> {
|
|
78
|
+
const tx = await this.c.begin(txnId);
|
|
79
|
+
this.tx = tx;
|
|
80
|
+
return tx;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Returns the connection to the pool, rolling back a still-open
|
|
84
|
+
* transaction first (session reset). */
|
|
85
|
+
async release(): Promise<void> {
|
|
86
|
+
if (this.tx !== null && !this.tx.done()) {
|
|
87
|
+
try {
|
|
88
|
+
await withDeadline(5000, (signal) => this.tx!.rollback({ signal }));
|
|
89
|
+
} catch {
|
|
90
|
+
this.broken = true; // session reset failed — drop the connection
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
this.tx = null;
|
|
94
|
+
this.p.put(this);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** Drops the connection (for a caller that saw a transport error and
|
|
98
|
+
* does not trust the connection anymore). */
|
|
99
|
+
invalidate(): void {
|
|
100
|
+
this.broken = true;
|
|
101
|
+
this.p.put(this);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** A bounded pool of client connections. */
|
|
106
|
+
export class Pool {
|
|
107
|
+
private cfg: Required<Omit<PoolConfig, "factory">> & Pick<PoolConfig, "factory">;
|
|
108
|
+
private idle: PooledConn[] = [];
|
|
109
|
+
private total = 0;
|
|
110
|
+
private waiters: Waiter[] = [];
|
|
111
|
+
private closedFlag = false;
|
|
112
|
+
|
|
113
|
+
/** Builds a pool with the defaults filled in. */
|
|
114
|
+
constructor(cfg: PoolConfig) {
|
|
115
|
+
if (!cfg.factory) throw new Error("aikoql: PoolConfig.factory is required");
|
|
116
|
+
this.cfg = {
|
|
117
|
+
factory: cfg.factory,
|
|
118
|
+
maxConns: cfg.maxConns ?? 10,
|
|
119
|
+
minIdle: cfg.minIdle ?? 0,
|
|
120
|
+
acquireTimeoutMs: cfg.acquireTimeoutMs ?? 30_000,
|
|
121
|
+
idleTimeoutMs: cfg.idleTimeoutMs ?? 60_000,
|
|
122
|
+
maxLifetimeMs: cfg.maxLifetimeMs ?? 3_600_000,
|
|
123
|
+
healthCheckIntervalMs: cfg.healthCheckIntervalMs ?? 30_000,
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** Returns a connection: an idle one (health-checked when due), a fresh
|
|
128
|
+
* dial, or — when the pool is exhausted — it waits for a return up to
|
|
129
|
+
* acquireTimeoutMs and fails with the retryable RESOURCE_EXHAUSTED. */
|
|
130
|
+
async acquire(): Promise<PooledConn> {
|
|
131
|
+
for (;;) {
|
|
132
|
+
if (this.closedFlag) {
|
|
133
|
+
throw new McpError(
|
|
134
|
+
"UNAVAILABLE",
|
|
135
|
+
"connection pool is closed",
|
|
136
|
+
true,
|
|
137
|
+
"Create a new pool.",
|
|
138
|
+
);
|
|
139
|
+
}
|
|
140
|
+
while (this.idle.length > 0) {
|
|
141
|
+
const pc = this.idle.pop()!;
|
|
142
|
+
if (this.expired(pc)) {
|
|
143
|
+
this.total--;
|
|
144
|
+
await pc.client().close().catch(() => {});
|
|
145
|
+
continue;
|
|
146
|
+
}
|
|
147
|
+
const now = Date.now();
|
|
148
|
+
if (now - pc.lastUsed > this.cfg.healthCheckIntervalMs) {
|
|
149
|
+
// Ping: a dead connection is dropped and replaced by a fresh
|
|
150
|
+
// dial (the reconnect leg of §21).
|
|
151
|
+
try {
|
|
152
|
+
await pc.client().health();
|
|
153
|
+
} catch {
|
|
154
|
+
this.total--;
|
|
155
|
+
await pc.client().close().catch(() => {});
|
|
156
|
+
continue;
|
|
157
|
+
}
|
|
158
|
+
pc.lastUsed = now;
|
|
159
|
+
return pc;
|
|
160
|
+
}
|
|
161
|
+
pc.lastUsed = now;
|
|
162
|
+
return pc;
|
|
163
|
+
}
|
|
164
|
+
if (this.total < this.cfg.maxConns) {
|
|
165
|
+
this.total++;
|
|
166
|
+
let c: Client;
|
|
167
|
+
try {
|
|
168
|
+
c = await this.dialRetry();
|
|
169
|
+
} catch (e) {
|
|
170
|
+
this.total--;
|
|
171
|
+
throw e;
|
|
172
|
+
}
|
|
173
|
+
const now = Date.now();
|
|
174
|
+
return new PooledConn(this, c, now);
|
|
175
|
+
}
|
|
176
|
+
// Exhausted: wait for a return or the acquire timeout.
|
|
177
|
+
let entry!: Waiter;
|
|
178
|
+
const wake = new Promise<"wake">((resolve) => {
|
|
179
|
+
entry = { resolve: () => resolve("wake") };
|
|
180
|
+
this.waiters.push(entry);
|
|
181
|
+
});
|
|
182
|
+
const timer = new Promise<"timer">((resolve) => {
|
|
183
|
+
const t = setTimeout(() => resolve("timer"), this.cfg.acquireTimeoutMs);
|
|
184
|
+
t.unref?.();
|
|
185
|
+
});
|
|
186
|
+
if ((await Promise.race([wake, timer])) === "timer") {
|
|
187
|
+
const i = this.waiters.indexOf(entry);
|
|
188
|
+
if (i >= 0) this.waiters.splice(i, 1);
|
|
189
|
+
throw new McpError(
|
|
190
|
+
"RESOURCE_EXHAUSTED",
|
|
191
|
+
"connection pool exhausted: all connections busy and the acquire timeout elapsed",
|
|
192
|
+
true,
|
|
193
|
+
"Retry with backoff, or raise MaxConns.",
|
|
194
|
+
);
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/** Calls the factory with a short backoff until the deadline — the
|
|
200
|
+
* reconnect leg: a dead server keeps the borrow pending and succeeds
|
|
201
|
+
* once the server returns. */
|
|
202
|
+
private async dialRetry(): Promise<Client> {
|
|
203
|
+
const deadline = Date.now() + this.cfg.acquireTimeoutMs;
|
|
204
|
+
let lastErr: unknown;
|
|
205
|
+
for (;;) {
|
|
206
|
+
try {
|
|
207
|
+
return await this.cfg.factory();
|
|
208
|
+
} catch (e) {
|
|
209
|
+
lastErr = e;
|
|
210
|
+
}
|
|
211
|
+
if (Date.now() >= deadline) {
|
|
212
|
+
throw new McpError(
|
|
213
|
+
"UNAVAILABLE",
|
|
214
|
+
`could not connect to the server: ${String(lastErr)}`,
|
|
215
|
+
true,
|
|
216
|
+
"Verify the server; the pool retries until the acquire timeout.",
|
|
217
|
+
);
|
|
218
|
+
}
|
|
219
|
+
await sleep(100);
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/** Whether an idle connection was reaped by the idle or lifetime
|
|
224
|
+
* timeout. */
|
|
225
|
+
private expired(pc: PooledConn): boolean {
|
|
226
|
+
const now = Date.now();
|
|
227
|
+
return (
|
|
228
|
+
now - pc.lastUsed > this.cfg.idleTimeoutMs ||
|
|
229
|
+
now - pc.createdAt > this.cfg.maxLifetimeMs
|
|
230
|
+
);
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/** Returns a connection to the pool: to a waiter when one is blocked,
|
|
234
|
+
* else to the idle stack; lifetime-expired or broken connections are
|
|
235
|
+
* dropped instead. Public only for PooledConn — the stand-in for Go's
|
|
236
|
+
* same-package access. */
|
|
237
|
+
put(pc: PooledConn): void {
|
|
238
|
+
const now = Date.now();
|
|
239
|
+
pc.lastUsed = now;
|
|
240
|
+
const drop =
|
|
241
|
+
this.closedFlag || pc.broken || now - pc.createdAt > this.cfg.maxLifetimeMs;
|
|
242
|
+
if (!drop) {
|
|
243
|
+
this.idle.push(pc);
|
|
244
|
+
const w = this.waiters.shift();
|
|
245
|
+
if (w) w.resolve();
|
|
246
|
+
} else {
|
|
247
|
+
this.total--;
|
|
248
|
+
}
|
|
249
|
+
if (drop) {
|
|
250
|
+
void pc.client().close().catch(() => {});
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/** Dials until minIdle connections sit idle (best-effort, no background
|
|
255
|
+
* refill). */
|
|
256
|
+
async fillMinIdle(): Promise<void> {
|
|
257
|
+
for (;;) {
|
|
258
|
+
if (
|
|
259
|
+
this.closedFlag ||
|
|
260
|
+
this.idle.length >= this.cfg.minIdle ||
|
|
261
|
+
this.total >= this.cfg.maxConns
|
|
262
|
+
) {
|
|
263
|
+
return;
|
|
264
|
+
}
|
|
265
|
+
this.total++;
|
|
266
|
+
let c: Client;
|
|
267
|
+
try {
|
|
268
|
+
c = await this.dialRetry();
|
|
269
|
+
} catch (e) {
|
|
270
|
+
this.total--;
|
|
271
|
+
throw e;
|
|
272
|
+
}
|
|
273
|
+
this.put(new PooledConn(this, c, Date.now()));
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/** The observable pool shape. */
|
|
278
|
+
stats(): PoolStats {
|
|
279
|
+
return { total: this.total, idle: this.idle.length };
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/** Closes the idle connections and marks the pool closed; checked-out
|
|
283
|
+
* connections are closed when they are released. */
|
|
284
|
+
async close(): Promise<void> {
|
|
285
|
+
if (this.closedFlag) return;
|
|
286
|
+
this.closedFlag = true;
|
|
287
|
+
const idle = this.idle;
|
|
288
|
+
this.idle = [];
|
|
289
|
+
this.total -= idle.length;
|
|
290
|
+
for (const pc of idle) {
|
|
291
|
+
await pc.client().close().catch(() => {});
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
}
|