@johannc-dev/zyrk 0.2.0 → 0.2.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 +1 -1
- package/dist/cli.d.ts +1 -1
- package/dist/cli.js +4 -4
- package/dist/client.d.ts +6 -2
- package/dist/client.js +510 -17
- package/dist/errors.d.ts +9 -1
- package/dist/errors.js +6 -1
- package/dist/export.d.ts +16 -7
- package/dist/export.js +102 -18
- package/dist/hourfile.d.ts +3 -1
- package/dist/hourfile.js +7 -0
- package/dist/limits.d.ts +1 -1
- package/dist/local-data.d.ts +13 -4
- package/dist/local-data.js +29 -1
- package/dist/local-replay.d.ts +5 -5
- package/dist/local-replay.js +4 -3
- package/dist/local-storage.d.ts +2 -2
- package/dist/local.d.ts +2 -2
- package/dist/local.js +46 -32
- package/dist/sandbox-bootstrap.js +171 -9
- package/dist/sdk.d.ts +51 -28
- package/dist/sdk.js +2 -0
- package/dist/stream.d.ts +52 -4
- package/dist/stream.js +111 -13
- package/package.json +3 -3
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# @johannc-dev/zyrk 0.2
|
|
2
2
|
|
|
3
|
-
SDK et CLI des fonctions hébergées Zyrk (
|
|
3
|
+
SDK et CLI des fonctions hébergées Zyrk (V8). Node.js 24
|
|
4
4
|
minimum. Ce package contient les types du programme, quelques aides de
|
|
5
5
|
lecture, le compilateur `zyrk build`, le client HTTP et le flux d'événements.
|
|
6
6
|
Le moteur, le stockage et le sandbox restent sur le serveur.
|
package/dist/cli.d.ts
CHANGED
package/dist/cli.js
CHANGED
|
@@ -8,7 +8,7 @@ import { decode, Json } from "./contracts.js";
|
|
|
8
8
|
import { exportHistory, exportTotals, exportVolumeSearch, } from "./export.js";
|
|
9
9
|
import { LocalRunner } from "./local.js";
|
|
10
10
|
import { DecodeError, messageOf, } from "./errors.js";
|
|
11
|
-
export const VERSION = "0.2.
|
|
11
|
+
export const VERSION = "0.2.1";
|
|
12
12
|
const print = (value) => Console.log(JSON.stringify(value, null, 2));
|
|
13
13
|
const entry = Argument.String("entry.ts").pipe(Argument.withDescription("TypeScript or JavaScript entry point of the program"));
|
|
14
14
|
const program = Argument.String("program").pipe(Argument.withDescription("Program name"));
|
|
@@ -65,8 +65,8 @@ const exportCommand = Command.make("export", {
|
|
|
65
65
|
"with-events": flag("with-events", "Export pool events too (implies --with-market)"),
|
|
66
66
|
"with-order": flag("with-order", "Export batch.order (on-chain order)"),
|
|
67
67
|
"with-tx": flag("with-tx", "Export batch.tx (transaction of each trade, place in it)"),
|
|
68
|
-
refresh: flag("refresh", "Download every hour again (a finished export only re-downloads the hours that were open or not sealed)"),
|
|
69
|
-
totals: optionalString("totals", "Also save
|
|
68
|
+
refresh: flag("refresh", "Download every hour again (a finished export only re-downloads the hours that were open or not sealed, or exported by an older version)"),
|
|
69
|
+
totals: optionalString("totals", "Also save the totals of this mint (ctx.data.tokenTotals, tokenInfo)"),
|
|
70
70
|
quote: optionalString("quote", "Quote mint of --totals"),
|
|
71
71
|
"volume-search": optionalString("volume-search", "Also save every page of ctx.data.volumeSearch(<json params>)"),
|
|
72
72
|
rpm: Flag.Int("rpm").pipe(Flag.withDescription("At most this many requests per minute"), Flag.optional),
|
|
@@ -139,7 +139,7 @@ const devCommand = Command.make("dev", {
|
|
|
139
139
|
replay: optionalString("replay", "Replay this subscription over --since/--until: <function> is ignored, json is unused"),
|
|
140
140
|
since: optionalString("since", "Replay start"),
|
|
141
141
|
until: optionalString("until", "Replay end"),
|
|
142
|
-
"events-per-delivery": Flag.Int("events-per-delivery").pipe(Flag.withDescription("Events per replayed delivery (1 to 32, default 1)"), Flag.optional),
|
|
142
|
+
"events-per-delivery": Flag.Int("events-per-delivery").pipe(Flag.withDescription("Events per replayed delivery (1 to 32, default 32; interval triggers: 1)"), Flag.optional),
|
|
143
143
|
quiet: flag("quiet", "Do not print logs and emits as they happen"),
|
|
144
144
|
"no-typecheck": noTypecheck,
|
|
145
145
|
}, (input) => local("dev", async () => {
|
package/dist/client.d.ts
CHANGED
|
@@ -18,7 +18,11 @@ export interface StartInput {
|
|
|
18
18
|
readonly requestId?: string;
|
|
19
19
|
}
|
|
20
20
|
export interface WaitOptions {
|
|
21
|
-
/**
|
|
21
|
+
/**
|
|
22
|
+
* Delay between two status checks, in milliseconds. Without it, each check
|
|
23
|
+
* is held by the service until the execution ends (at most 10 s), and a
|
|
24
|
+
* service that cannot hold is checked once a second.
|
|
25
|
+
*/
|
|
22
26
|
readonly pollMs?: number;
|
|
23
27
|
/** Give up after this many milliseconds (default: never). The execution keeps running. */
|
|
24
28
|
readonly timeoutMs?: number;
|
|
@@ -154,7 +158,7 @@ export declare class FunctionsClient {
|
|
|
154
158
|
*/
|
|
155
159
|
replayEmits(id: string, afterSeq?: number, signal?: AbortSignal): Promise<ReplayFeedItem[]>;
|
|
156
160
|
}
|
|
157
|
-
/**
|
|
161
|
+
/** The head of a stored object as the HTTP headers describe it. */
|
|
158
162
|
export interface HttpStorageHead {
|
|
159
163
|
readonly key: string;
|
|
160
164
|
readonly size: number;
|
package/dist/client.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { Cause, Config, Context, Effect, Exit, Layer, Option, Redacted, Schema, } from "effect";
|
|
1
|
+
import { Cause, Config, Context, Effect, Exit, Layer, Option, Redacted, Result, Schema, } from "effect";
|
|
2
2
|
import { decode, Job, Json, ProgramSummary, Started, toJobResult, } from "./contracts.js";
|
|
3
3
|
import { ApiError, ConfigError, DecodeError, ExecutionEnded, ExecutionFailed, Failure, messageOf, NetworkError, WaitTimeout, } from "./errors.js";
|
|
4
4
|
export * from "./errors.js";
|
|
@@ -38,9 +38,50 @@ const parseJson = (text) => {
|
|
|
38
38
|
};
|
|
39
39
|
const segment = encodeURIComponent;
|
|
40
40
|
const isFinal = (r) => r.status !== "queued" && r.status !== "running";
|
|
41
|
+
const MAX_HOLD_MS = 10_000;
|
|
42
|
+
const POLL_MS = 1_000;
|
|
43
|
+
const FOLLOW_SLACK_MS = 25;
|
|
44
|
+
const MAX_WAIT_IDS = 100;
|
|
45
|
+
const WAITED_HEADER = "x-functions-waited";
|
|
46
|
+
const rhythmFrom = (start) => ({
|
|
47
|
+
anchor: start,
|
|
48
|
+
last: start,
|
|
49
|
+
rtt: undefined,
|
|
50
|
+
});
|
|
51
|
+
const periodOf = (rhythm) => POLL_MS + (rhythm.rtt ?? 0);
|
|
52
|
+
const tickFrom = (rhythm, t) => {
|
|
53
|
+
const period = periodOf(rhythm);
|
|
54
|
+
const from = Math.max(t, rhythm.last + 1);
|
|
55
|
+
const k = Math.max(0, Math.ceil((from - rhythm.anchor) / period));
|
|
56
|
+
return rhythm.anchor + k * period;
|
|
57
|
+
};
|
|
58
|
+
const tickBefore = (rhythm, t) => t < rhythm.anchor
|
|
59
|
+
? -Infinity
|
|
60
|
+
: rhythm.anchor +
|
|
61
|
+
Math.floor((t - rhythm.anchor) / periodOf(rhythm)) * periodOf(rhythm);
|
|
62
|
+
const answered = (rhythm, sentAt, now, tick = sentAt) => {
|
|
63
|
+
rhythm.rtt = now - sentAt;
|
|
64
|
+
rhythm.anchor = tick + rhythm.rtt + POLL_MS;
|
|
65
|
+
};
|
|
66
|
+
const planFor = (rhythm, sentAt, rtt, offset, cap) => {
|
|
67
|
+
if (!(cap >= POLL_MS))
|
|
68
|
+
return undefined;
|
|
69
|
+
const cappedAt = Math.min(cap, MAX_HOLD_MS);
|
|
70
|
+
const tick = tickBefore(rhythm, sentAt + rtt + cappedAt - offset);
|
|
71
|
+
const waitMs = tick + offset - sentAt - rtt;
|
|
72
|
+
return tick > rhythm.last && waitMs >= POLL_MS
|
|
73
|
+
? { waitMs, tick }
|
|
74
|
+
: { waitMs: cappedAt, tick: undefined };
|
|
75
|
+
};
|
|
76
|
+
const transient = (error) => error instanceof NetworkError ||
|
|
77
|
+
(error instanceof ApiError && [502, 503, 504].includes(error.status));
|
|
78
|
+
const sleepUntil = (t) => Effect.suspend(() => {
|
|
79
|
+
const ms = t - Date.now();
|
|
80
|
+
return ms > 0 ? Effect.sleep(ms) : Effect.void;
|
|
81
|
+
});
|
|
41
82
|
export const make = (config) => {
|
|
42
83
|
const base = `${config.url.replace(/\/$/, "")}/v1/functions/`;
|
|
43
|
-
const
|
|
84
|
+
const send = Effect.fnUntraced(function* (method, path, body) {
|
|
44
85
|
const url = base + path;
|
|
45
86
|
const response = yield* Effect.tryPromise({
|
|
46
87
|
try: async (signal) => {
|
|
@@ -57,6 +98,7 @@ export const make = (config) => {
|
|
|
57
98
|
return {
|
|
58
99
|
status: response.status,
|
|
59
100
|
ok: response.ok,
|
|
101
|
+
waited: response.headers?.get?.(WAITED_HEADER) === "1",
|
|
60
102
|
text: await response.text(),
|
|
61
103
|
};
|
|
62
104
|
},
|
|
@@ -74,15 +116,53 @@ export const make = (config) => {
|
|
|
74
116
|
what: `${method} ${path}`,
|
|
75
117
|
message: "Response body is not JSON",
|
|
76
118
|
});
|
|
77
|
-
|
|
119
|
+
const json = yield* decode(Json, `${method} ${path}`)(parsed.value);
|
|
120
|
+
return { json, waited: response.waited };
|
|
121
|
+
});
|
|
122
|
+
const request = (method, path, body) => send(method, path, body).pipe(Effect.map((r) => r.json));
|
|
123
|
+
const executionAt = (id, query = "") => send("GET", `executions/${segment(id)}${query}`).pipe(Effect.flatMap((reply) => decode(Job, "execution")(reply.json).pipe(Effect.map((job) => ({ job, waited: reply.waited })))));
|
|
124
|
+
const execution = (id) => executionAt(id).pipe(Effect.map((r) => r.job));
|
|
125
|
+
const followWith = (id, cursor, query) => Effect.suspend(() => executionAt(cursor.current, query()).pipe(Effect.flatMap(({ job, waited }) => {
|
|
126
|
+
if (job.status !== "continued" || cursor.hops >= 10_000)
|
|
127
|
+
return Effect.succeed({
|
|
128
|
+
result: { ...toJobResult(job), id },
|
|
129
|
+
waited,
|
|
130
|
+
});
|
|
131
|
+
cursor.current = job.nextExecutionId;
|
|
132
|
+
cursor.hops++;
|
|
133
|
+
return followWith(id, cursor, query);
|
|
134
|
+
})));
|
|
135
|
+
const follow = (id, cursor) => followWith(id, cursor, () => "").pipe(Effect.map((r) => r.result));
|
|
136
|
+
const cursorAt = (id) => ({ current: id, hops: 0 });
|
|
137
|
+
const result = (id) => follow(id, cursorAt(id));
|
|
138
|
+
const deadlineOf = (options) => options.timeoutMs === undefined ? Infinity : Date.now() + options.timeoutMs;
|
|
139
|
+
const waitQuery = (plan) => plan === undefined ? "" : `?waitMs=${plan.waitMs}`;
|
|
140
|
+
const guarded = (check, held, deadline) => held && deadline !== Infinity
|
|
141
|
+
? check.pipe(Effect.timeoutOption(Math.max(0, deadline - Date.now())))
|
|
142
|
+
: check.pipe(Effect.map(Option.some));
|
|
143
|
+
const recover = (rhythm, failedAt, deadline, check, one = rhythm.rtt, offset = 0) => Effect.gen(function* () {
|
|
144
|
+
if (failedAt >= deadline)
|
|
145
|
+
return Option.none();
|
|
146
|
+
const safe = failedAt + POLL_MS;
|
|
147
|
+
const due = one === undefined
|
|
148
|
+
? undefined
|
|
149
|
+
: tickFrom(rhythm, failedAt - one - offset);
|
|
150
|
+
const at = Math.min(due === undefined ? safe : Math.max(due + offset, failedAt), deadline);
|
|
151
|
+
const tick = due !== undefined && due < deadline ? due : undefined;
|
|
152
|
+
yield* sleepUntil(at);
|
|
153
|
+
rhythm.last = Math.max(rhythm.last, tick ?? at);
|
|
154
|
+
const sentAt = Date.now();
|
|
155
|
+
const first = yield* Effect.result(check);
|
|
156
|
+
if (Result.isSuccess(first))
|
|
157
|
+
return Option.some({ value: first.success, sentAt, tick });
|
|
158
|
+
if (!transient(first.failure) || sentAt >= safe || Date.now() >= deadline)
|
|
159
|
+
return yield* Effect.fail(first.failure);
|
|
160
|
+
yield* sleepUntil(Math.min(safe, deadline));
|
|
161
|
+
const again = Date.now();
|
|
162
|
+
rhythm.last = Math.max(rhythm.last, again);
|
|
163
|
+
const value = yield* check;
|
|
164
|
+
return Option.some({ value, sentAt: again, tick: undefined });
|
|
78
165
|
});
|
|
79
|
-
const execution = (id) => request("GET", `executions/${segment(id)}`).pipe(Effect.flatMap(decode(Job, "execution")));
|
|
80
|
-
const result = (id) => {
|
|
81
|
-
const follow = (current, hops) => execution(current).pipe(Effect.flatMap((job) => job.status === "continued" && hops < 10_000
|
|
82
|
-
? follow(job.nextExecutionId, hops + 1)
|
|
83
|
-
: Effect.succeed({ ...toJobResult(job), id })));
|
|
84
|
-
return follow(id, 0);
|
|
85
|
-
};
|
|
86
166
|
const poll = (check, options, onTimeout) => {
|
|
87
167
|
const deadline = options.timeoutMs === undefined
|
|
88
168
|
? Infinity
|
|
@@ -98,7 +178,10 @@ export const make = (config) => {
|
|
|
98
178
|
})));
|
|
99
179
|
return step;
|
|
100
180
|
};
|
|
101
|
-
const wait = (id, options = {}) =>
|
|
181
|
+
const wait = (id, options = {}) => Effect.suspend(() => options.pollMs === undefined
|
|
182
|
+
? waitHeld(id, cursorAt(id), options)
|
|
183
|
+
: waitFrom(id, cursorAt(id), options));
|
|
184
|
+
const outcome = (id, r) => {
|
|
102
185
|
switch (r.status) {
|
|
103
186
|
case "succeeded":
|
|
104
187
|
return Effect.succeed(Option.some(r.result));
|
|
@@ -112,17 +195,427 @@ export const make = (config) => {
|
|
|
112
195
|
case "running":
|
|
113
196
|
return Effect.succeed(Option.none());
|
|
114
197
|
}
|
|
115
|
-
}
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
198
|
+
};
|
|
199
|
+
const timedOut = (id, options) => Effect.fail(new WaitTimeout({ executionId: id, timeoutMs: options.timeoutMs ?? 0 }));
|
|
200
|
+
const waitFrom = (id, cursor, options) => poll(follow(id, cursor).pipe(Effect.flatMap((r) => outcome(id, r))), options, () => timedOut(id, options));
|
|
201
|
+
const holdOne = (id, cursor, options) => Effect.gen(function* () {
|
|
202
|
+
const start = Date.now();
|
|
203
|
+
const deadline = deadlineOf(options);
|
|
204
|
+
const rhythm = rhythmFrom(start);
|
|
205
|
+
const plain = followWith(id, cursor, () => "");
|
|
206
|
+
let failures = 0;
|
|
207
|
+
let polling = false;
|
|
208
|
+
let at = start;
|
|
209
|
+
let tick = start;
|
|
210
|
+
let sentAt = start;
|
|
211
|
+
let plan;
|
|
212
|
+
const planNow = () => {
|
|
213
|
+
sentAt = Date.now();
|
|
214
|
+
const rtt = rhythm.rtt ?? 0;
|
|
215
|
+
plan = polling
|
|
216
|
+
? undefined
|
|
217
|
+
: planFor(rhythm, sentAt, rtt, 0, deadline - sentAt - rtt);
|
|
218
|
+
};
|
|
219
|
+
for (;;) {
|
|
220
|
+
yield* sleepUntil(at);
|
|
221
|
+
if (tick !== undefined)
|
|
222
|
+
rhythm.last = Math.max(rhythm.last, tick);
|
|
223
|
+
planNow();
|
|
224
|
+
const held = plan !== undefined;
|
|
225
|
+
const read = Effect.suspend(() => executionAt(cursor.current, waitQuery(plan)));
|
|
226
|
+
const tried = yield* Effect.result(guarded(read, held, deadline));
|
|
227
|
+
let result;
|
|
228
|
+
let now;
|
|
229
|
+
if (Result.isFailure(tried)) {
|
|
230
|
+
if (!held || !transient(tried.failure))
|
|
231
|
+
return yield* Effect.fail(tried.failure);
|
|
232
|
+
if (++failures >= 2)
|
|
233
|
+
polling = true;
|
|
234
|
+
const recovered = yield* recover(rhythm, Date.now(), deadline, plain);
|
|
235
|
+
if (Option.isNone(recovered))
|
|
236
|
+
return { latest: undefined };
|
|
237
|
+
const { value, sentAt: s, tick: due } = recovered.value;
|
|
238
|
+
now = Date.now();
|
|
239
|
+
result = value.result;
|
|
240
|
+
if (due !== undefined)
|
|
241
|
+
answered(rhythm, s, now, due);
|
|
242
|
+
else if (rhythm.rtt === undefined)
|
|
243
|
+
rhythm.rtt = now - s;
|
|
244
|
+
const lag = due === undefined ? 0 : s - due;
|
|
245
|
+
tick = tickFrom(rhythm, now - lag);
|
|
246
|
+
at = Math.max(tick, now);
|
|
247
|
+
}
|
|
248
|
+
else if (Option.isNone(tried.success)) {
|
|
249
|
+
const { result: last } = yield* plain;
|
|
250
|
+
return { latest: last };
|
|
251
|
+
}
|
|
252
|
+
else {
|
|
253
|
+
const { job, waited } = tried.success.value;
|
|
254
|
+
now = Date.now();
|
|
255
|
+
const asked = plan?.waitMs ?? 0;
|
|
256
|
+
const heldMs = now - sentAt;
|
|
257
|
+
const full = asked > 0 && heldMs >= Math.max(POLL_MS, 0.9 * asked);
|
|
258
|
+
if (job.status === "continued" && cursor.hops < 10_000) {
|
|
259
|
+
cursor.current = job.nextExecutionId;
|
|
260
|
+
cursor.hops++;
|
|
261
|
+
if (!waited || heldMs <= (rhythm.rtt ?? 0) + FOLLOW_SLACK_MS) {
|
|
262
|
+
if (tick !== undefined) {
|
|
263
|
+
if (!waited)
|
|
264
|
+
rhythm.rtt = heldMs;
|
|
265
|
+
rhythm.anchor = now + (rhythm.rtt ?? 0) + POLL_MS;
|
|
266
|
+
}
|
|
267
|
+
at = now;
|
|
268
|
+
}
|
|
269
|
+
else {
|
|
270
|
+
failures = 0;
|
|
271
|
+
const rtt = rhythm.rtt ?? 0;
|
|
272
|
+
tick = tickFrom(rhythm, now - rtt);
|
|
273
|
+
at = tick + rtt;
|
|
274
|
+
rhythm.anchor = at + rtt + POLL_MS;
|
|
275
|
+
}
|
|
276
|
+
if (at >= deadline) {
|
|
277
|
+
at = deadline;
|
|
278
|
+
tick = undefined;
|
|
279
|
+
}
|
|
280
|
+
continue;
|
|
281
|
+
}
|
|
282
|
+
result = { ...toJobResult(job), id };
|
|
283
|
+
if (!waited) {
|
|
284
|
+
if (tick !== undefined)
|
|
285
|
+
answered(rhythm, sentAt, now);
|
|
286
|
+
else if (rhythm.rtt === undefined)
|
|
287
|
+
rhythm.rtt = heldMs;
|
|
288
|
+
tick = at = tickFrom(rhythm, now);
|
|
289
|
+
}
|
|
290
|
+
else if (!full) {
|
|
291
|
+
rhythm.anchor = now + POLL_MS;
|
|
292
|
+
tick = at = rhythm.anchor;
|
|
293
|
+
}
|
|
294
|
+
else {
|
|
295
|
+
failures = 0;
|
|
296
|
+
if (rhythm.rtt === undefined) {
|
|
297
|
+
rhythm.rtt = Math.max(0, heldMs - asked);
|
|
298
|
+
tick = at = tickFrom(rhythm, now);
|
|
299
|
+
}
|
|
300
|
+
else if (plan?.tick !== undefined) {
|
|
301
|
+
tick = plan.tick;
|
|
302
|
+
at = Math.max(now, plan.tick);
|
|
303
|
+
}
|
|
304
|
+
else
|
|
305
|
+
tick = at = tickFrom(rhythm, now);
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
if (isFinal(result) || now >= deadline)
|
|
309
|
+
return { latest: result };
|
|
310
|
+
if (at >= deadline) {
|
|
311
|
+
at = deadline;
|
|
312
|
+
tick = undefined;
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
});
|
|
316
|
+
const waitHeld = (id, cursor, options) => holdOne(id, cursor, options).pipe(Effect.flatMap(({ latest }) => latest === undefined
|
|
317
|
+
? timedOut(id, options)
|
|
318
|
+
: outcome(id, latest).pipe(Effect.flatMap(Option.match({
|
|
319
|
+
onSome: Effect.succeed,
|
|
320
|
+
onNone: () => timedOut(id, options),
|
|
321
|
+
})))));
|
|
322
|
+
const waitAll = (ids, options = {}) => options.pollMs === undefined
|
|
323
|
+
? waitAllHeld(ids, options)
|
|
324
|
+
: waitAllPolled(ids, options);
|
|
325
|
+
const waitAllPolled = (ids, options) => Effect.suspend(() => {
|
|
120
326
|
const latest = new Map();
|
|
327
|
+
const entries = ids.map((id) => ({ id, cursor: cursorAt(id) }));
|
|
121
328
|
const snapshot = () => ids.map((id) => latest.get(id));
|
|
122
|
-
const check = Effect.forEach(
|
|
329
|
+
const check = Effect.forEach(entries, ({ id, cursor }) => follow(id, cursor).pipe(Effect.tap((r) => Effect.sync(() => latest.set(id, r)))), { concurrency: 8 }).pipe(Effect.map(() => ids.every((id) => isFinal(latest.get(id)))
|
|
123
330
|
? Option.some(snapshot())
|
|
124
331
|
: Option.none()));
|
|
125
332
|
return poll(check, options, () => Effect.sync(snapshot));
|
|
333
|
+
});
|
|
334
|
+
const waitAllHeld = (ids, options) => Effect.suspend(() => {
|
|
335
|
+
if (ids.length === 1) {
|
|
336
|
+
const id = ids[0];
|
|
337
|
+
const cursor = cursorAt(id);
|
|
338
|
+
return holdOne(id, cursor, options).pipe(Effect.flatMap(({ latest }) => latest === undefined ? follow(id, cursor) : Effect.succeed(latest)), Effect.map((r) => [r]));
|
|
339
|
+
}
|
|
340
|
+
return waitMany(ids, options);
|
|
341
|
+
});
|
|
342
|
+
const waitMany = (ids, options) => Effect.gen(function* () {
|
|
343
|
+
const start = Date.now();
|
|
344
|
+
const deadline = deadlineOf(options);
|
|
345
|
+
const rhythm = rhythmFrom(start);
|
|
346
|
+
const latest = new Map();
|
|
347
|
+
const entries = ids.map((id) => ({ id, cursor: cursorAt(id) }));
|
|
348
|
+
const snapshot = () => ids.map((id) => latest.get(id));
|
|
349
|
+
const allFinal = () => ids.every((id) => isFinal(latest.get(id)));
|
|
350
|
+
const seen = (entry, r) => {
|
|
351
|
+
latest.set(entry.id, r);
|
|
352
|
+
entry.readAt = entry.cursor.current;
|
|
353
|
+
};
|
|
354
|
+
let sent = 0;
|
|
355
|
+
const pollingSent = (t) => Math.max(Math.min(ids.length, 8), (ids.length * (t - start)) / periodOf(rhythm));
|
|
356
|
+
let rtt1;
|
|
357
|
+
const pool = (walks) => {
|
|
358
|
+
const slots = Array(Math.min(8, entries.length)).fill(0);
|
|
359
|
+
const starts = entries.map((_, j) => {
|
|
360
|
+
const i = slots.indexOf(Math.min(...slots));
|
|
361
|
+
const startsAt = slots[i];
|
|
362
|
+
slots[i] = startsAt + (1 + (walks?.[j] ?? 0)) * (rtt1 ?? 0);
|
|
363
|
+
return startsAt;
|
|
364
|
+
});
|
|
365
|
+
return { starts, length: Math.max(0, ...slots) };
|
|
366
|
+
};
|
|
367
|
+
const roundTime = (walks) => pool(walks).length;
|
|
368
|
+
const roundEnded = (sentAt, now, tick = sentAt) => {
|
|
369
|
+
answered(rhythm, sentAt, now, tick);
|
|
370
|
+
rhythm.rtt = roundTime();
|
|
371
|
+
};
|
|
372
|
+
const offsetOf = () => Math.max(0, (rhythm.rtt ?? 0) - (rtt1 ?? 0));
|
|
373
|
+
const round = (list, query) => Effect.suspend(() => {
|
|
374
|
+
sent += list.length;
|
|
375
|
+
return Effect.forEach(list, (entry) => Effect.suspend(() => {
|
|
376
|
+
const t = Date.now();
|
|
377
|
+
return followWith(entry.id, entry.cursor, query).pipe(Effect.tap((r) => Effect.sync(() => seen(entry, r.result))), Effect.map((r) => ({
|
|
378
|
+
waited: r.waited,
|
|
379
|
+
took: Date.now() - t,
|
|
380
|
+
})));
|
|
381
|
+
}), { concurrency: 8 });
|
|
382
|
+
}).pipe(Effect.map((reads) => {
|
|
383
|
+
for (const r of reads)
|
|
384
|
+
rtt1 = Math.min(rtt1 ?? Infinity, r.took);
|
|
385
|
+
return reads.some((r) => r.waited);
|
|
386
|
+
}));
|
|
387
|
+
const plainRound = () => round(entries, () => "");
|
|
388
|
+
const fresh = Effect.suspend(() => {
|
|
389
|
+
const stale = entries.filter((e) => e.readAt !== e.cursor.current);
|
|
390
|
+
return stale.length === 0
|
|
391
|
+
? Effect.sync(snapshot)
|
|
392
|
+
: round(stale, () => "").pipe(Effect.map(snapshot));
|
|
393
|
+
});
|
|
394
|
+
const multi = (waitMs) => {
|
|
395
|
+
const distinct = [...new Set(entries.map((e) => e.cursor.current))];
|
|
396
|
+
const chunks = [];
|
|
397
|
+
for (let i = 0; i < distinct.length; i += MAX_WAIT_IDS)
|
|
398
|
+
chunks.push(distinct.slice(i, i + MAX_WAIT_IDS));
|
|
399
|
+
sent += chunks.length;
|
|
400
|
+
return Effect.forEach(chunks, (chunk) => send("POST", "executions/wait", { ids: chunk, waitMs }).pipe(Effect.flatMap((reply) => readMulti(chunk, reply))), { concurrency: 8 }).pipe(Effect.catchIf((e) => e instanceof ApiError && [400, 404, 405].includes(e.status), () => Effect.succeed(null)), Effect.flatMap((answers) => {
|
|
401
|
+
if (answers === null)
|
|
402
|
+
return Effect.succeed(null);
|
|
403
|
+
const records = new Map();
|
|
404
|
+
for (const answer of answers)
|
|
405
|
+
for (const [id, record] of answer.records)
|
|
406
|
+
records.set(id, record);
|
|
407
|
+
let advanced = false;
|
|
408
|
+
let progressed = false;
|
|
409
|
+
const walks = entries.map(() => 0);
|
|
410
|
+
for (const [j, entry] of entries.entries()) {
|
|
411
|
+
const { cursor } = entry;
|
|
412
|
+
const record = records.get(cursor.current);
|
|
413
|
+
if (record instanceof ApiError)
|
|
414
|
+
return Effect.fail(record);
|
|
415
|
+
if (record.status === "continued" && cursor.hops < 10_000) {
|
|
416
|
+
cursor.current = record.nextExecutionId;
|
|
417
|
+
cursor.hops++;
|
|
418
|
+
advanced = true;
|
|
419
|
+
walks[j] = 1;
|
|
420
|
+
}
|
|
421
|
+
else {
|
|
422
|
+
const r = { ...toJobResult(record), id: entry.id };
|
|
423
|
+
const before = latest.get(entry.id);
|
|
424
|
+
if (before !== undefined && !isFinal(before) && isFinal(r))
|
|
425
|
+
progressed = true;
|
|
426
|
+
seen(entry, r);
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
return Effect.succeed({
|
|
430
|
+
waited: answers.every((a) => a.waited),
|
|
431
|
+
advanced,
|
|
432
|
+
progressed,
|
|
433
|
+
walks,
|
|
434
|
+
});
|
|
435
|
+
}));
|
|
436
|
+
};
|
|
437
|
+
if (ids.length === 0)
|
|
438
|
+
return [];
|
|
439
|
+
let mode = "probe";
|
|
440
|
+
let failures = 0;
|
|
441
|
+
let at = start;
|
|
442
|
+
let tick = start;
|
|
443
|
+
let capped = false;
|
|
444
|
+
let current = start;
|
|
445
|
+
let onRound = true;
|
|
446
|
+
let following = false;
|
|
447
|
+
let roundWalks = entries.map(() => 0);
|
|
448
|
+
let nextWalks;
|
|
449
|
+
for (;;) {
|
|
450
|
+
yield* sleepUntil(at);
|
|
451
|
+
if (tick !== undefined) {
|
|
452
|
+
rhythm.last = current = tick;
|
|
453
|
+
roundWalks = nextWalks ?? entries.map(() => 0);
|
|
454
|
+
nextWalks = undefined;
|
|
455
|
+
}
|
|
456
|
+
onRound = tick !== undefined || (following && onRound);
|
|
457
|
+
following = false;
|
|
458
|
+
const checkAt = Date.now();
|
|
459
|
+
const offset = capped ? offsetOf() : 0;
|
|
460
|
+
let now;
|
|
461
|
+
if (mode !== "multi") {
|
|
462
|
+
const waited = yield* round(entries, mode === "probe" ? () => "?waitMs=0" : () => "");
|
|
463
|
+
now = Date.now();
|
|
464
|
+
if (tick !== undefined)
|
|
465
|
+
roundEnded(checkAt, now);
|
|
466
|
+
if (mode === "probe" && waited)
|
|
467
|
+
mode = "multi";
|
|
468
|
+
if (allFinal() || now >= deadline)
|
|
469
|
+
return yield* fresh;
|
|
470
|
+
tick = tickFrom(rhythm, now);
|
|
471
|
+
at = tick;
|
|
472
|
+
}
|
|
473
|
+
else {
|
|
474
|
+
const one = rtt1 ?? 0;
|
|
475
|
+
const plan = planFor(rhythm, checkAt, one, offset, deadline - checkAt - one);
|
|
476
|
+
const tried = yield* Effect.result(guarded(multi(plan?.waitMs ?? 0), plan !== undefined, deadline));
|
|
477
|
+
now = Date.now();
|
|
478
|
+
if (Result.isFailure(tried)) {
|
|
479
|
+
if (plan === undefined || !transient(tried.failure))
|
|
480
|
+
return yield* Effect.fail(tried.failure);
|
|
481
|
+
if (++failures >= 2)
|
|
482
|
+
mode = "rounds";
|
|
483
|
+
const late = offsetOf();
|
|
484
|
+
const recovered = yield* recover(rhythm, now, deadline, multi(0), one, late);
|
|
485
|
+
if (Option.isNone(recovered))
|
|
486
|
+
return yield* fresh;
|
|
487
|
+
now = Date.now();
|
|
488
|
+
const { value: r, sentAt, tick: due } = recovered.value;
|
|
489
|
+
if (r === null) {
|
|
490
|
+
mode = "rounds";
|
|
491
|
+
yield* plainRound();
|
|
492
|
+
now = Date.now();
|
|
493
|
+
}
|
|
494
|
+
if (due !== undefined)
|
|
495
|
+
rhythm.anchor = due + roundTime() + POLL_MS;
|
|
496
|
+
if ((r === null || !r.advanced) && allFinal())
|
|
497
|
+
return yield* fresh;
|
|
498
|
+
if (now >= deadline)
|
|
499
|
+
return yield* fresh;
|
|
500
|
+
if (r !== null && r.advanced) {
|
|
501
|
+
tick = undefined;
|
|
502
|
+
at = now;
|
|
503
|
+
following = due !== undefined;
|
|
504
|
+
}
|
|
505
|
+
else {
|
|
506
|
+
const lag = due === undefined ? 0 : sentAt - (due + late);
|
|
507
|
+
tick = tickFrom(rhythm, now - lag);
|
|
508
|
+
at = Math.max(tick, now);
|
|
509
|
+
}
|
|
510
|
+
}
|
|
511
|
+
else if (Option.isNone(tried.success)) {
|
|
512
|
+
yield* multi(0);
|
|
513
|
+
return yield* fresh;
|
|
514
|
+
}
|
|
515
|
+
else if (tried.success.value === null) {
|
|
516
|
+
mode = "rounds";
|
|
517
|
+
yield* plainRound();
|
|
518
|
+
const end = Date.now();
|
|
519
|
+
roundEnded(now, end);
|
|
520
|
+
if (allFinal() || end >= deadline)
|
|
521
|
+
return yield* fresh;
|
|
522
|
+
tick = tickFrom(rhythm, end);
|
|
523
|
+
at = tick;
|
|
524
|
+
}
|
|
525
|
+
else {
|
|
526
|
+
const r = tried.success.value;
|
|
527
|
+
if (!r.advanced && allFinal())
|
|
528
|
+
return yield* fresh;
|
|
529
|
+
if (now >= deadline)
|
|
530
|
+
return yield* fresh;
|
|
531
|
+
const asked = plan?.waitMs ?? 0;
|
|
532
|
+
const heldMs = now - checkAt;
|
|
533
|
+
const full = r.waited && asked > 0 && heldMs >= Math.max(POLL_MS, 0.9 * asked);
|
|
534
|
+
if (full || (r.waited && (r.progressed || r.advanced)))
|
|
535
|
+
failures = 0;
|
|
536
|
+
capped = !r.waited;
|
|
537
|
+
const after = capped ? offsetOf() : 0;
|
|
538
|
+
const walked = r.advanced && r.waited && heldMs > one + FOLLOW_SLACK_MS;
|
|
539
|
+
if (onRound && !walked)
|
|
540
|
+
r.walks.forEach((w, j) => (roundWalks[j] += w));
|
|
541
|
+
const last = current + Math.max(0, roundTime(roundWalks) - one);
|
|
542
|
+
const again = capped && onRound && !r.advanced && last > now;
|
|
543
|
+
if (capped && onRound && !r.advanced && !again)
|
|
544
|
+
rhythm.anchor = now + POLL_MS;
|
|
545
|
+
const next = again ? current : tickFrom(rhythm, now);
|
|
546
|
+
const nextAt = again ? last : next + after;
|
|
547
|
+
if (walked && entries.length > 8) {
|
|
548
|
+
const { starts } = pool(roundWalks);
|
|
549
|
+
r.walks.forEach((w, j) => {
|
|
550
|
+
if (w === 0)
|
|
551
|
+
return;
|
|
552
|
+
if (current + starts[j] + one >= now)
|
|
553
|
+
roundWalks[j] += w;
|
|
554
|
+
else
|
|
555
|
+
(nextWalks ??= entries.map(() => 0))[j] += w;
|
|
556
|
+
});
|
|
557
|
+
rhythm.anchor = current + roundTime(roundWalks) + POLL_MS;
|
|
558
|
+
}
|
|
559
|
+
if (walked && entries.length <= 8) {
|
|
560
|
+
const length = roundTime(r.walks);
|
|
561
|
+
tick = tickFrom(rhythm, now - one);
|
|
562
|
+
at = tick + Math.max(0, length - one);
|
|
563
|
+
rhythm.anchor = tick + length + POLL_MS;
|
|
564
|
+
nextWalks = r.walks;
|
|
565
|
+
}
|
|
566
|
+
else if (r.advanced ||
|
|
567
|
+
(r.waited && r.progressed) ||
|
|
568
|
+
(full && plan?.tick === undefined)) {
|
|
569
|
+
if (r.advanced && onRound && r.waited && !walked)
|
|
570
|
+
rhythm.anchor = now + one + POLL_MS;
|
|
571
|
+
const chunks = Math.ceil(entries.length / MAX_WAIT_IDS);
|
|
572
|
+
if (sent + chunks <= pollingSent(now)) {
|
|
573
|
+
tick = undefined;
|
|
574
|
+
at = now;
|
|
575
|
+
following = r.advanced;
|
|
576
|
+
}
|
|
577
|
+
else {
|
|
578
|
+
tick = next;
|
|
579
|
+
at = nextAt;
|
|
580
|
+
}
|
|
581
|
+
}
|
|
582
|
+
else if (full && plan?.tick !== undefined) {
|
|
583
|
+
tick = plan.tick;
|
|
584
|
+
at = Math.max(now, plan.tick + offset);
|
|
585
|
+
}
|
|
586
|
+
else {
|
|
587
|
+
tick = next;
|
|
588
|
+
at = nextAt;
|
|
589
|
+
}
|
|
590
|
+
}
|
|
591
|
+
}
|
|
592
|
+
if (at >= deadline) {
|
|
593
|
+
at = deadline;
|
|
594
|
+
tick = undefined;
|
|
595
|
+
}
|
|
596
|
+
}
|
|
597
|
+
});
|
|
598
|
+
const readMulti = (chunk, reply) => {
|
|
599
|
+
const body = reply.json;
|
|
600
|
+
const list = body?.executions;
|
|
601
|
+
if (!Array.isArray(list) || list.length !== chunk.length)
|
|
602
|
+
return Effect.fail(new DecodeError({
|
|
603
|
+
what: "POST executions/wait",
|
|
604
|
+
message: "expected one record per id",
|
|
605
|
+
}));
|
|
606
|
+
return Effect.forEach(list, (item, i) => {
|
|
607
|
+
const missing = typeof item === "object" &&
|
|
608
|
+
item !== null &&
|
|
609
|
+
item.error !== undefined;
|
|
610
|
+
if (missing) {
|
|
611
|
+
const error = { error: item.error };
|
|
612
|
+
return Effect.succeed([chunk[i], apiError(404, error)]);
|
|
613
|
+
}
|
|
614
|
+
return decode(Job, "execution")(item).pipe(Effect.map((job) => [chunk[i], job]));
|
|
615
|
+
}).pipe(Effect.map((pairs) => ({
|
|
616
|
+
records: new Map(pairs),
|
|
617
|
+
waited: reply.waited && body?.waited === true,
|
|
618
|
+
})));
|
|
126
619
|
};
|
|
127
620
|
const start = (program, input) => request("POST", `programs/${segment(program)}/invoke`, {
|
|
128
621
|
...input,
|
package/dist/errors.d.ts
CHANGED
|
@@ -20,8 +20,16 @@ declare const NetworkError_base: Schema.Class<NetworkError, Schema.TaggedStruct<
|
|
|
20
20
|
readonly url: Schema.String;
|
|
21
21
|
readonly message: Schema.String;
|
|
22
22
|
readonly cause: Schema.Unknown;
|
|
23
|
+
readonly reason: Schema.optional<Schema.String>;
|
|
23
24
|
}>, import("effect/Cause").YieldableError>;
|
|
24
|
-
/**
|
|
25
|
+
/**
|
|
26
|
+
* The service could not be reached (DNS, TLS, connection reset, abort), or
|
|
27
|
+
* it ended a live stream. `reason` is set by the stream:
|
|
28
|
+
* - `functions_disabled`: Functions access was removed from the account;
|
|
29
|
+
* - `channel_closed`: the server closed the stream (subscribe again with
|
|
30
|
+
* `afterSeq`);
|
|
31
|
+
* - the server's reason of a refused join (e.g. `functions_unavailable`).
|
|
32
|
+
*/
|
|
25
33
|
export declare class NetworkError extends NetworkError_base {
|
|
26
34
|
}
|
|
27
35
|
declare const ApiError_base: Schema.Class<ApiError, Schema.TaggedStruct<"ApiError", {
|
package/dist/errors.js
CHANGED
|
@@ -13,7 +13,12 @@ export const Failure = Schema.Struct({
|
|
|
13
13
|
export const messageOf = (cause) => cause instanceof Error ? cause.message : String(cause);
|
|
14
14
|
export class ConfigError extends Schema.TaggedError()("ConfigError", { message: Schema.String }) {
|
|
15
15
|
}
|
|
16
|
-
export class NetworkError extends Schema.TaggedError()("NetworkError", {
|
|
16
|
+
export class NetworkError extends Schema.TaggedError()("NetworkError", {
|
|
17
|
+
url: Schema.String,
|
|
18
|
+
message: Schema.String,
|
|
19
|
+
cause: Schema.Unknown,
|
|
20
|
+
reason: Schema.optional(Schema.String),
|
|
21
|
+
}) {
|
|
17
22
|
}
|
|
18
23
|
export class ApiError extends Schema.TaggedError()("ApiError", {
|
|
19
24
|
status: Schema.Number,
|