@ixo/oracle-runtime-workers 0.33.2 → 0.34.1
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 +16 -16
- package/package.json +1 -1
- package/src/plugins/ixo-chain/denoms.ts +15 -12
- package/src/plugins/ixo-chain/http.ts +109 -29
- package/src/plugins/ixo-chain/ixo-chain.plugin.ts +122 -14
- package/src/plugins/ixo-chain/kinds/chain/shared.ts +15 -12
- package/src/plugins/ixo-chain/lookup.ts +66 -6
- package/src/plugins/ixo-chain/names/client.ts +467 -0
- package/src/plugins/ixo-chain/names/collect.ts +180 -0
- package/src/plugins/ixo-chain/names/types.ts +25 -0
- package/src/plugins/ixo-chain/overview.ts +92 -34
|
@@ -42,6 +42,8 @@ import type {
|
|
|
42
42
|
KindRestOptions,
|
|
43
43
|
KindResult,
|
|
44
44
|
} from './kinds/types';
|
|
45
|
+
import { dropLastName, pruneNames } from './names/collect';
|
|
46
|
+
import type { NameMap } from './names/types';
|
|
45
47
|
import { firstOf, type Subject } from './queries';
|
|
46
48
|
import { restJson, type Absent } from './rest';
|
|
47
49
|
import { isRecord } from './summaries';
|
|
@@ -395,6 +397,60 @@ export function shrink(
|
|
|
395
397
|
}
|
|
396
398
|
}
|
|
397
399
|
|
|
400
|
+
/**
|
|
401
|
+
* One row cut: the last row of the largest list inside `within` left out
|
|
402
|
+
* and `holder` marked `truncated`; false when no list has more than one
|
|
403
|
+
* row left.
|
|
404
|
+
*/
|
|
405
|
+
export function cutRow(
|
|
406
|
+
holder: { truncated?: boolean },
|
|
407
|
+
within: unknown,
|
|
408
|
+
): boolean {
|
|
409
|
+
const found = largestArray(within);
|
|
410
|
+
if (!found) return false;
|
|
411
|
+
found.list.pop();
|
|
412
|
+
holder.truncated = true;
|
|
413
|
+
return true;
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
/**
|
|
417
|
+
* One row cut across `results`: the last row of the largest list in any
|
|
418
|
+
* `ok` result left out and that result marked `truncated`; false when no
|
|
419
|
+
* result has a list of more than one row left.
|
|
420
|
+
*/
|
|
421
|
+
export function cutRowAcross(results: LookupResult[]): boolean {
|
|
422
|
+
let largest: { result: LookupResult; size: number } | undefined;
|
|
423
|
+
for (const result of results) {
|
|
424
|
+
if (!result.ok) continue;
|
|
425
|
+
const found = largestArray(result.data);
|
|
426
|
+
if (found && (!largest || found.size > largest.size))
|
|
427
|
+
largest = { result, size: found.size };
|
|
428
|
+
}
|
|
429
|
+
return largest?.result.ok
|
|
430
|
+
? cutRow(largest.result, largest.result.data)
|
|
431
|
+
: false;
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
/**
|
|
435
|
+
* Fits `answer`, which carries `names`, into {@link MAX_RESULT_CHARS}.
|
|
436
|
+
* Rows go first, one `cutRows` at a time (one row each: {@link cutRow},
|
|
437
|
+
* {@link cutRowAcross}), each cut followed by leaving out the names of
|
|
438
|
+
* identifiers no longer in the answer; with no row left to cut, names go
|
|
439
|
+
* from the end of the map (the least placed identifiers). An answer is
|
|
440
|
+
* never left out whole to make room for names. An empty map is removed.
|
|
441
|
+
*/
|
|
442
|
+
export function fitNames(
|
|
443
|
+
answer: { names?: NameMap },
|
|
444
|
+
cutRows: () => boolean,
|
|
445
|
+
): void {
|
|
446
|
+
while (size(answer) > MAX_RESULT_CHARS) {
|
|
447
|
+
if (!cutRows()) break;
|
|
448
|
+
pruneNames(answer);
|
|
449
|
+
}
|
|
450
|
+
while (size(answer) > MAX_RESULT_CHARS) if (!dropLastName(answer)) break;
|
|
451
|
+
if (answer.names && !Object.keys(answer.names).length) delete answer.names;
|
|
452
|
+
}
|
|
453
|
+
|
|
398
454
|
/**
|
|
399
455
|
* Fits `parts` (the results inside `whole`) into what
|
|
400
456
|
* {@link MAX_RESULT_CHARS} leaves after the envelope: each part gets an
|
|
@@ -446,18 +502,22 @@ export function fitResults(whole: unknown, results: LookupResult[]): void {
|
|
|
446
502
|
}
|
|
447
503
|
}
|
|
448
504
|
|
|
505
|
+
export interface LookupAnswer {
|
|
506
|
+
ok: true;
|
|
507
|
+
network: ChainCall['network'];
|
|
508
|
+
fetchedAt: string;
|
|
509
|
+
results: LookupResult[];
|
|
510
|
+
/** What the identifiers in the results are called (added by the tool). */
|
|
511
|
+
names?: NameMap;
|
|
512
|
+
}
|
|
513
|
+
|
|
449
514
|
/** `chain_lookup`'s answer: every request's result in order, cut to size. */
|
|
450
515
|
export async function chainLookup(
|
|
451
516
|
call: ChainCall,
|
|
452
517
|
identity: UserIdentity,
|
|
453
518
|
requests: readonly KindRequest[],
|
|
454
519
|
options: LookupOptions,
|
|
455
|
-
): Promise<{
|
|
456
|
-
ok: true;
|
|
457
|
-
network: ChainCall['network'];
|
|
458
|
-
fetchedAt: string;
|
|
459
|
-
results: LookupResult[];
|
|
460
|
-
}> {
|
|
520
|
+
): Promise<LookupAnswer> {
|
|
461
521
|
const results = await runRequests(call, identity, requests, options);
|
|
462
522
|
const whole = {
|
|
463
523
|
ok: true as const,
|
|
@@ -0,0 +1,467 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The IXO Names worker client: names for the identifiers of a tool result,
|
|
3
|
+
* asked of the shared worker (`POST <base>/v1/resolve`).
|
|
4
|
+
*
|
|
5
|
+
* Per isolate, three things sit in front of the worker:
|
|
6
|
+
* - a memo of its answers (at most {@link MEMO_ENTRIES}, each kept until
|
|
7
|
+
* the worker's `freshUntil`, at most {@link MEMO_MAX_MS} and at least
|
|
8
|
+
* {@link MEMO_MIN_MS}); partial answers are never kept;
|
|
9
|
+
* - a batcher per worker and client id: the ids every call misses are
|
|
10
|
+
* gathered for {@link BATCH_WINDOW_MS} after the first one (or until
|
|
11
|
+
* {@link MAX_BATCH_IDS} are waiting) and asked in one POST; an id queued
|
|
12
|
+
* or in flight is joined, never asked twice, until it is too old to
|
|
13
|
+
* trust;
|
|
14
|
+
* - a pause: after a 429 or 5xx no POST goes to that worker for its
|
|
15
|
+
* `Retry-After` ({@link PAUSE_DEFAULT_MS} without one, at most
|
|
16
|
+
* {@link PAUSE_MAX_MS}).
|
|
17
|
+
*
|
|
18
|
+
* A POST belongs to no call: it runs under its own {@link POST_TIMEOUT_MS}
|
|
19
|
+
* through the plugin's bounded exchange (no redirects, body cut off at
|
|
20
|
+
* {@link MAX_RESPONSE_BYTES}) and asks the worker to answer by
|
|
21
|
+
* {@link RESPONSE_BUDGET_MS} with what is ready. Each call waits for its own ids only, until
|
|
22
|
+
* its own deadline or its turn's abort; its answer then lands in the memo
|
|
23
|
+
* for the next call. Names never fail a tool: any failure is one log line
|
|
24
|
+
* (a status or an error class, never an identifier) and no names.
|
|
25
|
+
*
|
|
26
|
+
* A batcher is shared by every Durable Object of the isolate, while timers
|
|
27
|
+
* and fetches belong to the actor that started them: if it dies they end
|
|
28
|
+
* unsettled and nothing that follows them runs. A promise it made wakes no
|
|
29
|
+
* one either, even when a live actor settles it. So no shared state waits
|
|
30
|
+
* on one request finishing: age recovers it, noticed by the next caller.
|
|
31
|
+
* That caller asks an open batch past {@link BATCH_STALE_MS} afresh and
|
|
32
|
+
* sends it, and an id outstanding past {@link OUTSTANDING_MAX_MS} is
|
|
33
|
+
* dropped, to be asked afresh by whoever needs it.
|
|
34
|
+
*/
|
|
35
|
+
import { z } from 'zod';
|
|
36
|
+
import { LruMap } from '../../../core/domain-context/lru';
|
|
37
|
+
import {
|
|
38
|
+
READ_GRACE_MS,
|
|
39
|
+
exchange,
|
|
40
|
+
untilDeadline,
|
|
41
|
+
type ChainCall,
|
|
42
|
+
} from '../http';
|
|
43
|
+
import type { NameInfo, NameMap } from './types';
|
|
44
|
+
|
|
45
|
+
/** The worker per IXO network; `IXO_NAMES_URL` overrides. */
|
|
46
|
+
export const NAMES_URLS = {
|
|
47
|
+
mainnet: 'https://names.ixo.earth',
|
|
48
|
+
testnet: 'https://testnet.names.ixo.earth',
|
|
49
|
+
devnet: 'https://devnet.names.ixo.earth',
|
|
50
|
+
} as const;
|
|
51
|
+
|
|
52
|
+
/** The most ids the worker takes in one request. */
|
|
53
|
+
export const MAX_BATCH_IDS = 40;
|
|
54
|
+
/** How long a batch gathers ids after its first one before it is sent. */
|
|
55
|
+
export const BATCH_WINDOW_MS = 10;
|
|
56
|
+
/** An open batch this old lost its window timer: the next caller sends it. */
|
|
57
|
+
export const BATCH_STALE_MS = 100;
|
|
58
|
+
/** One POST's whole time, the body read included; no caller shortens it. */
|
|
59
|
+
export const POST_TIMEOUT_MS = 6_000;
|
|
60
|
+
/**
|
|
61
|
+
* The worker answers by this time with what is ready, the rest as `partial`
|
|
62
|
+
* ids, and finishes them in the background for the next call. Below
|
|
63
|
+
* {@link POST_TIMEOUT_MS} by enough for network time and the worker's D1
|
|
64
|
+
* reads, which come on top: an answer past the timeout would lose every id.
|
|
65
|
+
*/
|
|
66
|
+
export const RESPONSE_BUDGET_MS = 5_000;
|
|
67
|
+
/**
|
|
68
|
+
* An id outstanding this long lost its POST: it is never joined again and
|
|
69
|
+
* no longer counts toward {@link MAX_OUTSTANDING_IDS}.
|
|
70
|
+
*/
|
|
71
|
+
export const OUTSTANDING_MAX_MS = POST_TIMEOUT_MS + READ_GRACE_MS;
|
|
72
|
+
/**
|
|
73
|
+
* Ids queued or in flight per batcher; a miss past this is not asked (its
|
|
74
|
+
* call goes without that name). Five full POSTs, as many as the batcher's
|
|
75
|
+
* permits let run at once.
|
|
76
|
+
*/
|
|
77
|
+
export const MAX_OUTSTANDING_IDS = 200;
|
|
78
|
+
/** The largest answer body read. */
|
|
79
|
+
export const MAX_RESPONSE_BYTES = 256 * 1024;
|
|
80
|
+
export const MEMO_ENTRIES = 5_000;
|
|
81
|
+
export const MEMO_MAX_MS = 10 * 60_000;
|
|
82
|
+
export const MEMO_MIN_MS = 30_000;
|
|
83
|
+
export const PAUSE_DEFAULT_MS = 60_000;
|
|
84
|
+
export const PAUSE_MAX_MS = 300_000;
|
|
85
|
+
/** The worker cleans names to at most this many characters. */
|
|
86
|
+
const NAME_MAX_CHARS = 120;
|
|
87
|
+
|
|
88
|
+
/** The client's one log line: a status or an error class, never an identifier. */
|
|
89
|
+
function logFailure(reason: string): void {
|
|
90
|
+
// eslint-disable-next-line no-console -- console is the logger on Workers
|
|
91
|
+
console.warn(`[ixo-chain] names: ${reason}`);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
type Network = ChainCall['network'];
|
|
95
|
+
|
|
96
|
+
/** The worker's answer for one id; extra fields are ignored. */
|
|
97
|
+
const resultSchema = z.object({
|
|
98
|
+
id: z.string(),
|
|
99
|
+
kind: z.string(),
|
|
100
|
+
status: z.string(),
|
|
101
|
+
complete: z.boolean(),
|
|
102
|
+
name: z.string().min(1).max(NAME_MAX_CHARS).nullable(),
|
|
103
|
+
source: z.string().nullable(),
|
|
104
|
+
selfChosen: z.boolean(),
|
|
105
|
+
cache: z.object({ freshUntil: z.number().nullable() }).nullish(),
|
|
106
|
+
});
|
|
107
|
+
const responseSchema = z.object({
|
|
108
|
+
network: z.string(),
|
|
109
|
+
results: z.record(z.string(), resultSchema),
|
|
110
|
+
});
|
|
111
|
+
type WorkerResult = z.infer<typeof resultSchema>;
|
|
112
|
+
|
|
113
|
+
/** One id's answer: its name, or null for none (unnamed, unanswered or failed). */
|
|
114
|
+
type Answer = NameInfo | null;
|
|
115
|
+
|
|
116
|
+
/** Where a batch goes: the worker, the network it must answer for, and who asks. */
|
|
117
|
+
interface Target {
|
|
118
|
+
network: Network;
|
|
119
|
+
baseUrl: string;
|
|
120
|
+
clientId: string;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/* ── memo ─────────────────────────────────────────────────────────────── */
|
|
124
|
+
|
|
125
|
+
const newMemo = () =>
|
|
126
|
+
new LruMap<string, { answer: Answer; until: number }>({
|
|
127
|
+
maxEntries: MEMO_ENTRIES,
|
|
128
|
+
});
|
|
129
|
+
let memo = newMemo();
|
|
130
|
+
|
|
131
|
+
const memoKey = (baseUrl: string, id: string) => `${baseUrl}|${id}`;
|
|
132
|
+
|
|
133
|
+
/** The memoised answer for `id`; undefined when none is kept or it expired. */
|
|
134
|
+
function memoised(baseUrl: string, id: string): { answer: Answer } | undefined {
|
|
135
|
+
const key = memoKey(baseUrl, id);
|
|
136
|
+
const kept = memo.get(key);
|
|
137
|
+
if (!kept) return undefined;
|
|
138
|
+
if (kept.until > Date.now()) return kept;
|
|
139
|
+
memo.delete(key);
|
|
140
|
+
return undefined;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* The answer a result gives: a name only when the worker resolved the id
|
|
145
|
+
* completely (a partial answer is the worker running out of time).
|
|
146
|
+
*/
|
|
147
|
+
function answerOf(result: WorkerResult): Answer {
|
|
148
|
+
if (
|
|
149
|
+
result.status === 'partial' ||
|
|
150
|
+
!result.complete ||
|
|
151
|
+
result.kind === 'unknown' ||
|
|
152
|
+
result.name === null ||
|
|
153
|
+
result.source === null
|
|
154
|
+
)
|
|
155
|
+
return null;
|
|
156
|
+
return {
|
|
157
|
+
name: result.name,
|
|
158
|
+
source: result.source,
|
|
159
|
+
selfChosen: result.selfChosen,
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** Keeps a complete answer until `freshUntil`, clamped to {@link MEMO_MIN_MS}–{@link MEMO_MAX_MS}. */
|
|
164
|
+
function remember(baseUrl: string, result: WorkerResult, answer: Answer): void {
|
|
165
|
+
if (result.status === 'partial' || !result.complete) return;
|
|
166
|
+
const now = Date.now();
|
|
167
|
+
const fresh = result.cache?.freshUntil;
|
|
168
|
+
const until =
|
|
169
|
+
typeof fresh === 'number'
|
|
170
|
+
? Math.min(Math.max(fresh * 1000, now + MEMO_MIN_MS), now + MEMO_MAX_MS)
|
|
171
|
+
: now + MEMO_MAX_MS;
|
|
172
|
+
memo.set(memoKey(baseUrl, result.id), { answer, until });
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/* ── pause ────────────────────────────────────────────────────────────── */
|
|
176
|
+
|
|
177
|
+
/** Per worker base URL: the epoch ms until which no POST is sent to it. */
|
|
178
|
+
const pausedUntil = new Map<string, number>();
|
|
179
|
+
|
|
180
|
+
function paused(baseUrl: string): boolean {
|
|
181
|
+
const until = pausedUntil.get(baseUrl);
|
|
182
|
+
if (until === undefined) return false;
|
|
183
|
+
if (until > Date.now()) return true;
|
|
184
|
+
pausedUntil.delete(baseUrl);
|
|
185
|
+
return false;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function pause(baseUrl: string, retryAfterMs: number | undefined): number {
|
|
189
|
+
const ms = Math.min(retryAfterMs ?? PAUSE_DEFAULT_MS, PAUSE_MAX_MS);
|
|
190
|
+
pausedUntil.set(baseUrl, Date.now() + ms);
|
|
191
|
+
return ms;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/* ── one POST ─────────────────────────────────────────────────────────── */
|
|
195
|
+
|
|
196
|
+
/** A POST's own signal: no caller's abort reaches it. */
|
|
197
|
+
const NEVER_ABORTED = new AbortController().signal;
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* Asks the worker for `ids` (at most {@link MAX_BATCH_IDS}); the answers it
|
|
201
|
+
* gave, memoised when complete. Never throws: a failure is logged once
|
|
202
|
+
* and answers nothing; a 429 or 5xx also pauses the worker.
|
|
203
|
+
*/
|
|
204
|
+
async function post(
|
|
205
|
+
target: Target,
|
|
206
|
+
ids: readonly string[],
|
|
207
|
+
fetcher: typeof fetch,
|
|
208
|
+
): Promise<Map<string, Answer>> {
|
|
209
|
+
const answers = new Map<string, Answer>();
|
|
210
|
+
// The POST's own "call": its permits are the batcher's, not a user's.
|
|
211
|
+
const call: ChainCall = {
|
|
212
|
+
network: target.network,
|
|
213
|
+
restUrl: '',
|
|
214
|
+
blocksyncUrl: '',
|
|
215
|
+
user: `names|${target.baseUrl}|${target.clientId}`,
|
|
216
|
+
deadline: Date.now() + POST_TIMEOUT_MS,
|
|
217
|
+
signal: NEVER_ABORTED,
|
|
218
|
+
fetch: fetcher,
|
|
219
|
+
};
|
|
220
|
+
try {
|
|
221
|
+
const attempt = await exchange(
|
|
222
|
+
call,
|
|
223
|
+
'names',
|
|
224
|
+
`${target.baseUrl}/v1/resolve`,
|
|
225
|
+
{
|
|
226
|
+
method: 'POST',
|
|
227
|
+
headers: {
|
|
228
|
+
'content-type': 'application/json',
|
|
229
|
+
'x-client-id': target.clientId,
|
|
230
|
+
},
|
|
231
|
+
body: JSON.stringify({
|
|
232
|
+
ids,
|
|
233
|
+
options: { include: ['name', 'cache'], budgetMs: RESPONSE_BUDGET_MS },
|
|
234
|
+
}),
|
|
235
|
+
},
|
|
236
|
+
{ timeoutMs: POST_TIMEOUT_MS, maxBytes: MAX_RESPONSE_BYTES },
|
|
237
|
+
);
|
|
238
|
+
if (!attempt.ok) {
|
|
239
|
+
const { status } = attempt;
|
|
240
|
+
if (status !== undefined && (status === 429 || status >= 500)) {
|
|
241
|
+
const ms = pause(target.baseUrl, attempt.retryAfterMs);
|
|
242
|
+
logFailure(`${attempt.note}; paused for ${Math.ceil(ms / 1000)} s`);
|
|
243
|
+
} else logFailure(attempt.note);
|
|
244
|
+
return answers;
|
|
245
|
+
}
|
|
246
|
+
let body: unknown;
|
|
247
|
+
try {
|
|
248
|
+
body = JSON.parse(attempt.value);
|
|
249
|
+
} catch {
|
|
250
|
+
logFailure('the answer is not JSON');
|
|
251
|
+
return answers;
|
|
252
|
+
}
|
|
253
|
+
const parsed = responseSchema.safeParse(body);
|
|
254
|
+
if (!parsed.success) {
|
|
255
|
+
logFailure('the answer has an unexpected shape');
|
|
256
|
+
return answers;
|
|
257
|
+
}
|
|
258
|
+
if (parsed.data.network !== target.network) {
|
|
259
|
+
logFailure('the worker answered for another network');
|
|
260
|
+
return answers;
|
|
261
|
+
}
|
|
262
|
+
const asked = new Set(ids);
|
|
263
|
+
for (const result of Object.values(parsed.data.results)) {
|
|
264
|
+
if (!asked.has(result.id)) continue;
|
|
265
|
+
const answer = answerOf(result);
|
|
266
|
+
remember(target.baseUrl, result, answer);
|
|
267
|
+
answers.set(result.id, answer);
|
|
268
|
+
}
|
|
269
|
+
} catch (error) {
|
|
270
|
+
// Nothing above throws by design; a bug here still names nothing.
|
|
271
|
+
logFailure(
|
|
272
|
+
`the request threw (${error instanceof Error ? error.name : 'non-error value'})`,
|
|
273
|
+
);
|
|
274
|
+
}
|
|
275
|
+
return answers;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/* ── batcher ──────────────────────────────────────────────────────────── */
|
|
279
|
+
|
|
280
|
+
/** An id queued or in flight: the promise callers join, and since when it is outstanding. */
|
|
281
|
+
interface Outstanding {
|
|
282
|
+
answer: Promise<Answer>;
|
|
283
|
+
settle: (answer: Answer | Promise<Answer>) => void;
|
|
284
|
+
since: number;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/** The batch gathering ids: its ids, the fetch it is sent with, its start and timer. */
|
|
288
|
+
interface OpenBatch {
|
|
289
|
+
ids: Map<string, Outstanding>;
|
|
290
|
+
fetch: typeof fetch;
|
|
291
|
+
since: number;
|
|
292
|
+
timer: ReturnType<typeof setTimeout>;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/**
|
|
296
|
+
* Gathers one worker's misses into POSTs of at most {@link MAX_BATCH_IDS}.
|
|
297
|
+
* Each id queued or in flight has one promise every caller joins; it
|
|
298
|
+
* settles with the id's answer (null when the POST failed or skipped it)
|
|
299
|
+
* and never rejects, unless the actor behind it died (see the file header).
|
|
300
|
+
*/
|
|
301
|
+
class Batcher {
|
|
302
|
+
private open: OpenBatch | null = null;
|
|
303
|
+
/** In the order the ids were asked, so the oldest come first. */
|
|
304
|
+
private readonly outstanding = new Map<string, Outstanding>();
|
|
305
|
+
|
|
306
|
+
constructor(private readonly target: Target) {}
|
|
307
|
+
|
|
308
|
+
/**
|
|
309
|
+
* The promise of `id`'s answer: joined when it is already queued or in
|
|
310
|
+
* flight, else queued in the open batch (opened with `fetcher`); undefined
|
|
311
|
+
* when {@link MAX_OUTSTANDING_IDS} are outstanding.
|
|
312
|
+
*/
|
|
313
|
+
ask(id: string, fetcher: typeof fetch): Promise<Answer> | undefined {
|
|
314
|
+
this.recover(fetcher);
|
|
315
|
+
const joined = this.outstanding.get(id);
|
|
316
|
+
if (joined) return joined.answer;
|
|
317
|
+
if (this.outstanding.size >= MAX_OUTSTANDING_IDS) return undefined;
|
|
318
|
+
return this.queue(id, fetcher);
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
/** Puts `id` in the open batch, opened with `fetcher` if none is. */
|
|
322
|
+
private queue(id: string, fetcher: typeof fetch): Promise<Answer> {
|
|
323
|
+
let settle: Outstanding['settle'] = () => undefined;
|
|
324
|
+
const answer = new Promise<Answer>((resolve) => {
|
|
325
|
+
settle = resolve;
|
|
326
|
+
});
|
|
327
|
+
const entry = { answer, settle, since: Date.now() };
|
|
328
|
+
this.outstanding.set(id, entry);
|
|
329
|
+
this.open ??= {
|
|
330
|
+
ids: new Map(),
|
|
331
|
+
fetch: fetcher,
|
|
332
|
+
since: entry.since,
|
|
333
|
+
timer: setTimeout(() => this.flush(), BATCH_WINDOW_MS),
|
|
334
|
+
};
|
|
335
|
+
this.open.ids.set(id, entry);
|
|
336
|
+
if (this.open.ids.size >= MAX_BATCH_IDS) this.flush();
|
|
337
|
+
return answer;
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
/**
|
|
341
|
+
* Drops the ids too old to trust, then sends an open batch whose timer is
|
|
342
|
+
* presumed dead, in this caller's name: with its `fetcher` (the batch's
|
|
343
|
+
* own may belong to the dead actor) and under new entries (the old
|
|
344
|
+
* promises may too). The old entries follow the new ones, so a live call
|
|
345
|
+
* that joined the batch still gets its answer.
|
|
346
|
+
*/
|
|
347
|
+
private recover(fetcher: typeof fetch): void {
|
|
348
|
+
const now = Date.now();
|
|
349
|
+
for (const [id, entry] of this.outstanding) {
|
|
350
|
+
if (now - entry.since <= OUTSTANDING_MAX_MS) break;
|
|
351
|
+
this.outstanding.delete(id);
|
|
352
|
+
}
|
|
353
|
+
const stale = this.open;
|
|
354
|
+
if (!stale || now - stale.since <= BATCH_STALE_MS) return;
|
|
355
|
+
this.open = null;
|
|
356
|
+
clearTimeout(stale.timer);
|
|
357
|
+
for (const [id, entry] of stale.ids) {
|
|
358
|
+
if (this.outstanding.get(id) !== entry) continue;
|
|
359
|
+
this.outstanding.delete(id);
|
|
360
|
+
entry.settle(this.queue(id, fetcher));
|
|
361
|
+
}
|
|
362
|
+
this.flush();
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
/** Sends the open batch now (its timer cleared). */
|
|
366
|
+
private flush(): void {
|
|
367
|
+
const batch = this.open;
|
|
368
|
+
if (!batch) return;
|
|
369
|
+
this.open = null;
|
|
370
|
+
clearTimeout(batch.timer);
|
|
371
|
+
void this.send(batch);
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
private async send(batch: OpenBatch): Promise<void> {
|
|
375
|
+
// A pause that began while the batch gathered: nothing is asked.
|
|
376
|
+
const answers = paused(this.target.baseUrl)
|
|
377
|
+
? new Map<string, Answer>()
|
|
378
|
+
: await post(this.target, [...batch.ids.keys()], batch.fetch);
|
|
379
|
+
for (const [id, entry] of batch.ids) {
|
|
380
|
+
entry.settle(answers.get(id) ?? null);
|
|
381
|
+
// An id asked afresh meanwhile keeps its new entry.
|
|
382
|
+
if (this.outstanding.get(id) === entry) this.outstanding.delete(id);
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
/** Tests only: ids queued or in flight, and whether a window timer runs. */
|
|
387
|
+
state(): { outstanding: number; timer: boolean } {
|
|
388
|
+
return { outstanding: this.outstanding.size, timer: this.open !== null };
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
/** Tests only: drops the open batch and its timer (its callers' waits end at their deadlines). */
|
|
392
|
+
drop(): void {
|
|
393
|
+
if (this.open) clearTimeout(this.open.timer);
|
|
394
|
+
this.open = null;
|
|
395
|
+
this.outstanding.clear();
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
/** The isolate's batchers, one per worker base URL and client id. */
|
|
400
|
+
const batchers = new Map<string, Batcher>();
|
|
401
|
+
|
|
402
|
+
function batcherFor(target: Target): Batcher {
|
|
403
|
+
const key = `${target.baseUrl}|${target.clientId}`;
|
|
404
|
+
let batcher = batchers.get(key);
|
|
405
|
+
if (!batcher) {
|
|
406
|
+
batcher = new Batcher(target);
|
|
407
|
+
batchers.set(key, batcher);
|
|
408
|
+
}
|
|
409
|
+
return batcher;
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
/** Tests only: the batcher of `baseUrl` and `clientId`, if one was made. */
|
|
413
|
+
export function batcherState(
|
|
414
|
+
baseUrl: string,
|
|
415
|
+
clientId: string,
|
|
416
|
+
): { outstanding: number; timer: boolean } | undefined {
|
|
417
|
+
return batchers.get(`${baseUrl}|${clientId}`)?.state();
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
/** Tests only: forget the memo, the pauses and every batch. */
|
|
421
|
+
export function resetNamesClient(): void {
|
|
422
|
+
memo = newMemo();
|
|
423
|
+
pausedUntil.clear();
|
|
424
|
+
for (const batcher of batchers.values()) batcher.drop();
|
|
425
|
+
batchers.clear();
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
/* ── one call ─────────────────────────────────────────────────────────── */
|
|
429
|
+
|
|
430
|
+
export interface NamesRequest extends Target {
|
|
431
|
+
fetch: typeof fetch;
|
|
432
|
+
/** Epoch ms after which this call stops waiting (the POST runs on). */
|
|
433
|
+
deadline: number;
|
|
434
|
+
/** The turn's abort: thrown as soon as it fires. */
|
|
435
|
+
signal: AbortSignal;
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
/**
|
|
439
|
+
* The names of `ids` known by `request.deadline`, in the order of `ids`
|
|
440
|
+
* (best placed first); identifiers without a name are absent. Memoised
|
|
441
|
+
* answers come straight from the memo; the rest join or open a batch,
|
|
442
|
+
* unless the worker is paused. Only the turn's abort is thrown.
|
|
443
|
+
*/
|
|
444
|
+
export async function resolveNames(
|
|
445
|
+
ids: readonly string[],
|
|
446
|
+
request: NamesRequest,
|
|
447
|
+
): Promise<NameMap> {
|
|
448
|
+
const answers = new Map<string, Answer>();
|
|
449
|
+
const waits: Promise<void>[] = [];
|
|
450
|
+
const batcher = paused(request.baseUrl) ? null : batcherFor(request);
|
|
451
|
+
for (const id of new Set(ids)) {
|
|
452
|
+
const kept = memoised(request.baseUrl, id);
|
|
453
|
+
if (kept) {
|
|
454
|
+
answers.set(id, kept.answer);
|
|
455
|
+
continue;
|
|
456
|
+
}
|
|
457
|
+
const asked = batcher?.ask(id, request.fetch);
|
|
458
|
+
if (asked) waits.push(asked.then((answer) => void answers.set(id, answer)));
|
|
459
|
+
}
|
|
460
|
+
if (waits.length) await untilDeadline(Promise.all(waits), request);
|
|
461
|
+
const names: NameMap = {};
|
|
462
|
+
for (const id of ids) {
|
|
463
|
+
const answer = answers.get(id);
|
|
464
|
+
if (answer) names[id] = { ...answer };
|
|
465
|
+
}
|
|
466
|
+
return names;
|
|
467
|
+
}
|