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