reactor-effect-client 0.6.0 → 0.7.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 +15 -3
- package/dist/orchestration/index.d.ts +2 -2
- package/dist/orchestration/index.d.ts.map +1 -1
- package/dist/orchestration/index.js +1 -1
- package/dist/orchestration/index.js.map +1 -1
- package/dist/orchestration/renewal.d.ts.map +1 -1
- package/dist/orchestration/renewal.js +15 -0
- package/dist/orchestration/renewal.js.map +1 -1
- package/dist/orchestration/scheduler-key.d.ts +2 -1
- package/dist/orchestration/scheduler-key.d.ts.map +1 -1
- package/dist/orchestration/scheduler-key.js +2 -2
- package/dist/orchestration/scheduler-key.js.map +1 -1
- package/dist/orchestration/scheduler-policy.d.ts +175 -10
- package/dist/orchestration/scheduler-policy.d.ts.map +1 -1
- package/dist/orchestration/scheduler-policy.js +514 -46
- package/dist/orchestration/scheduler-policy.js.map +1 -1
- package/dist/orchestration/scheduler.d.ts +211 -2
- package/dist/orchestration/scheduler.d.ts.map +1 -1
- package/dist/orchestration/scheduler.js +1127 -186
- package/dist/orchestration/scheduler.js.map +1 -1
- package/dist/orchestration/types.d.ts +20 -0
- package/dist/orchestration/types.d.ts.map +1 -1
- package/dist/orchestration/types.js.map +1 -1
- package/package.json +2 -2
|
@@ -17,10 +17,11 @@ import { take as takeQueue } from "../_internal/queue.js";
|
|
|
17
17
|
import { duration } from "../duration.js";
|
|
18
18
|
import { parsedInput, ReactorError } from "../errors.js";
|
|
19
19
|
import { monotonicMillis } from "./elapsed.js";
|
|
20
|
+
import { requestSeconds as h3RequestSeconds } from "../h3/profile.js";
|
|
20
21
|
import { captureRequest, PolicyFailure } from "./request.js";
|
|
21
22
|
import { activeIds } from "./routing.js";
|
|
22
23
|
import { fillerIndexFromKey, fillerKey, isReservedSchedulerKey, keyedRequest, keyFromProviderMetadata, } from "./scheduler-key.js";
|
|
23
|
-
import { plan, runwaySeconds } from "./scheduler-policy.js";
|
|
24
|
+
import { dueCues, estimatesFrom, continuedBuildRate, plan, projectedStartMs, runwaySeconds, } from "./scheduler-policy.js";
|
|
24
25
|
import { Engine } from "./types.js";
|
|
25
26
|
/** Stable caller identity for a scheduled item. */
|
|
26
27
|
export const ItemKey = Schema.NonEmptyString.pipe(Schema.brand("ItemKey"));
|
|
@@ -37,6 +38,14 @@ export class KeyMismatch extends Schema.TaggedError()("KeyMismatch", {
|
|
|
37
38
|
return new KeyMismatch({ key });
|
|
38
39
|
}
|
|
39
40
|
}
|
|
41
|
+
export class LaneBusy extends Schema.TaggedError()("LaneBusy", {
|
|
42
|
+
key: ItemKey,
|
|
43
|
+
lane: Schema.String,
|
|
44
|
+
}) {
|
|
45
|
+
static of(key, lane) {
|
|
46
|
+
return new LaneBusy({ key, lane });
|
|
47
|
+
}
|
|
48
|
+
}
|
|
40
49
|
export class WouldMissDeadline extends Schema.TaggedError()("WouldMissDeadline", { key: ItemKey }) {
|
|
41
50
|
static of(key) {
|
|
42
51
|
return new WouldMissDeadline({ key });
|
|
@@ -104,33 +113,65 @@ const requestDuration = (input, name, allowZero = false) => Effect.gen(function*
|
|
|
104
113
|
const captured = yield* captureDuration(input);
|
|
105
114
|
return yield* parsedInput(() => Duration.toMillis(duration(captured, name, { allowZero, allowNegative: name === "startBy" })), "submit").pipe(Effect.mapError((error) => invalid(error.message)));
|
|
106
115
|
});
|
|
107
|
-
/**
|
|
108
|
-
const
|
|
109
|
-
if (input ===
|
|
110
|
-
return
|
|
116
|
+
/** A continuity choice, read as caller data, checked against the request it would continue. */
|
|
117
|
+
const captureContinuity = (input, request) => Effect.gen(function* () {
|
|
118
|
+
if (input === undefined)
|
|
119
|
+
return undefined;
|
|
120
|
+
if (input !== "previous")
|
|
121
|
+
return yield* invalid('Continuity is "previous" or absent');
|
|
122
|
+
if ((request.audio?.length ?? 0) > 2)
|
|
123
|
+
return yield* invalid("A clip that continues takes at most two audio references");
|
|
124
|
+
return "previous";
|
|
125
|
+
});
|
|
126
|
+
/** Enough cues for a line's overlays and actions, and a bound on one clip's timers. */
|
|
127
|
+
const maxCues = 32;
|
|
128
|
+
/** A clip's cues, read as caller data. */
|
|
129
|
+
const captureCues = (input) => Effect.gen(function* () {
|
|
130
|
+
if (input === undefined)
|
|
131
|
+
return [];
|
|
132
|
+
if (!Array.isArray(input))
|
|
133
|
+
return yield* invalid("Cues must be an array");
|
|
134
|
+
// Read as a plain object: a mapped array type would hide the descriptors.
|
|
111
135
|
const descriptors = Object.getOwnPropertyDescriptors(input);
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
136
|
+
const length = descriptors.length?.value;
|
|
137
|
+
if (typeof length !== "number" || length > maxCues)
|
|
138
|
+
return yield* invalid(`A clip takes at most ${maxCues} cues`);
|
|
139
|
+
const cues = [];
|
|
140
|
+
for (let index = 0; index < length; index++) {
|
|
141
|
+
const descriptor = descriptors[String(index)];
|
|
142
|
+
if (descriptor === undefined || !("value" in descriptor))
|
|
143
|
+
return yield* invalid("Cues must be data");
|
|
144
|
+
const cue = yield* Effect.fromResult(ownedData(descriptor.value, ["name", "at"], "Cue"));
|
|
145
|
+
const at = yield* Effect.fromResult(ownedData(cue.at, ["from", "offset"], "Cue time"));
|
|
146
|
+
if (typeof cue.name !== "string" || cue.name.length === 0)
|
|
147
|
+
return yield* invalid("A cue needs a nonempty name");
|
|
148
|
+
if (at.from !== "start" && at.from !== "end")
|
|
149
|
+
return yield* invalid("A cue is timed from the start or the end");
|
|
150
|
+
cues.push({
|
|
151
|
+
name: cue.name,
|
|
152
|
+
from: at.from,
|
|
153
|
+
offsetMs: yield* requestDuration(at.offset, "cue offset", true),
|
|
154
|
+
});
|
|
155
|
+
}
|
|
156
|
+
return cues;
|
|
157
|
+
});
|
|
158
|
+
/** Capture scheduling fields before reading the request's placement fields. */
|
|
159
|
+
/** A caller's clip request, without the placement and source fields the scheduler owns. */
|
|
160
|
+
const captureClip = (input) => Effect.gen(function* () {
|
|
161
|
+
const request = yield* captureRequest(input);
|
|
124
162
|
if (request.position !== undefined ||
|
|
125
163
|
request.before !== undefined ||
|
|
126
164
|
request.sameSessionAs !== undefined ||
|
|
127
165
|
request.continueFrom !== undefined ||
|
|
128
166
|
request.sequence !== undefined)
|
|
129
167
|
return yield* invalid("Scheduler owns placement and source selection");
|
|
130
|
-
|
|
131
|
-
|
|
168
|
+
return request;
|
|
169
|
+
});
|
|
170
|
+
/** A window's offsets from admission, read as caller data. */
|
|
171
|
+
const captureWindow = (input) => Effect.gen(function* () {
|
|
172
|
+
const window = input === undefined
|
|
132
173
|
? undefined
|
|
133
|
-
: yield* Effect.fromResult(ownedData(
|
|
174
|
+
: yield* Effect.fromResult(ownedData(input, ["notBefore", "startBy", "firm"], "Scheduled window"));
|
|
134
175
|
const notBeforeOffsetMs = window?.notBefore === undefined
|
|
135
176
|
? undefined
|
|
136
177
|
: yield* requestDuration(window.notBefore, "notBefore", true);
|
|
@@ -144,15 +185,37 @@ const captureItem = (input, lanes) => Effect.gen(function* () {
|
|
|
144
185
|
startByOffsetMs >= 0 &&
|
|
145
186
|
notBeforeOffsetMs > startByOffsetMs)))
|
|
146
187
|
return yield* invalid("Scheduled window is inconsistent");
|
|
188
|
+
return { notBeforeOffsetMs, startByOffsetMs, firm: window?.firm === true };
|
|
189
|
+
});
|
|
190
|
+
const captureItem = (input, lanes) => Effect.gen(function* () {
|
|
191
|
+
if (input === null || typeof input !== "object" || Array.isArray(input))
|
|
192
|
+
return yield* invalid("Scheduled item must be an object");
|
|
193
|
+
const descriptors = Object.getOwnPropertyDescriptors(input);
|
|
194
|
+
if (Object.getOwnPropertySymbols(input).length > 0 ||
|
|
195
|
+
Object.keys(descriptors).some((field) => !["key", "lane", "request", "window", "start", "cues", "continuity"].includes(field) ||
|
|
196
|
+
!("value" in descriptors[field])))
|
|
197
|
+
return yield* invalid("Scheduled item has unsupported fields or accessors");
|
|
198
|
+
const key = yield* Schema.decodeUnknownEffect(ItemKey)(descriptors.key?.value).pipe(Effect.mapError(() => invalid("Scheduled item key must be nonempty")));
|
|
199
|
+
if (isReservedSchedulerKey(key))
|
|
200
|
+
return yield* invalid("Scheduled item key is reserved");
|
|
201
|
+
const lane = descriptors.lane?.value;
|
|
202
|
+
if (typeof lane !== "string" || !lanes.has(lane))
|
|
203
|
+
return yield* invalid("Scheduled item lane is not configured");
|
|
204
|
+
const request = yield* captureClip(descriptors.request?.value);
|
|
205
|
+
const { notBeforeOffsetMs, startByOffsetMs, firm } = yield* captureWindow(descriptors.window?.value);
|
|
206
|
+
const cues = yield* captureCues(descriptors.cues?.value);
|
|
207
|
+
const continuity = yield* captureContinuity(descriptors.continuity?.value, request);
|
|
147
208
|
const rawStart = descriptors.start?.value;
|
|
148
209
|
const start = rawStart === undefined
|
|
149
210
|
? undefined
|
|
150
211
|
: yield* Effect.fromResult(ownedData(rawStart, ["_tag", "time", "late"], "Scheduled start mode"));
|
|
151
212
|
let atWallMs;
|
|
152
213
|
let late = "nextBoundary";
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
214
|
+
const mode = start?._tag === "Asap" ? "asap" : start?._tag === "Manual" ? "manual" : undefined;
|
|
215
|
+
if ((start?._tag === "Follow" || mode !== undefined) &&
|
|
216
|
+
(start?.time !== undefined || start?.late !== undefined))
|
|
217
|
+
return yield* invalid(`${String(start?._tag)} cannot contain an anchor or lateness policy`);
|
|
218
|
+
if (start !== undefined && start._tag !== "Follow" && mode === undefined) {
|
|
156
219
|
if (start._tag !== "At" || !Number.isFinite(start.time))
|
|
157
220
|
return yield* invalid("Scheduled start mode is invalid");
|
|
158
221
|
atWallMs = start.time;
|
|
@@ -172,20 +235,166 @@ const captureItem = (input, lanes) => Effect.gen(function* () {
|
|
|
172
235
|
request,
|
|
173
236
|
notBeforeOffsetMs,
|
|
174
237
|
startByOffsetMs,
|
|
175
|
-
firm
|
|
238
|
+
firm,
|
|
176
239
|
atWallMs,
|
|
177
240
|
late,
|
|
241
|
+
...(mode === undefined ? {} : { mode }),
|
|
242
|
+
...(cues.length === 0 ? {} : { cues }),
|
|
243
|
+
...(continuity === undefined ? {} : { continuity }),
|
|
178
244
|
fingerprint: JSON.stringify({
|
|
179
245
|
lane,
|
|
180
246
|
request,
|
|
181
247
|
notBeforeOffsetMs,
|
|
182
248
|
startByOffsetMs,
|
|
183
|
-
firm
|
|
249
|
+
firm,
|
|
184
250
|
atWallMs,
|
|
185
251
|
late,
|
|
252
|
+
mode,
|
|
253
|
+
...(cues.length === 0 ? {} : { cues }),
|
|
254
|
+
...(continuity === undefined ? {} : { continuity }),
|
|
186
255
|
}),
|
|
187
256
|
};
|
|
188
257
|
});
|
|
258
|
+
/** Enough parts for a spoken line of beats, and a bound on one submission's work. */
|
|
259
|
+
const maxGroupParts = 64;
|
|
260
|
+
const captureGroup = (input, lanes) => Effect.gen(function* () {
|
|
261
|
+
const group = yield* Effect.fromResult(ownedData(input, ["key", "lane", "parts", "window"], "Scheduled group"));
|
|
262
|
+
const key = yield* Schema.decodeUnknownEffect(ItemKey)(group.key).pipe(Effect.mapError(() => invalid("Scheduled group key must be nonempty")));
|
|
263
|
+
if (isReservedSchedulerKey(key))
|
|
264
|
+
return yield* invalid("Scheduled group key is reserved");
|
|
265
|
+
if (!Array.isArray(group.parts))
|
|
266
|
+
return yield* invalid("Scheduled group parts must be an array");
|
|
267
|
+
// Read as a plain object: a mapped array type would hide the descriptors.
|
|
268
|
+
const descriptors = Object.getOwnPropertyDescriptors(group.parts);
|
|
269
|
+
const length = descriptors.length?.value;
|
|
270
|
+
if (typeof length !== "number" || length < 1 || length > maxGroupParts)
|
|
271
|
+
return yield* invalid(`Scheduled group needs 1 to ${maxGroupParts} parts`);
|
|
272
|
+
const parts = [];
|
|
273
|
+
for (let index = 0; index < length; index++) {
|
|
274
|
+
const descriptor = descriptors[String(index)];
|
|
275
|
+
if (descriptor === undefined || !("value" in descriptor))
|
|
276
|
+
return yield* invalid("Scheduled group parts must be data");
|
|
277
|
+
const part = yield* Effect.fromResult(ownedData(descriptor.value, ["key", "request", "cues", "continuity"], "Scheduled group part"));
|
|
278
|
+
parts.push(yield* captureItem({
|
|
279
|
+
key: part.key,
|
|
280
|
+
lane: group.lane,
|
|
281
|
+
request: part.request,
|
|
282
|
+
...(part.cues === undefined ? {} : { cues: part.cues }),
|
|
283
|
+
...(part.continuity === undefined ? {} : { continuity: part.continuity }),
|
|
284
|
+
...(index === 0 && group.window !== undefined ? { window: group.window } : {}),
|
|
285
|
+
}, lanes));
|
|
286
|
+
}
|
|
287
|
+
const [first, ...rest] = parts;
|
|
288
|
+
if (first === undefined || new Set([key, ...parts.map((part) => part.key)]).size !== length + 1)
|
|
289
|
+
return yield* invalid("Scheduled group and part keys must be distinct");
|
|
290
|
+
return {
|
|
291
|
+
key,
|
|
292
|
+
parts: [first, ...rest],
|
|
293
|
+
fingerprint: JSON.stringify(parts.map((part) => [part.key, part.fingerprint])),
|
|
294
|
+
};
|
|
295
|
+
});
|
|
296
|
+
const captureReplacement = (input) => Effect.gen(function* () {
|
|
297
|
+
const next = yield* Effect.fromResult(ownedData(input, ["key", "request", "cues", "continuity"], "Replacement"));
|
|
298
|
+
const key = yield* Schema.decodeUnknownEffect(ItemKey)(next.key).pipe(Effect.mapError(() => invalid("Replacement key must be nonempty")));
|
|
299
|
+
if (isReservedSchedulerKey(key))
|
|
300
|
+
return yield* invalid("Replacement key is reserved");
|
|
301
|
+
const request = yield* captureClip(next.request);
|
|
302
|
+
return {
|
|
303
|
+
key,
|
|
304
|
+
request,
|
|
305
|
+
cues: yield* captureCues(next.cues),
|
|
306
|
+
continuity: yield* captureContinuity(next.continuity, request),
|
|
307
|
+
};
|
|
308
|
+
});
|
|
309
|
+
const captureInsert = (input) => Effect.gen(function* () {
|
|
310
|
+
const spec = yield* Effect.fromResult(ownedData(input, ["key", "request", "before", "after", "window", "cues", "continuity"], "Insert"));
|
|
311
|
+
const key = yield* Schema.decodeUnknownEffect(ItemKey)(spec.key).pipe(Effect.mapError(() => invalid("Insert key must be nonempty")));
|
|
312
|
+
if (isReservedSchedulerKey(key))
|
|
313
|
+
return yield* invalid("Insert key is reserved");
|
|
314
|
+
if ((spec.before === undefined) === (spec.after === undefined))
|
|
315
|
+
return yield* invalid("Insert needs exactly one of before or after");
|
|
316
|
+
const side = spec.before === undefined ? "after" : "before";
|
|
317
|
+
const anchor = yield* Schema.decodeUnknownEffect(ItemKey)(spec.before ?? spec.after).pipe(Effect.mapError(() => invalid("Insert anchor must be a nonempty key")));
|
|
318
|
+
const request = yield* captureClip(spec.request);
|
|
319
|
+
const window = yield* captureWindow(spec.window);
|
|
320
|
+
const cues = yield* captureCues(spec.cues);
|
|
321
|
+
const continuity = yield* captureContinuity(spec.continuity, request);
|
|
322
|
+
return {
|
|
323
|
+
key,
|
|
324
|
+
request,
|
|
325
|
+
side,
|
|
326
|
+
anchor,
|
|
327
|
+
...window,
|
|
328
|
+
cues,
|
|
329
|
+
continuity,
|
|
330
|
+
fingerprint: JSON.stringify({
|
|
331
|
+
[side]: anchor,
|
|
332
|
+
request,
|
|
333
|
+
...window,
|
|
334
|
+
...(cues.length === 0 ? {} : { cues }),
|
|
335
|
+
...(continuity === undefined ? {} : { continuity }),
|
|
336
|
+
}),
|
|
337
|
+
};
|
|
338
|
+
});
|
|
339
|
+
/** A bound on one batch's work, generous for a spoken plan of several lines. */
|
|
340
|
+
const maxEdits = 256;
|
|
341
|
+
const editKey = (input, name) => Schema.decodeUnknownEffect(ItemKey)(input).pipe(Effect.mapError(() => invalid(`${name} must be a nonempty key`)));
|
|
342
|
+
const captureEdits = (input, lanes) => Effect.gen(function* () {
|
|
343
|
+
if (!Array.isArray(input))
|
|
344
|
+
return yield* invalid("Edits must be an array");
|
|
345
|
+
// Read as a plain object: a mapped array type would hide the descriptors.
|
|
346
|
+
const descriptors = Object.getOwnPropertyDescriptors(input);
|
|
347
|
+
const length = descriptors.length?.value;
|
|
348
|
+
if (typeof length !== "number" || length < 1 || length > maxEdits)
|
|
349
|
+
return yield* invalid(`An edit batch needs 1 to ${maxEdits} edits`);
|
|
350
|
+
const edits = [];
|
|
351
|
+
for (let index = 0; index < length; index++) {
|
|
352
|
+
const descriptor = descriptors[String(index)];
|
|
353
|
+
if (descriptor === undefined || !("value" in descriptor))
|
|
354
|
+
return yield* invalid("Edits must be data");
|
|
355
|
+
const raw = descriptor.value;
|
|
356
|
+
const tag = raw !== null && typeof raw === "object"
|
|
357
|
+
? Object.getOwnPropertyDescriptor(raw, "_tag")?.value
|
|
358
|
+
: undefined;
|
|
359
|
+
switch (tag) {
|
|
360
|
+
case "Submit": {
|
|
361
|
+
const edit = yield* Effect.fromResult(ownedData(raw, ["_tag", "item"], "Edit"));
|
|
362
|
+
edits.push({ _tag: "Submit", item: yield* captureItem(edit.item, lanes) });
|
|
363
|
+
break;
|
|
364
|
+
}
|
|
365
|
+
case "SubmitGroup": {
|
|
366
|
+
const edit = yield* Effect.fromResult(ownedData(raw, ["_tag", "group"], "Edit"));
|
|
367
|
+
edits.push({
|
|
368
|
+
_tag: "SubmitGroup",
|
|
369
|
+
group: yield* captureGroup(edit.group, lanes),
|
|
370
|
+
});
|
|
371
|
+
break;
|
|
372
|
+
}
|
|
373
|
+
case "Insert": {
|
|
374
|
+
const edit = yield* Effect.fromResult(ownedData(raw, ["_tag", "insert"], "Edit"));
|
|
375
|
+
edits.push({ _tag: "Insert", insert: yield* captureInsert(edit.insert) });
|
|
376
|
+
break;
|
|
377
|
+
}
|
|
378
|
+
case "Replace": {
|
|
379
|
+
const edit = yield* Effect.fromResult(ownedData(raw, ["_tag", "key", "next"], "Edit"));
|
|
380
|
+
edits.push({
|
|
381
|
+
_tag: "Replace",
|
|
382
|
+
key: yield* editKey(edit.key, "A replaced key"),
|
|
383
|
+
next: yield* captureReplacement(edit.next),
|
|
384
|
+
});
|
|
385
|
+
break;
|
|
386
|
+
}
|
|
387
|
+
case "Withdraw": {
|
|
388
|
+
const edit = yield* Effect.fromResult(ownedData(raw, ["_tag", "key"], "Edit"));
|
|
389
|
+
edits.push({ _tag: "Withdraw", key: yield* editKey(edit.key, "A withdrawn key") });
|
|
390
|
+
break;
|
|
391
|
+
}
|
|
392
|
+
default:
|
|
393
|
+
return yield* invalid("An edit's _tag is not one the scheduler knows");
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
return edits;
|
|
397
|
+
});
|
|
189
398
|
export const lineup = (filler) => ({
|
|
190
399
|
lanes: [{ name: "line" }],
|
|
191
400
|
filler,
|
|
@@ -201,9 +410,15 @@ export const makeScheduler = (options) => Effect.gen(function* () {
|
|
|
201
410
|
options.lanes.some((lane) => typeof lane.name !== "string" || lane.name.length === 0) ||
|
|
202
411
|
new Set(options.lanes.map((lane) => lane.name)).size !== options.lanes.length)
|
|
203
412
|
throw ReactorError.fromCode("InvalidInput", "Scheduler lanes must be unique and nonempty");
|
|
413
|
+
if (options.lanes.some((lane) => (lane.conflict !== undefined &&
|
|
414
|
+
!["queue", "replace", "skip"].includes(lane.conflict)) ||
|
|
415
|
+
(lane.cut !== undefined && typeof lane.cut !== "boolean")))
|
|
416
|
+
throw ReactorError.fromCode("InvalidInput", "A lane's conflict or cut is invalid");
|
|
204
417
|
return options.lanes.map((lane) => lane.name);
|
|
205
418
|
}, "makeScheduler");
|
|
206
|
-
const
|
|
419
|
+
const conflicts = new Map(options.lanes.map((lane) => [lane.name, lane.conflict ?? "queue"]));
|
|
420
|
+
const cutLanes = new Set(options.lanes.filter((lane) => lane.cut === true).map((lane) => lane.name));
|
|
421
|
+
const { floorSeconds, targetSeconds, maxBuildsInFlight, maxHistory, unknownRecoveryMs, fillLengths, } = yield* parsedInput(() => {
|
|
207
422
|
const floorSeconds = Duration.toMillis(duration(options.filler.runway.floor, "runway floor", { allowZero: true })) / 1000;
|
|
208
423
|
const targetSeconds = Duration.toMillis(duration(options.filler.runway.target, "runway target")) / 1000;
|
|
209
424
|
const maxBuildsInFlight = options.maxBuildsInFlight ?? 1;
|
|
@@ -218,12 +433,31 @@ export const makeScheduler = (options) => Effect.gen(function* () {
|
|
|
218
433
|
maxHistory < 0 ||
|
|
219
434
|
maxHistory > 65_536)
|
|
220
435
|
throw ReactorError.fromCode("InvalidInput", "Scheduler runway or build cap is invalid");
|
|
221
|
-
|
|
436
|
+
const lengths = options.filler.lengths;
|
|
437
|
+
const fillLengths = {
|
|
438
|
+
min: lengths === undefined
|
|
439
|
+
? h3RequestSeconds.min
|
|
440
|
+
: Duration.toMillis(duration(lengths.min, "filler minimum length")) / 1000,
|
|
441
|
+
max: lengths === undefined
|
|
442
|
+
? h3RequestSeconds.max
|
|
443
|
+
: Duration.toMillis(duration(lengths.max, "filler maximum length")) / 1000,
|
|
444
|
+
};
|
|
445
|
+
if (fillLengths.min > fillLengths.max)
|
|
446
|
+
throw ReactorError.fromCode("InvalidInput", "Filler lengths are inconsistent");
|
|
447
|
+
return {
|
|
448
|
+
floorSeconds,
|
|
449
|
+
targetSeconds,
|
|
450
|
+
maxBuildsInFlight,
|
|
451
|
+
maxHistory,
|
|
452
|
+
unknownRecoveryMs,
|
|
453
|
+
fillLengths,
|
|
454
|
+
};
|
|
222
455
|
}, "makeScheduler");
|
|
223
|
-
const captureFiller = (index, runway, targetSeconds) => Effect.gen(function* () {
|
|
456
|
+
const captureFiller = (index, runway, durationSeconds, targetSeconds) => Effect.gen(function* () {
|
|
224
457
|
const request = yield* captureRequest(options.filler.clip({
|
|
225
458
|
index,
|
|
226
459
|
runwaySeconds: runway,
|
|
460
|
+
durationSeconds,
|
|
227
461
|
...(targetSeconds === undefined ? {} : { targetSeconds }),
|
|
228
462
|
}));
|
|
229
463
|
if (request.position !== undefined ||
|
|
@@ -238,14 +472,17 @@ export const makeScheduler = (options) => Effect.gen(function* () {
|
|
|
238
472
|
let fillerRetryAtMs = 0;
|
|
239
473
|
const unknownFillers = new Map();
|
|
240
474
|
const maxUnknownFillers = 4096;
|
|
241
|
-
let upcomingFiller = yield* captureFiller(0, 0);
|
|
475
|
+
let upcomingFiller = yield* captureFiller(0, 0, fillLengths.min);
|
|
476
|
+
const actorScope = yield* Effect.scope;
|
|
242
477
|
const inbox = yield* Queue.unbounded();
|
|
243
478
|
const commandQueue = yield* Queue.unbounded();
|
|
244
479
|
const events = yield* PubSub.unbounded();
|
|
480
|
+
const cueEvents = yield* PubSub.unbounded();
|
|
245
481
|
yield* Effect.addFinalizer(() => Effect.gen(function* () {
|
|
246
482
|
yield* Queue.shutdown(commandQueue);
|
|
247
483
|
yield* Queue.shutdown(inbox);
|
|
248
484
|
yield* PubSub.shutdown(events);
|
|
485
|
+
yield* PubSub.shutdown(cueEvents);
|
|
249
486
|
}));
|
|
250
487
|
const stateRef = yield* SubscriptionRef.make({
|
|
251
488
|
accepting: true,
|
|
@@ -254,6 +491,7 @@ export const makeScheduler = (options) => Effect.gen(function* () {
|
|
|
254
491
|
lanes: lanes.map((name) => ({ name, keys: [] })),
|
|
255
492
|
sessions: [],
|
|
256
493
|
starved: 0,
|
|
494
|
+
estimates: { build: undefined, length: 1 },
|
|
257
495
|
});
|
|
258
496
|
const initialized = yield* Deferred.make();
|
|
259
497
|
const stopped = yield* Deferred.make();
|
|
@@ -261,6 +499,8 @@ export const makeScheduler = (options) => Effect.gen(function* () {
|
|
|
261
499
|
const closedCall = Deferred.await(stopped).pipe(Effect.flatMap((cause) => PolicyFailure.refuse("SessionClosed", cause.message)));
|
|
262
500
|
const items = new Map();
|
|
263
501
|
const history = new Map();
|
|
502
|
+
/** Kept while any part is active or in history, for idempotency and withdrawal. */
|
|
503
|
+
const groups = new Map();
|
|
264
504
|
const owned = new Map();
|
|
265
505
|
// An event can overtake its enqueue reply. Keep only the evidence needed to
|
|
266
506
|
// decide that clip's fate until the one in-flight command returns its ID.
|
|
@@ -276,6 +516,9 @@ export const makeScheduler = (options) => Effect.gen(function* () {
|
|
|
276
516
|
let drainFailure;
|
|
277
517
|
const drainReplies = [];
|
|
278
518
|
const pendingWithdrawals = new Map();
|
|
519
|
+
/** Edit batches that have not taken effect yet. */
|
|
520
|
+
const batches = new Map();
|
|
521
|
+
let batchCount = 0;
|
|
279
522
|
const pendingBuilds = new Set();
|
|
280
523
|
// A Build whose enqueue has started and whose result the worker has not
|
|
281
524
|
// yet seen. The provider may still accept it after the scheduler ends.
|
|
@@ -294,11 +537,58 @@ export const makeScheduler = (options) => Effect.gen(function* () {
|
|
|
294
537
|
let drained = false;
|
|
295
538
|
let observationReady = false;
|
|
296
539
|
let blockedMove;
|
|
540
|
+
let blockedCut;
|
|
541
|
+
// Recent measured builds: build seconds per requested second, and actual over requested length.
|
|
542
|
+
const buildSamples = [];
|
|
543
|
+
const continuedSamples = [];
|
|
544
|
+
const lengthSamples = [];
|
|
545
|
+
const maxSamples = 32;
|
|
546
|
+
let lastFillerSeconds;
|
|
547
|
+
/** Filler builds sent and not yet Ready, by filler index. */
|
|
548
|
+
const fillerDispatch = new Map();
|
|
549
|
+
const measure = (atMs, requested, actual, continued = false) => {
|
|
550
|
+
if (!(requested > 0))
|
|
551
|
+
return;
|
|
552
|
+
const samples = continued ? continuedSamples : buildSamples;
|
|
553
|
+
samples.push((monotonicMillis(clock) - atMs) / 1000 / requested);
|
|
554
|
+
lengthSamples.push(actual / requested);
|
|
555
|
+
if (samples.length > maxSamples)
|
|
556
|
+
samples.shift();
|
|
557
|
+
if (lengthSamples.length > maxSamples)
|
|
558
|
+
lengthSamples.shift();
|
|
559
|
+
};
|
|
560
|
+
/** An item's build is measured once, when its clip is first seen Ready. */
|
|
561
|
+
const measureItem = (item, actual) => {
|
|
562
|
+
if (item.dispatchedAtMs === undefined)
|
|
563
|
+
return;
|
|
564
|
+
measure(item.dispatchedAtMs, item.request.durationSeconds, actual, item.continued === true);
|
|
565
|
+
delete item.dispatchedAtMs;
|
|
566
|
+
};
|
|
567
|
+
/** Fires the cues of a playing item that are due by `untilMs`, in the order they fall. */
|
|
568
|
+
const fireCues = (entry, untilMs) => {
|
|
569
|
+
if (entry.status._tag !== "Started" || entry.startedAtMonoMs === undefined)
|
|
570
|
+
return;
|
|
571
|
+
for (const due of dueCues(entry.cues, entry.firedCues, entry.startedAtMonoMs, entry.status.durationSeconds, untilMs)) {
|
|
572
|
+
entry.firedCues.add(due);
|
|
573
|
+
PubSub.publishUnsafe(cueEvents, {
|
|
574
|
+
key: entry.key,
|
|
575
|
+
name: entry.cues[due].name,
|
|
576
|
+
at: clock.currentTimeMillisUnsafe(),
|
|
577
|
+
});
|
|
578
|
+
}
|
|
579
|
+
};
|
|
297
580
|
const emit = (entry, status, terminal = false) => Effect.gen(function* () {
|
|
298
581
|
if (ended !== undefined && !terminal)
|
|
299
582
|
return;
|
|
300
583
|
if (JSON.stringify(entry.status) === JSON.stringify(status))
|
|
301
584
|
return;
|
|
585
|
+
// A clip that ends fires only the cues that fell within the airtime it had.
|
|
586
|
+
if (status._tag === "Ended" && entry.startedAtMonoMs !== undefined)
|
|
587
|
+
fireCues(entry, entry.startedAtMonoMs +
|
|
588
|
+
1000 *
|
|
589
|
+
(status.termination === "finished" && entry.status._tag === "Started"
|
|
590
|
+
? Math.max(status.airedSeconds, entry.status.durationSeconds)
|
|
591
|
+
: status.airedSeconds));
|
|
302
592
|
entry.status = status;
|
|
303
593
|
entry.phase =
|
|
304
594
|
status._tag === "Accepted" ||
|
|
@@ -309,6 +599,16 @@ export const makeScheduler = (options) => Effect.gen(function* () {
|
|
|
309
599
|
: status._tag === "Unknown" && status.terminal !== true
|
|
310
600
|
? "Unknown"
|
|
311
601
|
: "Terminal";
|
|
602
|
+
const replaced = entry.replaces === undefined ? undefined : items.get(entry.replaces);
|
|
603
|
+
if (entry.group !== undefined &&
|
|
604
|
+
entry.inserted !== true &&
|
|
605
|
+
(status._tag === "Failed" || status._tag === "Dropped") &&
|
|
606
|
+
!(status._tag === "Dropped" && status.reason === "replaced") &&
|
|
607
|
+
(replaced === undefined || replaced.phase === "Terminal")) {
|
|
608
|
+
const group = groups.get(entry.group.key);
|
|
609
|
+
if (group !== undefined)
|
|
610
|
+
group.brokenAt = Math.min(group.brokenAt ?? entry.group.index, entry.group.index);
|
|
611
|
+
}
|
|
312
612
|
// Publish in the step that records it (the PubSub is unbounded): a claim
|
|
313
613
|
// from another fiber cannot land between the two, so asRun never carries
|
|
314
614
|
// a settlement ahead of a recorded status, nor omits one.
|
|
@@ -380,6 +680,13 @@ export const makeScheduler = (options) => Effect.gen(function* () {
|
|
|
380
680
|
yield* Deferred.done(waiter, reply);
|
|
381
681
|
}
|
|
382
682
|
pendingWithdrawals.clear();
|
|
683
|
+
for (const batch of batches.values()) {
|
|
684
|
+
for (const target of batch.targets.values())
|
|
685
|
+
if (target.reply !== undefined)
|
|
686
|
+
yield* Deferred.done(target.reply, closed);
|
|
687
|
+
yield* Deferred.done(batch.committed, closed);
|
|
688
|
+
}
|
|
689
|
+
batches.clear();
|
|
383
690
|
for (const reply of drainReplies.splice(0))
|
|
384
691
|
yield* Deferred.done(reply, closed);
|
|
385
692
|
yield* SubscriptionRef.update(stateRef, (state) => ({ ...state, accepting: false }));
|
|
@@ -509,6 +816,10 @@ export const makeScheduler = (options) => Effect.gen(function* () {
|
|
|
509
816
|
pendingWithdrawals.has(key) ||
|
|
510
817
|
pendingAtDeferrals.has(key) ||
|
|
511
818
|
pendingItemRemovals.has(key) ||
|
|
819
|
+
[...batches.values()].some((batch) => batch.targets.has(key)) ||
|
|
820
|
+
// A replacement dropped before it was built still links its own replacement to
|
|
821
|
+
// the item in the place.
|
|
822
|
+
[...items.values()].some((other) => other.replaces === key && other.phase !== "Terminal") ||
|
|
512
823
|
(item.clipId !== undefined && active.has(item.clipId)))
|
|
513
824
|
continue;
|
|
514
825
|
if (item.clipId !== undefined) {
|
|
@@ -520,6 +831,9 @@ export const makeScheduler = (options) => Effect.gen(function* () {
|
|
|
520
831
|
}
|
|
521
832
|
while (history.size > maxHistory)
|
|
522
833
|
history.delete(history.keys().next().value);
|
|
834
|
+
for (const [key, group] of groups)
|
|
835
|
+
if (!group.parts.some((part) => items.has(part) || history.has(part)))
|
|
836
|
+
groups.delete(key);
|
|
523
837
|
};
|
|
524
838
|
// Capture membership and public rows in one pass. A fresh token reads every
|
|
525
839
|
// source on every observation, including in-place changes to Engine arrays;
|
|
@@ -582,8 +896,16 @@ export const makeScheduler = (options) => Effect.gen(function* () {
|
|
|
582
896
|
})),
|
|
583
897
|
sessions,
|
|
584
898
|
starved,
|
|
899
|
+
estimates: estimatesFrom(buildSamples, lengthSamples, continuedSamples),
|
|
585
900
|
});
|
|
586
901
|
};
|
|
902
|
+
/** When the filler build in flight was sent, and the next filler clip's requested length. */
|
|
903
|
+
const fillerView = () => ({
|
|
904
|
+
fillerDispatchedAtMs: fillerDispatch.size === 0
|
|
905
|
+
? undefined
|
|
906
|
+
: Math.min(...[...fillerDispatch.values()].map((sent) => sent.atMs)),
|
|
907
|
+
fillerSeconds: upcomingFiller?.durationSeconds ?? lastFillerSeconds,
|
|
908
|
+
});
|
|
587
909
|
const activeRecords = (state) => [
|
|
588
910
|
...state.queued,
|
|
589
911
|
...state.ready,
|
|
@@ -708,6 +1030,7 @@ export const makeScheduler = (options) => Effect.gen(function* () {
|
|
|
708
1030
|
}
|
|
709
1031
|
else if (state.ready.some((record) => record.clipId === clipId)) {
|
|
710
1032
|
const record = knownRecord(state, clipId);
|
|
1033
|
+
measureItem(item, record.durationSeconds);
|
|
711
1034
|
item.sessionId = record.sessionId;
|
|
712
1035
|
if (item.phase !== "Started")
|
|
713
1036
|
yield* emit(item, { _tag: "Ready", sessionId: record.sessionId });
|
|
@@ -771,6 +1094,11 @@ export const makeScheduler = (options) => Effect.gen(function* () {
|
|
|
771
1094
|
if (owner === undefined)
|
|
772
1095
|
return;
|
|
773
1096
|
if (owner._tag === "Filler") {
|
|
1097
|
+
const sent = fillerDispatch.get(owner.index);
|
|
1098
|
+
if (event._tag === "Ready" && sent !== undefined)
|
|
1099
|
+
measure(sent.atMs, sent.seconds, event.durationSeconds);
|
|
1100
|
+
if (event._tag === "Ready" || event._tag === "Ended" || event._tag === "Failed")
|
|
1101
|
+
fillerDispatch.delete(owner.index);
|
|
774
1102
|
if (event._tag === "Started")
|
|
775
1103
|
playingStartedMs.set(event.clipId, event.atMonotonicMillis ?? monotonicMillis(clock));
|
|
776
1104
|
if (event._tag === "Ended" || event._tag === "Failed") {
|
|
@@ -791,6 +1119,8 @@ export const makeScheduler = (options) => Effect.gen(function* () {
|
|
|
791
1119
|
yield* emit(item, { _tag: "Building" });
|
|
792
1120
|
break;
|
|
793
1121
|
case "Ready": {
|
|
1122
|
+
measureItem(item, event.durationSeconds);
|
|
1123
|
+
delete item.lostUnbuilt;
|
|
794
1124
|
const record = knownRecord(yield* engine.state, event.clipId);
|
|
795
1125
|
if (record !== undefined) {
|
|
796
1126
|
item.sessionId = record.sessionId;
|
|
@@ -842,9 +1172,47 @@ export const makeScheduler = (options) => Effect.gen(function* () {
|
|
|
842
1172
|
});
|
|
843
1173
|
playingStartedMs.delete(event.clipId);
|
|
844
1174
|
break;
|
|
845
|
-
case "Failed":
|
|
1175
|
+
case "Failed": {
|
|
1176
|
+
// A clip lost with a replaced session before it aired is rebuilt from the plan.
|
|
1177
|
+
if (event.lost === true &&
|
|
1178
|
+
(item.phase === "Accepted" || item.phase === "Building" || item.phase === "Ready")) {
|
|
1179
|
+
// A moderated prompt ends its session, possibly after it was accepted, and would
|
|
1180
|
+
// end every session it is rebuilt on. So a clip lost before it was built with two
|
|
1181
|
+
// sessions in a row is not rebuilt again. A clip that was built passed screening.
|
|
1182
|
+
const unbuilt = item.phase !== "Ready";
|
|
1183
|
+
if (unbuilt)
|
|
1184
|
+
item.lostUnbuilt = (item.lostUnbuilt ?? 0) + 1;
|
|
1185
|
+
const sessionId = event.sessionId ?? item.sessionId;
|
|
1186
|
+
// Its session may still list the clip until it closes; it is not adopted again.
|
|
1187
|
+
removedIds.add(event.clipId);
|
|
1188
|
+
owned.delete(event.clipId);
|
|
1189
|
+
playingStartedMs.delete(event.clipId);
|
|
1190
|
+
delete item.clipId;
|
|
1191
|
+
delete item.dispatchedAtMs;
|
|
1192
|
+
delete item.acknowledgedAtSnapshotSerial;
|
|
1193
|
+
item.sessionId = undefined;
|
|
1194
|
+
const pending = pendingWithdrawals.get(item.key);
|
|
1195
|
+
if (pending !== undefined) {
|
|
1196
|
+
yield* emit(item, { _tag: "Dropped", reason: pending.reason });
|
|
1197
|
+
yield* finishWithdrawal(item.key, Result.succeed("withdrawn"));
|
|
1198
|
+
}
|
|
1199
|
+
else if (unbuilt && (item.lostUnbuilt ?? 0) >= 2)
|
|
1200
|
+
yield* emit(item, {
|
|
1201
|
+
_tag: "Failed",
|
|
1202
|
+
reason: {
|
|
1203
|
+
_tag: "Clip",
|
|
1204
|
+
message: "Its clip was lost with two sessions in a row before it was built",
|
|
1205
|
+
},
|
|
1206
|
+
});
|
|
1207
|
+
else
|
|
1208
|
+
yield* emit(item, sessionId === undefined
|
|
1209
|
+
? { _tag: "Accepted" }
|
|
1210
|
+
: { _tag: "Accepted", carried: { sessionId } });
|
|
1211
|
+
break;
|
|
1212
|
+
}
|
|
846
1213
|
yield* emit(item, { _tag: "Failed", reason: { _tag: "Clip", message: event.reason } });
|
|
847
1214
|
break;
|
|
1215
|
+
}
|
|
848
1216
|
default:
|
|
849
1217
|
break;
|
|
850
1218
|
}
|
|
@@ -884,6 +1252,8 @@ export const makeScheduler = (options) => Effect.gen(function* () {
|
|
|
884
1252
|
return engine.move(command.clipId, command.position, "playout");
|
|
885
1253
|
case "PauseAutoplay":
|
|
886
1254
|
return engine.setAutoplay(false);
|
|
1255
|
+
case "Cut":
|
|
1256
|
+
return engine.cut(command.clipId);
|
|
887
1257
|
}
|
|
888
1258
|
};
|
|
889
1259
|
const commandWorker = Effect.gen(function* () {
|
|
@@ -926,8 +1296,23 @@ export const makeScheduler = (options) => Effect.gen(function* () {
|
|
|
926
1296
|
const sendCommand = (command) => Effect.gen(function* () {
|
|
927
1297
|
if (ended !== undefined)
|
|
928
1298
|
return;
|
|
929
|
-
if (command._tag === "Build")
|
|
1299
|
+
if (command._tag === "Build") {
|
|
930
1300
|
pendingBuilds.add(command.key);
|
|
1301
|
+
const item = items.get(command.key);
|
|
1302
|
+
if (item !== undefined)
|
|
1303
|
+
item.dispatchedAtMs = monotonicMillis(clock);
|
|
1304
|
+
}
|
|
1305
|
+
if (command._tag === "BuildFiller") {
|
|
1306
|
+
lastFillerSeconds = command.request.durationSeconds;
|
|
1307
|
+
fillerDispatch.set(command.index, {
|
|
1308
|
+
atMs: monotonicMillis(clock),
|
|
1309
|
+
seconds: command.request.durationSeconds,
|
|
1310
|
+
});
|
|
1311
|
+
// In-flight filler is bounded like the samples it feeds.
|
|
1312
|
+
for (const index of fillerDispatch.keys())
|
|
1313
|
+
if (fillerDispatch.size > maxSamples)
|
|
1314
|
+
fillerDispatch.delete(index);
|
|
1315
|
+
}
|
|
931
1316
|
if (command._tag === "DeferAt")
|
|
932
1317
|
pendingAtDeferrals.add(command.key);
|
|
933
1318
|
commandCount++;
|
|
@@ -955,10 +1340,16 @@ export const makeScheduler = (options) => Effect.gen(function* () {
|
|
|
955
1340
|
pendingItemRemovals.add(item.key);
|
|
956
1341
|
yield* sendCommand({ _tag: "RemoveItem", key: item.key, clipId: item.clipId, reason });
|
|
957
1342
|
});
|
|
1343
|
+
/** A withdrawal's answer for an item that has started or settled another way. */
|
|
1344
|
+
const settledOutcome = (item) => item.phase === "Started" || item.status._tag === "Ended" || item.status._tag === "Unobserved"
|
|
1345
|
+
? "already-started"
|
|
1346
|
+
: item.status._tag === "Dropped"
|
|
1347
|
+
? "withdrawn"
|
|
1348
|
+
: "not-found";
|
|
958
1349
|
const requestWithdrawal = (item, reason, reply) => Effect.gen(function* () {
|
|
959
1350
|
if (item.phase === "Started" || item.phase === "Terminal") {
|
|
960
1351
|
if (reply !== undefined)
|
|
961
|
-
yield* Deferred.succeed(reply, item
|
|
1352
|
+
yield* Deferred.succeed(reply, settledOutcome(item));
|
|
962
1353
|
return;
|
|
963
1354
|
}
|
|
964
1355
|
const pending = pendingWithdrawals.get(item.key);
|
|
@@ -981,22 +1372,12 @@ export const makeScheduler = (options) => Effect.gen(function* () {
|
|
|
981
1372
|
});
|
|
982
1373
|
const applyPolicy = (action, state) => Effect.gen(function* () {
|
|
983
1374
|
switch (action._tag) {
|
|
984
|
-
case "Withdraw": {
|
|
985
|
-
const item = items.get(action.key);
|
|
986
|
-
if (item !== undefined)
|
|
987
|
-
yield* requestWithdrawal(item, action.reason);
|
|
988
|
-
break;
|
|
989
|
-
}
|
|
990
1375
|
case "DeferAt":
|
|
991
1376
|
if (canRetryRemoval(action.clipId))
|
|
992
1377
|
yield* sendCommand({ _tag: "DeferAt", key: action.key, clipId: action.clipId });
|
|
993
1378
|
break;
|
|
994
|
-
case "
|
|
995
|
-
|
|
996
|
-
if (!pendingFillerRemovals.has(clipId) && canRetryRemoval(clipId)) {
|
|
997
|
-
pendingFillerRemovals.add(clipId);
|
|
998
|
-
yield* sendCommand({ _tag: "RemoveFiller", clipId });
|
|
999
|
-
}
|
|
1379
|
+
case "Cut":
|
|
1380
|
+
yield* sendCommand({ _tag: "Cut", clipId: action.clipId });
|
|
1000
1381
|
break;
|
|
1001
1382
|
case "Order": {
|
|
1002
1383
|
const signature = state.ready.map((record) => record.clipId).join(",") +
|
|
@@ -1017,7 +1398,7 @@ export const makeScheduler = (options) => Effect.gen(function* () {
|
|
|
1017
1398
|
const item = items.get(action.key);
|
|
1018
1399
|
if (item?.phase !== "Accepted")
|
|
1019
1400
|
break;
|
|
1020
|
-
const prepared = yield* Effect.result(keyedRequest(item.request, item.key));
|
|
1401
|
+
const prepared = yield* Effect.result(keyedRequest(item.request, item.key, action.continueFrom));
|
|
1021
1402
|
if (Result.isFailure(prepared)) {
|
|
1022
1403
|
yield* emit(item, {
|
|
1023
1404
|
_tag: "Failed",
|
|
@@ -1025,6 +1406,11 @@ export const makeScheduler = (options) => Effect.gen(function* () {
|
|
|
1025
1406
|
});
|
|
1026
1407
|
break;
|
|
1027
1408
|
}
|
|
1409
|
+
item.continued = action.continueFrom !== undefined;
|
|
1410
|
+
if (action.follows === undefined)
|
|
1411
|
+
delete item.follows;
|
|
1412
|
+
else
|
|
1413
|
+
item.follows = action.follows;
|
|
1028
1414
|
yield* sendCommand({
|
|
1029
1415
|
_tag: "Build",
|
|
1030
1416
|
key: item.key,
|
|
@@ -1043,7 +1429,7 @@ export const makeScheduler = (options) => Effect.gen(function* () {
|
|
|
1043
1429
|
const captured = upcomingFiller === undefined
|
|
1044
1430
|
? yield* Effect.result(captureFiller(fillerIndex, runwaySeconds(state, monotonicMillis(clock), playingStartedMs, owned, [
|
|
1045
1431
|
...items.values(),
|
|
1046
|
-
]), action.targetSeconds))
|
|
1432
|
+
]), action.durationSeconds, action.targetSeconds))
|
|
1047
1433
|
: Result.succeed(upcomingFiller);
|
|
1048
1434
|
if (Result.isFailure(captured)) {
|
|
1049
1435
|
yield* closeActor(captured.failure);
|
|
@@ -1114,14 +1500,16 @@ export const makeScheduler = (options) => Effect.gen(function* () {
|
|
|
1114
1500
|
item.unknownSessionId = undefined;
|
|
1115
1501
|
item.unknownCause = undefined;
|
|
1116
1502
|
yield* replayEarlyClipEvents(clipId);
|
|
1503
|
+
// Evidence that overtook the reply may already have carried the clip away
|
|
1504
|
+
// with a lost session; the item then waits to be rebuilt, not Building.
|
|
1505
|
+
const carried = item.clipId === undefined;
|
|
1117
1506
|
const pending = pendingWithdrawals.get(item.key);
|
|
1118
|
-
if (
|
|
1119
|
-
|
|
1120
|
-
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
yield* finishWithdrawal(item.key, Result.succeed("not-found"));
|
|
1507
|
+
if (carried) {
|
|
1508
|
+
// The carry settled any withdrawal that was waiting.
|
|
1509
|
+
}
|
|
1510
|
+
else if (pending !== undefined) {
|
|
1511
|
+
if (item.phase === "Started" || item.phase === "Terminal")
|
|
1512
|
+
yield* finishWithdrawal(item.key, Result.succeed(settledOutcome(item)));
|
|
1125
1513
|
else
|
|
1126
1514
|
yield* sendItemRemoval(item, pending.reason);
|
|
1127
1515
|
}
|
|
@@ -1130,6 +1518,8 @@ export const makeScheduler = (options) => Effect.gen(function* () {
|
|
|
1130
1518
|
yield* observed(yield* engine.state);
|
|
1131
1519
|
}
|
|
1132
1520
|
else if (result.failure.context.outcome === "unknown") {
|
|
1521
|
+
// Its time to Ready spans the uncertainty, so it is never measured.
|
|
1522
|
+
delete item.dispatchedAtMs;
|
|
1133
1523
|
if (item.clipId === undefined) {
|
|
1134
1524
|
// A generic engine may re-route between the state read and dispatch.
|
|
1135
1525
|
// Only an owner-fenced enqueue can attribute its unknown result.
|
|
@@ -1153,6 +1543,7 @@ export const makeScheduler = (options) => Effect.gen(function* () {
|
|
|
1153
1543
|
});
|
|
1154
1544
|
else
|
|
1155
1545
|
item.retryAtMs = monotonicMillis(clock) + 1_000;
|
|
1546
|
+
delete item.dispatchedAtMs;
|
|
1156
1547
|
}
|
|
1157
1548
|
break;
|
|
1158
1549
|
}
|
|
@@ -1180,6 +1571,7 @@ export const makeScheduler = (options) => Effect.gen(function* () {
|
|
|
1180
1571
|
// The worker already retained this identity and its original age.
|
|
1181
1572
|
}
|
|
1182
1573
|
else {
|
|
1574
|
+
fillerDispatch.delete(command.index);
|
|
1183
1575
|
fillerRetryAtMs = monotonicMillis(clock) + 1_000;
|
|
1184
1576
|
if (result.failure.context.outcome === "replied") {
|
|
1185
1577
|
fillerIndex = Math.max(fillerIndex, command.index + 1);
|
|
@@ -1212,7 +1604,9 @@ export const makeScheduler = (options) => Effect.gen(function* () {
|
|
|
1212
1604
|
removalUnknownAtSerial.set(command.clipId, snapshotAcquiredSerial);
|
|
1213
1605
|
if (item?.phase === "Started")
|
|
1214
1606
|
yield* finishWithdrawal(command.key, Result.succeed("already-started"));
|
|
1215
|
-
|
|
1607
|
+
// A refusal is not an outcome: the withdrawal stays pending and is retried.
|
|
1608
|
+
// An unknown removal may already have taken effect, so it ends the wait.
|
|
1609
|
+
else if (result.failure.context.outcome === "unknown")
|
|
1216
1610
|
yield* finishWithdrawal(command.key, Result.fail(result.failure));
|
|
1217
1611
|
}
|
|
1218
1612
|
break;
|
|
@@ -1270,6 +1664,11 @@ export const makeScheduler = (options) => Effect.gen(function* () {
|
|
|
1270
1664
|
case "Order":
|
|
1271
1665
|
blockedMove = Result.isFailure(result) ? command.signature : undefined;
|
|
1272
1666
|
break;
|
|
1667
|
+
case "Cut":
|
|
1668
|
+
// A stopped clip can go on looking like it plays until the provider reports it
|
|
1669
|
+
// ended, so it is never cut again, whether its cut was refused or sent.
|
|
1670
|
+
blockedCut = command.clipId;
|
|
1671
|
+
break;
|
|
1273
1672
|
case "PauseAutoplay":
|
|
1274
1673
|
if (Result.isFailure(result)) {
|
|
1275
1674
|
drainFailure = result.failure;
|
|
@@ -1280,66 +1679,37 @@ export const makeScheduler = (options) => Effect.gen(function* () {
|
|
|
1280
1679
|
break;
|
|
1281
1680
|
}
|
|
1282
1681
|
});
|
|
1283
|
-
|
|
1284
|
-
|
|
1285
|
-
|
|
1286
|
-
|
|
1287
|
-
|
|
1288
|
-
|
|
1289
|
-
const
|
|
1290
|
-
|
|
1291
|
-
|
|
1292
|
-
|
|
1293
|
-
(item.phase === "Ready" && item.atMs !== undefined && item.atMs > nowMs));
|
|
1294
|
-
const drainPending = (state) => Effect.gen(function* () {
|
|
1295
|
-
if (!draining)
|
|
1296
|
-
return;
|
|
1297
|
-
const playingId = Option.getOrUndefined(state.playing)?.clipId;
|
|
1298
|
-
for (const item of items.values()) {
|
|
1299
|
-
if (finishAccepted)
|
|
1300
|
-
break;
|
|
1301
|
-
if (item.phase === "Terminal" || item.phase === "Started")
|
|
1302
|
-
continue;
|
|
1303
|
-
if (item.clipId === playingId)
|
|
1682
|
+
const pendingBatches = () => [...batches.values()].map((batch) => ({
|
|
1683
|
+
id: batch.id,
|
|
1684
|
+
waitFor: batch.waitFor,
|
|
1685
|
+
targets: [...batch.targets].map(([key, target]) => ({ key, reason: target.reason })),
|
|
1686
|
+
}));
|
|
1687
|
+
/** The reply a batch keeps for its withdrawal of `key`, taken once. */
|
|
1688
|
+
const takeBatchReply = (key) => {
|
|
1689
|
+
for (const batch of batches.values()) {
|
|
1690
|
+
const target = batch.targets.get(key);
|
|
1691
|
+
if (target === undefined)
|
|
1304
1692
|
continue;
|
|
1305
|
-
|
|
1693
|
+
batch.targets.delete(key);
|
|
1694
|
+
return target.reply;
|
|
1306
1695
|
}
|
|
1307
|
-
|
|
1696
|
+
return undefined;
|
|
1697
|
+
};
|
|
1698
|
+
/** A batch takes effect: what it withdraws goes at once, and its replacements take over. */
|
|
1699
|
+
const commitBatch = (id) => Effect.gen(function* () {
|
|
1700
|
+
const batch = batches.get(id);
|
|
1701
|
+
if (batch === undefined)
|
|
1308
1702
|
return;
|
|
1309
|
-
|
|
1310
|
-
|
|
1311
|
-
|
|
1312
|
-
|
|
1313
|
-
|
|
1314
|
-
|
|
1315
|
-
yield*
|
|
1316
|
-
}
|
|
1703
|
+
batches.delete(id);
|
|
1704
|
+
for (const [key, target] of batch.targets) {
|
|
1705
|
+
const item = items.get(key);
|
|
1706
|
+
if (item !== undefined)
|
|
1707
|
+
yield* requestWithdrawal(item, target.reason, target.reply);
|
|
1708
|
+
else if (target.reply !== undefined)
|
|
1709
|
+
yield* Deferred.succeed(target.reply, "not-found");
|
|
1317
1710
|
}
|
|
1711
|
+
yield* Deferred.succeed(batch.committed, undefined);
|
|
1318
1712
|
});
|
|
1319
|
-
const projectedStartMs = (state, lane, nowMs) => {
|
|
1320
|
-
const playing = Option.getOrUndefined(state.playing);
|
|
1321
|
-
const playingRecord = playing === undefined ? undefined : Option.getOrUndefined(playing.record);
|
|
1322
|
-
const started = playing === undefined ? undefined : playingStartedMs.get(playing.clipId);
|
|
1323
|
-
const restMs = playingRecord === undefined || started === undefined
|
|
1324
|
-
? 0
|
|
1325
|
-
: Math.max(0, playingRecord.durationSeconds * 1000 - (nowMs - started));
|
|
1326
|
-
const laneRank = lanes.indexOf(lane);
|
|
1327
|
-
const preferred = Option.getOrUndefined(state.preferredSessionId);
|
|
1328
|
-
const aheadMs = state.ready.reduce((total, record) => {
|
|
1329
|
-
if (record.sessionId !== preferred)
|
|
1330
|
-
return total;
|
|
1331
|
-
const owner = owned.get(record.clipId);
|
|
1332
|
-
if (owner?._tag === "Filler")
|
|
1333
|
-
return total;
|
|
1334
|
-
if (owner?._tag === "Item") {
|
|
1335
|
-
const prior = items.get(owner.key);
|
|
1336
|
-
if (prior !== undefined && lanes.indexOf(prior.lane) > laneRank)
|
|
1337
|
-
return total;
|
|
1338
|
-
}
|
|
1339
|
-
return total + record.durationSeconds * 1000;
|
|
1340
|
-
}, 0);
|
|
1341
|
-
return nowMs + restMs + aheadMs;
|
|
1342
|
-
};
|
|
1343
1713
|
const reconcile = Effect.gen(function* () {
|
|
1344
1714
|
const state = yield* engine.state;
|
|
1345
1715
|
if (ended !== undefined)
|
|
@@ -1351,6 +1721,10 @@ export const makeScheduler = (options) => Effect.gen(function* () {
|
|
|
1351
1721
|
yield* publishState(state, sources.sessions);
|
|
1352
1722
|
return;
|
|
1353
1723
|
}
|
|
1724
|
+
const nowMs = monotonicMillis(clock);
|
|
1725
|
+
for (const item of items.values())
|
|
1726
|
+
if (item.phase === "Started")
|
|
1727
|
+
fireCues(item, nowMs);
|
|
1354
1728
|
for (const uncertain of unknownFillers.values()) {
|
|
1355
1729
|
const sourceRetired = uncertain.sessionId !== undefined && !sources.has(uncertain.sessionId);
|
|
1356
1730
|
if (sourceRetired)
|
|
@@ -1370,6 +1744,10 @@ export const makeScheduler = (options) => Effect.gen(function* () {
|
|
|
1370
1744
|
continue;
|
|
1371
1745
|
if (item.phase === "Started")
|
|
1372
1746
|
yield* finishWithdrawal(key, Result.succeed("already-started"));
|
|
1747
|
+
// A withdrawal still being retried when its item settles another way. Terminal
|
|
1748
|
+
// Unknown keeps its uncertain result from the retirement or terminal settlement.
|
|
1749
|
+
else if (item.phase === "Terminal" && item.status._tag !== "Unknown")
|
|
1750
|
+
yield* finishWithdrawal(key, Result.succeed(settledOutcome(item)));
|
|
1373
1751
|
else if (item.clipId !== undefined)
|
|
1374
1752
|
yield* sendItemRemoval(item, pending.reason);
|
|
1375
1753
|
}
|
|
@@ -1382,14 +1760,16 @@ export const makeScheduler = (options) => Effect.gen(function* () {
|
|
|
1382
1760
|
// After retirement, so a deadline no longer outlives the uncertainty that
|
|
1383
1761
|
// set it until the next message.
|
|
1384
1762
|
updateRecovery(state);
|
|
1385
|
-
if (draining)
|
|
1386
|
-
yield* drainPending(state);
|
|
1387
1763
|
const preferred = Option.getOrUndefined(state.preferredSessionId);
|
|
1388
1764
|
let unknownFillerCount = 0;
|
|
1389
1765
|
for (const entry of unknownFillers.values())
|
|
1390
1766
|
if (entry.sessionId === undefined || entry.sessionId === preferred)
|
|
1391
1767
|
unknownFillerCount++;
|
|
1392
|
-
const
|
|
1768
|
+
const brokenGroups = new Map();
|
|
1769
|
+
for (const [key, group] of groups)
|
|
1770
|
+
if (group.brokenAt !== undefined)
|
|
1771
|
+
brokenGroups.set(key, group.brokenAt);
|
|
1772
|
+
const decide = () => plan({
|
|
1393
1773
|
engine: state,
|
|
1394
1774
|
items: [...items.values()],
|
|
1395
1775
|
owned,
|
|
@@ -1400,13 +1780,40 @@ export const makeScheduler = (options) => Effect.gen(function* () {
|
|
|
1400
1780
|
targetSeconds,
|
|
1401
1781
|
refillActive,
|
|
1402
1782
|
maxBuildsInFlight,
|
|
1403
|
-
accepting
|
|
1404
|
-
|
|
1783
|
+
accepting,
|
|
1784
|
+
drain: draining ? (finishAccepted ? "accepted" : "playing") : undefined,
|
|
1405
1785
|
fillerRetryAtMs,
|
|
1406
1786
|
unknownFillerCount,
|
|
1407
1787
|
blockedMove,
|
|
1788
|
+
withdrawing: new Set(pendingWithdrawals.keys()),
|
|
1789
|
+
dispatched: pendingBuilds,
|
|
1790
|
+
brokenGroups,
|
|
1791
|
+
batches: pendingBatches(),
|
|
1792
|
+
estimates: estimatesFrom(buildSamples, lengthSamples, continuedSamples),
|
|
1793
|
+
continuedBuildRate: continuedBuildRate(buildSamples, continuedSamples),
|
|
1794
|
+
...fillerView(),
|
|
1795
|
+
cutLanes,
|
|
1796
|
+
fillLengths,
|
|
1797
|
+
blockedCut,
|
|
1408
1798
|
});
|
|
1799
|
+
let decision = decide();
|
|
1800
|
+
// A commit changes what is withdrawn and what takes over, so the plan is read again.
|
|
1801
|
+
if (decision.commit.length > 0) {
|
|
1802
|
+
for (const id of decision.commit)
|
|
1803
|
+
yield* commitBatch(id);
|
|
1804
|
+
decision = decide();
|
|
1805
|
+
}
|
|
1409
1806
|
refillActive = decision.refillActive;
|
|
1807
|
+
for (const { key, reason } of decision.withdraw) {
|
|
1808
|
+
const item = items.get(key);
|
|
1809
|
+
if (item !== undefined)
|
|
1810
|
+
yield* requestWithdrawal(item, reason, takeBatchReply(key));
|
|
1811
|
+
}
|
|
1812
|
+
for (const clipId of decision.withdrawFiller)
|
|
1813
|
+
if (!pendingFillerRemovals.has(clipId) && canRetryRemoval(clipId)) {
|
|
1814
|
+
pendingFillerRemovals.add(clipId);
|
|
1815
|
+
yield* sendCommand({ _tag: "RemoveFiller", clipId });
|
|
1816
|
+
}
|
|
1410
1817
|
yield* publishState(state, sources.sessions);
|
|
1411
1818
|
if ((!draining || finishAccepted) && commandCount === 0 && decision.action !== undefined)
|
|
1412
1819
|
yield* applyPolicy(decision.action, state);
|
|
@@ -1437,6 +1844,504 @@ export const makeScheduler = (options) => Effect.gen(function* () {
|
|
|
1437
1844
|
}
|
|
1438
1845
|
}
|
|
1439
1846
|
});
|
|
1847
|
+
/** Registers a captured item, without yielding; its caller publishes the Accepted evidence. */
|
|
1848
|
+
const accept = (captured, nowMs, startByMs, group, place) => {
|
|
1849
|
+
const startedWaiter = Deferred.makeUnsafe();
|
|
1850
|
+
const outcomeWaiter = Deferred.makeUnsafe();
|
|
1851
|
+
const firstDecisiveWaiter = Deferred.makeUnsafe();
|
|
1852
|
+
const item = {
|
|
1853
|
+
key: captured.key,
|
|
1854
|
+
lane: captured.lane,
|
|
1855
|
+
request: captured.request,
|
|
1856
|
+
fingerprint: captured.fingerprint,
|
|
1857
|
+
admission: place === undefined ? ++admission : place.admission,
|
|
1858
|
+
seconds: captured.request.durationSeconds,
|
|
1859
|
+
phase: "Accepted",
|
|
1860
|
+
status: { _tag: "Accepted" },
|
|
1861
|
+
...(place === undefined
|
|
1862
|
+
? captured.notBeforeOffsetMs === undefined
|
|
1863
|
+
? {}
|
|
1864
|
+
: { notBeforeMs: nowMs + captured.notBeforeOffsetMs }
|
|
1865
|
+
: place.notBeforeMs === undefined
|
|
1866
|
+
? {}
|
|
1867
|
+
: { notBeforeMs: place.notBeforeMs }),
|
|
1868
|
+
...(place?.replaces === undefined
|
|
1869
|
+
? {}
|
|
1870
|
+
: { replaces: place.replaces, generation: place.generation ?? 1 }),
|
|
1871
|
+
...(place?.inserted === true ? { inserted: true } : {}),
|
|
1872
|
+
...(startByMs === undefined ? {} : { startByMs }),
|
|
1873
|
+
firm: captured.firm,
|
|
1874
|
+
...(captured.atWallMs === undefined
|
|
1875
|
+
? {}
|
|
1876
|
+
: {
|
|
1877
|
+
atWallMs: captured.atWallMs,
|
|
1878
|
+
atMs: nowMs + captured.atWallMs - clock.currentTimeMillisUnsafe(),
|
|
1879
|
+
}),
|
|
1880
|
+
late: captured.late,
|
|
1881
|
+
cues: captured.cues ?? [],
|
|
1882
|
+
firedCues: new Set(),
|
|
1883
|
+
...(captured.continuity === undefined ? {} : { continuity: captured.continuity }),
|
|
1884
|
+
...(captured.mode === "asap" ? { asap: true } : {}),
|
|
1885
|
+
...(captured.mode === "manual" ? { held: true } : {}),
|
|
1886
|
+
...(group === undefined ? {} : { group }),
|
|
1887
|
+
sessionId: undefined,
|
|
1888
|
+
unknownSessionId: undefined,
|
|
1889
|
+
unknownCause: undefined,
|
|
1890
|
+
unknownAtMs: undefined,
|
|
1891
|
+
handle: {
|
|
1892
|
+
key: captured.key,
|
|
1893
|
+
started: Deferred.await(startedWaiter),
|
|
1894
|
+
outcome: Deferred.await(outcomeWaiter),
|
|
1895
|
+
firstDecisive: Deferred.await(firstDecisiveWaiter),
|
|
1896
|
+
},
|
|
1897
|
+
startedWaiter,
|
|
1898
|
+
outcomeWaiter,
|
|
1899
|
+
firstDecisiveWaiter,
|
|
1900
|
+
};
|
|
1901
|
+
items.set(item.key, item);
|
|
1902
|
+
return item;
|
|
1903
|
+
};
|
|
1904
|
+
/**
|
|
1905
|
+
* One reply for withdrawals across a group's parts. A group key reports
|
|
1906
|
+
* `withdrawn` if any part was; a part key reports that part's own outcome,
|
|
1907
|
+
* while the parts after it are withdrawn too.
|
|
1908
|
+
*/
|
|
1909
|
+
const settleWithdrawals = (replies, reply, wholeGroup) => Effect.gen(function* () {
|
|
1910
|
+
const outcomes = [];
|
|
1911
|
+
for (const pending of replies) {
|
|
1912
|
+
const result = yield* Effect.result(Deferred.await(pending));
|
|
1913
|
+
if (Result.isFailure(result)) {
|
|
1914
|
+
yield* Deferred.fail(reply, result.failure);
|
|
1915
|
+
return;
|
|
1916
|
+
}
|
|
1917
|
+
outcomes.push(result.success);
|
|
1918
|
+
}
|
|
1919
|
+
yield* Deferred.succeed(reply, wholeGroup && outcomes.includes("withdrawn") ? "withdrawn" : (outcomes[0] ?? "not-found"));
|
|
1920
|
+
});
|
|
1921
|
+
/**
|
|
1922
|
+
* The place an insert takes beside its anchor, or why it cannot: the anchor's lane, an
|
|
1923
|
+
* admission between the anchor and its neighbour in that lane, and a group place when
|
|
1924
|
+
* the insert falls between two parts of a group.
|
|
1925
|
+
*/
|
|
1926
|
+
const placeInsert = (insert) => {
|
|
1927
|
+
const live = (entry) => entry !== undefined && entry.phase !== "Terminal" && entry.replacedBy === undefined;
|
|
1928
|
+
const named = groups.get(insert.anchor);
|
|
1929
|
+
let anchor;
|
|
1930
|
+
if (named === undefined)
|
|
1931
|
+
anchor = items.get(insert.anchor);
|
|
1932
|
+
else {
|
|
1933
|
+
const parts = named.parts
|
|
1934
|
+
.map((key) => items.get(key))
|
|
1935
|
+
.filter(live)
|
|
1936
|
+
.sort((a, b) => a.group.index - b.group.index);
|
|
1937
|
+
if (insert.side === "before" &&
|
|
1938
|
+
(parts.some((part) => part.phase === "Started") || parts[0]?.group?.index !== 0))
|
|
1939
|
+
return "The anchor group already started";
|
|
1940
|
+
anchor = insert.side === "before" ? parts[0] : parts.at(-1);
|
|
1941
|
+
}
|
|
1942
|
+
if (!live(anchor))
|
|
1943
|
+
return "Nothing queued under the anchor key";
|
|
1944
|
+
if (anchor.phase === "Unknown")
|
|
1945
|
+
return "The anchor's admission is uncertain";
|
|
1946
|
+
if (anchor.held === true)
|
|
1947
|
+
return "The anchor is held until it is released";
|
|
1948
|
+
if (insert.side === "before" && anchor.phase === "Started")
|
|
1949
|
+
return "The anchor already started";
|
|
1950
|
+
const neighbours = [...items.values()]
|
|
1951
|
+
.filter((entry) => entry.lane === anchor.lane && entry.phase !== "Terminal")
|
|
1952
|
+
.map((entry) => entry.admission);
|
|
1953
|
+
const bound = insert.side === "before"
|
|
1954
|
+
? Math.max(...neighbours.filter((value) => value < anchor.admission))
|
|
1955
|
+
: Math.min(...neighbours.filter((value) => value > anchor.admission));
|
|
1956
|
+
const admission = Number.isFinite(bound)
|
|
1957
|
+
? (anchor.admission + bound) / 2
|
|
1958
|
+
: anchor.admission + (insert.side === "before" ? -0.5 : 0.5);
|
|
1959
|
+
if (admission === anchor.admission || admission === bound)
|
|
1960
|
+
return "No room is left to insert at that place";
|
|
1961
|
+
let group;
|
|
1962
|
+
if (anchor.group !== undefined) {
|
|
1963
|
+
const indexes = (groups.get(anchor.group.key)?.parts ?? [])
|
|
1964
|
+
.map((key) => items.get(key)?.group?.index)
|
|
1965
|
+
.filter((value) => value !== undefined);
|
|
1966
|
+
const index = anchor.group.index;
|
|
1967
|
+
const neighbour = insert.side === "before"
|
|
1968
|
+
? Math.max(...indexes.filter((value) => value < index))
|
|
1969
|
+
: Math.min(...indexes.filter((value) => value > index));
|
|
1970
|
+
// Before the first part or after the last one, the insert stays outside the group.
|
|
1971
|
+
// Once parts have aired and settled, the place before the first remaining part is
|
|
1972
|
+
// still inside the group.
|
|
1973
|
+
const between = insert.side === "before"
|
|
1974
|
+
? index > 0
|
|
1975
|
+
? (Number.isFinite(neighbour) ? neighbour : index - 1) + index
|
|
1976
|
+
: undefined
|
|
1977
|
+
: Number.isFinite(neighbour)
|
|
1978
|
+
? index + neighbour
|
|
1979
|
+
: undefined;
|
|
1980
|
+
if (between !== undefined) {
|
|
1981
|
+
if (between / 2 === index || between / 2 === neighbour)
|
|
1982
|
+
return "No room is left to insert at that place";
|
|
1983
|
+
group = { key: anchor.group.key, index: between / 2 };
|
|
1984
|
+
}
|
|
1985
|
+
}
|
|
1986
|
+
const timing = {
|
|
1987
|
+
...(anchor.atWallMs === undefined ? {} : { atWallMs: anchor.atWallMs }),
|
|
1988
|
+
late: anchor.late,
|
|
1989
|
+
asap: anchor.asap === true,
|
|
1990
|
+
};
|
|
1991
|
+
return group === undefined
|
|
1992
|
+
? { lane: anchor.lane, admission, ...timing }
|
|
1993
|
+
: { lane: anchor.lane, admission, group, ...timing };
|
|
1994
|
+
};
|
|
1995
|
+
/** The items a withdrawal of `key` names: a group's parts, a part and those after it, or one item. */
|
|
1996
|
+
const withdrawalTargets = (key) => {
|
|
1997
|
+
const item = items.get(key);
|
|
1998
|
+
const named = groups.get(key);
|
|
1999
|
+
const group = named ?? (item?.group === undefined ? undefined : groups.get(item.group.key));
|
|
2000
|
+
if (group === undefined || (named === undefined && item?.inserted === true))
|
|
2001
|
+
return item === undefined ? [] : [item];
|
|
2002
|
+
const from = named === undefined ? item.group.index : 0;
|
|
2003
|
+
const targets = group.parts
|
|
2004
|
+
.map((part) => items.get(part))
|
|
2005
|
+
.filter((part) => part?.group !== undefined && part.group.index >= from);
|
|
2006
|
+
// A part key's reply is that part's own outcome, so it goes first.
|
|
2007
|
+
if (named === undefined)
|
|
2008
|
+
targets.sort((a, b) => Number(b === item) - Number(a === item));
|
|
2009
|
+
return targets;
|
|
2010
|
+
};
|
|
2011
|
+
/**
|
|
2012
|
+
* Checks and admits a call's edits in order, without yielding. A refused edit undoes the
|
|
2013
|
+
* edits before it, so nothing of a refused batch is admitted or published.
|
|
2014
|
+
*/
|
|
2015
|
+
const stageEdits = (edits, batched, state, nowMs) => {
|
|
2016
|
+
const undo = [];
|
|
2017
|
+
const added = [];
|
|
2018
|
+
const waitFor = [];
|
|
2019
|
+
const replaced = [];
|
|
2020
|
+
const results = [];
|
|
2021
|
+
const refuse = (error) => {
|
|
2022
|
+
for (const step of undo.reverse())
|
|
2023
|
+
step();
|
|
2024
|
+
return Result.fail(error);
|
|
2025
|
+
};
|
|
2026
|
+
// Clips a batch withdraws or replaces do not count ahead of the clips it adds.
|
|
2027
|
+
const excluding = new Set();
|
|
2028
|
+
const named = [];
|
|
2029
|
+
for (const edit of edits)
|
|
2030
|
+
switch (edit._tag) {
|
|
2031
|
+
case "Submit":
|
|
2032
|
+
named.push(edit.item.key);
|
|
2033
|
+
break;
|
|
2034
|
+
case "SubmitGroup":
|
|
2035
|
+
named.push(edit.group.key, ...edit.group.parts.map((part) => part.key));
|
|
2036
|
+
break;
|
|
2037
|
+
case "Insert":
|
|
2038
|
+
named.push(edit.insert.key);
|
|
2039
|
+
break;
|
|
2040
|
+
case "Replace":
|
|
2041
|
+
named.push(edit.key, edit.next.key);
|
|
2042
|
+
excluding.add(edit.key);
|
|
2043
|
+
break;
|
|
2044
|
+
case "Withdraw": {
|
|
2045
|
+
const targets = withdrawalTargets(edit.key);
|
|
2046
|
+
named.push(edit.key, ...targets.map((target) => target.key).filter((key) => key !== edit.key));
|
|
2047
|
+
for (const target of targets)
|
|
2048
|
+
excluding.add(target.key);
|
|
2049
|
+
break;
|
|
2050
|
+
}
|
|
2051
|
+
}
|
|
2052
|
+
if (new Set(named).size !== named.length)
|
|
2053
|
+
return refuse(invalid("An edit batch names each key once"));
|
|
2054
|
+
for (const edit of edits)
|
|
2055
|
+
if (edit._tag === "Insert" && excluding.has(edit.insert.anchor))
|
|
2056
|
+
return refuse(invalid("An insert's anchor is withdrawn or replaced in the same batch"));
|
|
2057
|
+
const view = (place) => projectedStartMs({
|
|
2058
|
+
engine: state,
|
|
2059
|
+
items: [...items.values()],
|
|
2060
|
+
owned,
|
|
2061
|
+
lanes,
|
|
2062
|
+
playingStartedMs,
|
|
2063
|
+
nowMs,
|
|
2064
|
+
batches: pendingBatches(),
|
|
2065
|
+
estimates: estimatesFrom(buildSamples, lengthSamples, continuedSamples),
|
|
2066
|
+
...fillerView(),
|
|
2067
|
+
withdrawing: new Set([...pendingWithdrawals.keys(), ...excluding]),
|
|
2068
|
+
}, place);
|
|
2069
|
+
/**
|
|
2070
|
+
* A new submission's lane policy: a skip lane refuses it while the lane has an item
|
|
2071
|
+
* waiting or playing; a replace lane supersedes the items still waiting there.
|
|
2072
|
+
*/
|
|
2073
|
+
const conflict = (key, lane) => {
|
|
2074
|
+
const others = [...items.values()].filter((entry) => entry.lane === lane &&
|
|
2075
|
+
entry.phase !== "Terminal" &&
|
|
2076
|
+
!excluding.has(entry.key) &&
|
|
2077
|
+
!added.includes(entry));
|
|
2078
|
+
switch (conflicts.get(lane)) {
|
|
2079
|
+
case "skip":
|
|
2080
|
+
return others.length > 0 ? LaneBusy.of(key, lane) : undefined;
|
|
2081
|
+
case "replace":
|
|
2082
|
+
for (const entry of others)
|
|
2083
|
+
if (entry.phase !== "Started" && !pendingWithdrawals.has(entry.key)) {
|
|
2084
|
+
replaced.push(entry.key);
|
|
2085
|
+
excluding.add(entry.key);
|
|
2086
|
+
}
|
|
2087
|
+
return undefined;
|
|
2088
|
+
default:
|
|
2089
|
+
return undefined;
|
|
2090
|
+
}
|
|
2091
|
+
};
|
|
2092
|
+
const admitted = (entry) => {
|
|
2093
|
+
added.push(entry);
|
|
2094
|
+
waitFor.push(entry.key);
|
|
2095
|
+
undo.push(() => items.delete(entry.key));
|
|
2096
|
+
};
|
|
2097
|
+
/** An existing key: the same spec returns its handle, and the batch waits for it too. */
|
|
2098
|
+
const repeated = (key, fingerprint) => {
|
|
2099
|
+
const existing = items.get(key) ?? history.get(key);
|
|
2100
|
+
if (existing === undefined && !groups.has(key))
|
|
2101
|
+
return Result.succeed(undefined);
|
|
2102
|
+
if (existing?.fingerprint !== fingerprint)
|
|
2103
|
+
return Result.fail(KeyMismatch.of(key));
|
|
2104
|
+
waitFor.push(key);
|
|
2105
|
+
return Result.succeed(existing.handle);
|
|
2106
|
+
};
|
|
2107
|
+
for (const edit of edits) {
|
|
2108
|
+
switch (edit._tag) {
|
|
2109
|
+
case "Submit": {
|
|
2110
|
+
const { item } = edit;
|
|
2111
|
+
const known = repeated(item.key, item.fingerprint);
|
|
2112
|
+
if (Result.isFailure(known))
|
|
2113
|
+
return refuse(known.failure);
|
|
2114
|
+
if (known.success !== undefined) {
|
|
2115
|
+
results.push({ _tag: "Added", handle: known.success });
|
|
2116
|
+
break;
|
|
2117
|
+
}
|
|
2118
|
+
const busy = conflict(item.key, item.lane);
|
|
2119
|
+
if (busy !== undefined)
|
|
2120
|
+
return refuse(busy);
|
|
2121
|
+
const startByMs = item.startByOffsetMs === undefined ? undefined : nowMs + item.startByOffsetMs;
|
|
2122
|
+
if (startByMs !== undefined &&
|
|
2123
|
+
view({
|
|
2124
|
+
lane: item.lane,
|
|
2125
|
+
admission: Infinity,
|
|
2126
|
+
seconds: item.request.durationSeconds,
|
|
2127
|
+
startByMs,
|
|
2128
|
+
...(item.mode === "asap" ? { asap: true } : {}),
|
|
2129
|
+
...(item.mode === "manual" ? { held: true } : {}),
|
|
2130
|
+
}) > startByMs)
|
|
2131
|
+
return refuse(WouldMissDeadline.of(item.key));
|
|
2132
|
+
const entry = accept(item, nowMs, startByMs, undefined);
|
|
2133
|
+
admitted(entry);
|
|
2134
|
+
results.push({ _tag: "Added", handle: entry.handle });
|
|
2135
|
+
break;
|
|
2136
|
+
}
|
|
2137
|
+
case "SubmitGroup": {
|
|
2138
|
+
const { group } = edit;
|
|
2139
|
+
const known = groups.get(group.key);
|
|
2140
|
+
if (known !== undefined) {
|
|
2141
|
+
if (known.fingerprint !== group.fingerprint)
|
|
2142
|
+
return refuse(KeyMismatch.of(group.key));
|
|
2143
|
+
waitFor.push(...known.parts);
|
|
2144
|
+
results.push({ _tag: "AddedGroup", handle: known.handle });
|
|
2145
|
+
break;
|
|
2146
|
+
}
|
|
2147
|
+
const taken = [group.key, ...group.parts.map((part) => part.key)].find((key) => items.has(key) || history.has(key) || groups.has(key));
|
|
2148
|
+
if (taken !== undefined)
|
|
2149
|
+
return refuse(KeyMismatch.of(taken));
|
|
2150
|
+
const [first, ...rest] = group.parts;
|
|
2151
|
+
const busy = conflict(group.key, first.lane);
|
|
2152
|
+
if (busy !== undefined)
|
|
2153
|
+
return refuse(busy);
|
|
2154
|
+
const startByMs = first.startByOffsetMs === undefined ? undefined : nowMs + first.startByOffsetMs;
|
|
2155
|
+
if (startByMs !== undefined &&
|
|
2156
|
+
view({
|
|
2157
|
+
lane: first.lane,
|
|
2158
|
+
admission: Infinity,
|
|
2159
|
+
seconds: first.request.durationSeconds,
|
|
2160
|
+
startByMs,
|
|
2161
|
+
}) > startByMs)
|
|
2162
|
+
return refuse(WouldMissDeadline.of(group.key));
|
|
2163
|
+
const parts = [
|
|
2164
|
+
accept(first, nowMs, startByMs, { key: group.key, index: 0 }),
|
|
2165
|
+
...rest.map((part, index) => accept(part, nowMs, undefined, { key: group.key, index: index + 1 })),
|
|
2166
|
+
];
|
|
2167
|
+
for (const part of parts)
|
|
2168
|
+
admitted(part);
|
|
2169
|
+
const handle = {
|
|
2170
|
+
key: group.key,
|
|
2171
|
+
parts: [parts[0].handle, ...parts.slice(1).map((part) => part.handle)],
|
|
2172
|
+
};
|
|
2173
|
+
groups.set(group.key, {
|
|
2174
|
+
fingerprint: group.fingerprint,
|
|
2175
|
+
handle,
|
|
2176
|
+
parts: group.parts.map((part) => part.key),
|
|
2177
|
+
brokenAt: undefined,
|
|
2178
|
+
});
|
|
2179
|
+
undo.push(() => groups.delete(group.key));
|
|
2180
|
+
results.push({ _tag: "AddedGroup", handle });
|
|
2181
|
+
break;
|
|
2182
|
+
}
|
|
2183
|
+
case "Insert": {
|
|
2184
|
+
const { insert } = edit;
|
|
2185
|
+
const known = repeated(insert.key, insert.fingerprint);
|
|
2186
|
+
if (Result.isFailure(known))
|
|
2187
|
+
return refuse(known.failure);
|
|
2188
|
+
if (known.success !== undefined) {
|
|
2189
|
+
results.push({ _tag: "Added", handle: known.success });
|
|
2190
|
+
break;
|
|
2191
|
+
}
|
|
2192
|
+
const placed = placeInsert(insert);
|
|
2193
|
+
if (typeof placed === "string")
|
|
2194
|
+
return refuse(invalid(placed));
|
|
2195
|
+
const startByMs = insert.startByOffsetMs === undefined ? undefined : nowMs + insert.startByOffsetMs;
|
|
2196
|
+
if (startByMs !== undefined &&
|
|
2197
|
+
view({ ...placed, seconds: insert.request.durationSeconds, startByMs }) > startByMs)
|
|
2198
|
+
return refuse(WouldMissDeadline.of(insert.key));
|
|
2199
|
+
const entry = accept({
|
|
2200
|
+
key: insert.key,
|
|
2201
|
+
lane: placed.lane,
|
|
2202
|
+
request: insert.request,
|
|
2203
|
+
fingerprint: insert.fingerprint,
|
|
2204
|
+
notBeforeOffsetMs: insert.notBeforeOffsetMs,
|
|
2205
|
+
startByOffsetMs: insert.startByOffsetMs,
|
|
2206
|
+
firm: insert.firm,
|
|
2207
|
+
atWallMs: placed.atWallMs,
|
|
2208
|
+
late: placed.late,
|
|
2209
|
+
...(placed.asap ? { mode: "asap" } : {}),
|
|
2210
|
+
cues: insert.cues,
|
|
2211
|
+
...(insert.continuity === undefined ? {} : { continuity: insert.continuity }),
|
|
2212
|
+
}, nowMs, startByMs, placed.group, {
|
|
2213
|
+
admission: placed.admission,
|
|
2214
|
+
inserted: true,
|
|
2215
|
+
notBeforeMs: insert.notBeforeOffsetMs === undefined
|
|
2216
|
+
? undefined
|
|
2217
|
+
: nowMs + insert.notBeforeOffsetMs,
|
|
2218
|
+
});
|
|
2219
|
+
admitted(entry);
|
|
2220
|
+
if (placed.group !== undefined) {
|
|
2221
|
+
const parts = groups.get(placed.group.key)?.parts;
|
|
2222
|
+
parts?.push(entry.key);
|
|
2223
|
+
undo.push(() => parts?.pop());
|
|
2224
|
+
}
|
|
2225
|
+
results.push({ _tag: "Added", handle: entry.handle });
|
|
2226
|
+
break;
|
|
2227
|
+
}
|
|
2228
|
+
case "Replace": {
|
|
2229
|
+
const { next } = edit;
|
|
2230
|
+
const fingerprint = JSON.stringify({
|
|
2231
|
+
replaces: edit.key,
|
|
2232
|
+
request: next.request,
|
|
2233
|
+
...(next.cues.length === 0 ? {} : { cues: next.cues }),
|
|
2234
|
+
...(next.continuity === undefined ? {} : { continuity: next.continuity }),
|
|
2235
|
+
});
|
|
2236
|
+
const known = repeated(next.key, fingerprint);
|
|
2237
|
+
if (Result.isFailure(known))
|
|
2238
|
+
return refuse(known.failure);
|
|
2239
|
+
if (known.success !== undefined) {
|
|
2240
|
+
results.push({ _tag: "Added", handle: known.success });
|
|
2241
|
+
break;
|
|
2242
|
+
}
|
|
2243
|
+
const old = items.get(edit.key);
|
|
2244
|
+
if (old === undefined ||
|
|
2245
|
+
old.replacedBy !== undefined ||
|
|
2246
|
+
(old.phase !== "Accepted" && old.phase !== "Building" && old.phase !== "Ready"))
|
|
2247
|
+
return refuse(invalid("Nothing queued to replace under that key"));
|
|
2248
|
+
const entry = accept({
|
|
2249
|
+
key: next.key,
|
|
2250
|
+
lane: old.lane,
|
|
2251
|
+
request: next.request,
|
|
2252
|
+
fingerprint,
|
|
2253
|
+
notBeforeOffsetMs: undefined,
|
|
2254
|
+
startByOffsetMs: undefined,
|
|
2255
|
+
firm: old.firm,
|
|
2256
|
+
atWallMs: old.atWallMs,
|
|
2257
|
+
late: old.late,
|
|
2258
|
+
...(old.asap === true ? { mode: "asap" } : {}),
|
|
2259
|
+
...(old.held === true ? { mode: "manual" } : {}),
|
|
2260
|
+
cues: next.cues,
|
|
2261
|
+
...(next.continuity === undefined ? {} : { continuity: next.continuity }),
|
|
2262
|
+
}, nowMs, old.startByMs, old.group, {
|
|
2263
|
+
admission: old.admission,
|
|
2264
|
+
replaces: old.key,
|
|
2265
|
+
generation: (old.generation ?? 0) + 1,
|
|
2266
|
+
inserted: old.inserted === true,
|
|
2267
|
+
notBeforeMs: old.notBeforeMs,
|
|
2268
|
+
});
|
|
2269
|
+
admitted(entry);
|
|
2270
|
+
old.replacedBy = entry.key;
|
|
2271
|
+
undo.push(() => delete old.replacedBy);
|
|
2272
|
+
// A pending batch that waits for the replaced item waits for its replacement.
|
|
2273
|
+
for (const batch of batches.values())
|
|
2274
|
+
if (batch.waitFor.includes(old.key)) {
|
|
2275
|
+
batch.waitFor.push(entry.key);
|
|
2276
|
+
undo.push(() => batch.waitFor.pop());
|
|
2277
|
+
}
|
|
2278
|
+
if (old.group !== undefined) {
|
|
2279
|
+
const parts = groups.get(old.group.key)?.parts;
|
|
2280
|
+
parts?.push(entry.key);
|
|
2281
|
+
undo.push(() => parts?.pop());
|
|
2282
|
+
}
|
|
2283
|
+
results.push({ _tag: "Added", handle: entry.handle });
|
|
2284
|
+
break;
|
|
2285
|
+
}
|
|
2286
|
+
case "Withdraw":
|
|
2287
|
+
results.push({ _tag: "Withdrawal", key: edit.key });
|
|
2288
|
+
break;
|
|
2289
|
+
}
|
|
2290
|
+
}
|
|
2291
|
+
return Result.succeed({ results, added, waitFor, replaced });
|
|
2292
|
+
};
|
|
2293
|
+
/**
|
|
2294
|
+
* Makes a staged call's handle. A batch keeps what it withdraws as cover until it commits;
|
|
2295
|
+
* a single call adds one item or group and takes effect at once.
|
|
2296
|
+
*/
|
|
2297
|
+
const openBatch = (staged, batched) => {
|
|
2298
|
+
// A single call adds one item or group and never withdraws; only a replace lane gives
|
|
2299
|
+
// it something to supersede.
|
|
2300
|
+
if (!batched && staged.replaced.length === 0)
|
|
2301
|
+
return {
|
|
2302
|
+
handle: {
|
|
2303
|
+
results: staged.results.filter((result) => result._tag !== "Withdrawal"),
|
|
2304
|
+
committed: Effect.void,
|
|
2305
|
+
},
|
|
2306
|
+
settle: [],
|
|
2307
|
+
};
|
|
2308
|
+
const settle = [];
|
|
2309
|
+
const batch = {
|
|
2310
|
+
id: ++batchCount,
|
|
2311
|
+
waitFor: [...staged.waitFor],
|
|
2312
|
+
targets: new Map(staged.replaced.map((key) => [key, { reason: "replaced", reply: undefined }])),
|
|
2313
|
+
committed: Deferred.makeUnsafe(),
|
|
2314
|
+
};
|
|
2315
|
+
const results = staged.results.map((result) => {
|
|
2316
|
+
if (result._tag !== "Withdrawal")
|
|
2317
|
+
return result;
|
|
2318
|
+
const replies = [];
|
|
2319
|
+
for (const target of withdrawalTargets(result.key)) {
|
|
2320
|
+
const reply = Deferred.makeUnsafe();
|
|
2321
|
+
replies.push(reply);
|
|
2322
|
+
batch.targets.set(target.key, { reason: "withdrawn", reply });
|
|
2323
|
+
}
|
|
2324
|
+
const outcome = Deferred.makeUnsafe();
|
|
2325
|
+
settle.push(settleWithdrawals(replies, outcome, groups.has(result.key)));
|
|
2326
|
+
return {
|
|
2327
|
+
_tag: "Withdrawal",
|
|
2328
|
+
outcome: Deferred.await(outcome).pipe(Effect.raceFirst(closedCall)),
|
|
2329
|
+
};
|
|
2330
|
+
});
|
|
2331
|
+
// A pending batch whose new clip this one withdraws now also waits for this one's, so
|
|
2332
|
+
// its own cover is not withdrawn before anything replaces it.
|
|
2333
|
+
for (const other of batches.values())
|
|
2334
|
+
if (other.waitFor.some((key) => batch.targets.has(key)))
|
|
2335
|
+
other.waitFor.push(...batch.waitFor);
|
|
2336
|
+
batches.set(batch.id, batch);
|
|
2337
|
+
return {
|
|
2338
|
+
handle: {
|
|
2339
|
+
results: batched ? results : results.filter((result) => result._tag !== "Withdrawal"),
|
|
2340
|
+
committed: batched ? Deferred.await(batch.committed) : Effect.void,
|
|
2341
|
+
},
|
|
2342
|
+
settle,
|
|
2343
|
+
};
|
|
2344
|
+
};
|
|
1440
2345
|
const actor = Effect.gen(function* () {
|
|
1441
2346
|
for (;;) {
|
|
1442
2347
|
const message = yield* takeQueue(inbox);
|
|
@@ -1444,91 +2349,66 @@ export const makeScheduler = (options) => Effect.gen(function* () {
|
|
|
1444
2349
|
if (ended !== undefined && message._tag !== "CommandDone")
|
|
1445
2350
|
continue;
|
|
1446
2351
|
switch (message._tag) {
|
|
1447
|
-
case "
|
|
2352
|
+
case "Edit": {
|
|
1448
2353
|
if (!accepting) {
|
|
1449
2354
|
yield* Deferred.fail(message.reply, invalid("Scheduler is draining or closed"));
|
|
1450
2355
|
break;
|
|
1451
2356
|
}
|
|
1452
|
-
const existing = items.get(message.item.key) ?? history.get(message.item.key);
|
|
1453
|
-
if (existing !== undefined) {
|
|
1454
|
-
if (existing.fingerprint === message.item.fingerprint)
|
|
1455
|
-
yield* Deferred.succeed(message.reply, existing.handle);
|
|
1456
|
-
else
|
|
1457
|
-
yield* Deferred.fail(message.reply, KeyMismatch.of(message.item.key));
|
|
1458
|
-
break;
|
|
1459
|
-
}
|
|
1460
2357
|
const nowMs = monotonicMillis(clock);
|
|
1461
2358
|
const state = yield* engine.state;
|
|
1462
2359
|
if (ended !== undefined)
|
|
1463
2360
|
continue;
|
|
1464
|
-
|
|
1465
|
-
|
|
1466
|
-
|
|
1467
|
-
const
|
|
1468
|
-
if (
|
|
1469
|
-
yield* Deferred.fail(message.reply,
|
|
2361
|
+
// No yield from the terminal check above through admission and its
|
|
2362
|
+
// publication below: a claim either precedes the items or settles them
|
|
2363
|
+
// after their Accepted evidence.
|
|
2364
|
+
const staged = stageEdits(message.edits, message.batched, state, nowMs);
|
|
2365
|
+
if (Result.isFailure(staged)) {
|
|
2366
|
+
yield* Deferred.fail(message.reply, staged.failure);
|
|
1470
2367
|
break;
|
|
1471
2368
|
}
|
|
1472
|
-
|
|
1473
|
-
|
|
1474
|
-
|
|
1475
|
-
|
|
1476
|
-
|
|
1477
|
-
|
|
1478
|
-
const
|
|
1479
|
-
|
|
1480
|
-
|
|
1481
|
-
outcome: Deferred.await(outcomeWaiter),
|
|
1482
|
-
firstDecisive: Deferred.await(firstDecisiveWaiter),
|
|
1483
|
-
};
|
|
1484
|
-
const item = {
|
|
1485
|
-
key: message.item.key,
|
|
1486
|
-
lane: message.item.lane,
|
|
1487
|
-
request: message.item.request,
|
|
1488
|
-
fingerprint: message.item.fingerprint,
|
|
1489
|
-
admission: ++admission,
|
|
1490
|
-
phase: "Accepted",
|
|
1491
|
-
status: { _tag: "Accepted" },
|
|
1492
|
-
...(message.item.notBeforeOffsetMs === undefined
|
|
1493
|
-
? {}
|
|
1494
|
-
: { notBeforeMs: nowMs + message.item.notBeforeOffsetMs }),
|
|
1495
|
-
...(startByMs === undefined ? {} : { startByMs }),
|
|
1496
|
-
firm: message.item.firm,
|
|
1497
|
-
...(message.item.atWallMs === undefined
|
|
1498
|
-
? {}
|
|
1499
|
-
: {
|
|
1500
|
-
atWallMs: message.item.atWallMs,
|
|
1501
|
-
atMs: nowMs + message.item.atWallMs - clock.currentTimeMillisUnsafe(),
|
|
1502
|
-
}),
|
|
1503
|
-
late: message.item.late,
|
|
1504
|
-
sessionId: undefined,
|
|
1505
|
-
unknownSessionId: undefined,
|
|
1506
|
-
unknownCause: undefined,
|
|
1507
|
-
unknownAtMs: undefined,
|
|
1508
|
-
handle,
|
|
1509
|
-
startedWaiter,
|
|
1510
|
-
outcomeWaiter,
|
|
1511
|
-
firstDecisiveWaiter,
|
|
1512
|
-
};
|
|
1513
|
-
items.set(item.key, item);
|
|
1514
|
-
PubSub.publishUnsafe(events, {
|
|
1515
|
-
key: item.key,
|
|
1516
|
-
at: clock.currentTimeMillisUnsafe(),
|
|
1517
|
-
status: item.status,
|
|
1518
|
-
});
|
|
2369
|
+
for (const entry of staged.success.added)
|
|
2370
|
+
PubSub.publishUnsafe(events, {
|
|
2371
|
+
key: entry.key,
|
|
2372
|
+
at: clock.currentTimeMillisUnsafe(),
|
|
2373
|
+
status: entry.status,
|
|
2374
|
+
});
|
|
2375
|
+
const opened = openBatch(staged.success, message.batched);
|
|
2376
|
+
for (const effect of opened.settle)
|
|
2377
|
+
yield* Effect.forkIn(effect, actorScope);
|
|
1519
2378
|
yield* observed(state);
|
|
1520
|
-
//
|
|
2379
|
+
// An edit returns its committed view; other messages publish once
|
|
1521
2380
|
// through reconciliation after all their evidence is adopted.
|
|
1522
2381
|
yield* publishState(state);
|
|
1523
|
-
yield* Deferred.succeed(message.reply, handle);
|
|
2382
|
+
yield* Deferred.succeed(message.reply, opened.handle);
|
|
1524
2383
|
break;
|
|
1525
2384
|
}
|
|
1526
2385
|
case "Withdraw": {
|
|
2386
|
+
const targets = withdrawalTargets(message.key);
|
|
2387
|
+
if (targets.length <= 1 && !groups.has(message.key)) {
|
|
2388
|
+
if (targets[0] === undefined)
|
|
2389
|
+
yield* Deferred.succeed(message.reply, "not-found");
|
|
2390
|
+
else
|
|
2391
|
+
yield* requestWithdrawal(targets[0], "withdrawn", message.reply);
|
|
2392
|
+
break;
|
|
2393
|
+
}
|
|
2394
|
+
const replies = [];
|
|
2395
|
+
for (const part of targets) {
|
|
2396
|
+
const reply = Deferred.makeUnsafe();
|
|
2397
|
+
replies.push(reply);
|
|
2398
|
+
yield* requestWithdrawal(part, "withdrawn", reply);
|
|
2399
|
+
}
|
|
2400
|
+
yield* Effect.forkIn(settleWithdrawals(replies, message.reply, groups.has(message.key)), actorScope);
|
|
2401
|
+
break;
|
|
2402
|
+
}
|
|
2403
|
+
case "Release": {
|
|
1527
2404
|
const item = items.get(message.key);
|
|
1528
|
-
if (item ===
|
|
1529
|
-
yield* Deferred.
|
|
1530
|
-
|
|
1531
|
-
|
|
2405
|
+
if (item?.held !== true || item.phase === "Started" || item.phase === "Terminal") {
|
|
2406
|
+
yield* Deferred.fail(message.reply, invalid("Nothing is held under that key"));
|
|
2407
|
+
break;
|
|
2408
|
+
}
|
|
2409
|
+
item.held = false;
|
|
2410
|
+
item.asap = true;
|
|
2411
|
+
yield* Deferred.succeed(message.reply, undefined);
|
|
1532
2412
|
break;
|
|
1533
2413
|
}
|
|
1534
2414
|
case "Drain": {
|
|
@@ -1562,7 +2442,6 @@ export const makeScheduler = (options) => Effect.gen(function* () {
|
|
|
1562
2442
|
yield* sendCommand({ _tag: "PauseAutoplay" });
|
|
1563
2443
|
}
|
|
1564
2444
|
}
|
|
1565
|
-
yield* drainPending(yield* engine.state);
|
|
1566
2445
|
break;
|
|
1567
2446
|
}
|
|
1568
2447
|
case "Snapshot": {
|
|
@@ -1640,16 +2519,72 @@ export const makeScheduler = (options) => Effect.gen(function* () {
|
|
|
1640
2519
|
yield* Queue.offer(inbox, { _tag: "Tick" });
|
|
1641
2520
|
yield* Effect.addFinalizer(() => closeActor(ReactorError.fromCode("Closed", "The scheduler closed")));
|
|
1642
2521
|
yield* Deferred.await(initialized);
|
|
2522
|
+
const laneSet = new Set(lanes);
|
|
2523
|
+
const send = (edits, batched) => Effect.gen(function* () {
|
|
2524
|
+
const reply = yield* Deferred.make();
|
|
2525
|
+
if (!(yield* Queue.offer(inbox, { _tag: "Edit", edits, batched, reply })))
|
|
2526
|
+
return yield* closedCall;
|
|
2527
|
+
return yield* Deferred.await(reply).pipe(Effect.raceFirst(closedCall));
|
|
2528
|
+
});
|
|
2529
|
+
/** The one result of a single call. */
|
|
2530
|
+
const only = (handle) => handle.results[0];
|
|
1643
2531
|
const submit = (input) => Effect.gen(function* () {
|
|
1644
2532
|
if (yield* Deferred.isDone(stopped))
|
|
1645
2533
|
return yield* closedCall;
|
|
1646
|
-
const item = yield* captureItem(input,
|
|
2534
|
+
const item = yield* captureItem(input, laneSet);
|
|
1647
2535
|
yield* Effect.annotateCurrentSpan("reactor.scheduler.item.key", item.key);
|
|
2536
|
+
const result = only(yield* send([{ _tag: "Submit", item }], false));
|
|
2537
|
+
if (result?._tag !== "Added")
|
|
2538
|
+
return yield* Effect.die("A submission returned no item");
|
|
2539
|
+
return result.handle;
|
|
2540
|
+
}).pipe(Effect.withSpan("Scheduler.submit", {}, { captureStackTrace: false }));
|
|
2541
|
+
const submitGroup = (input) => Effect.gen(function* () {
|
|
2542
|
+
if (yield* Deferred.isDone(stopped))
|
|
2543
|
+
return yield* closedCall;
|
|
2544
|
+
const group = yield* captureGroup(input, laneSet);
|
|
2545
|
+
yield* Effect.annotateCurrentSpan("reactor.scheduler.group.key", group.key);
|
|
2546
|
+
const result = only(yield* send([{ _tag: "SubmitGroup", group }], false));
|
|
2547
|
+
if (result?._tag !== "AddedGroup")
|
|
2548
|
+
return yield* Effect.die("A group returned no handle");
|
|
2549
|
+
return result.handle;
|
|
2550
|
+
}).pipe(Effect.withSpan("Scheduler.submitGroup", {}, { captureStackTrace: false }));
|
|
2551
|
+
const replace = (key, input) => Effect.gen(function* () {
|
|
2552
|
+
if (yield* Deferred.isDone(stopped))
|
|
2553
|
+
return yield* closedCall;
|
|
2554
|
+
const next = yield* captureReplacement(input);
|
|
2555
|
+
const result = only(yield* send([{ _tag: "Replace", key, next }], false).pipe(
|
|
2556
|
+
// A replacement keeps the replaced item's place and window, so neither a deadline
|
|
2557
|
+
// nor its lane's policy is checked for it.
|
|
2558
|
+
Effect.catchTag(["WouldMissDeadline", "LaneBusy"], (error) => Effect.die(error))));
|
|
2559
|
+
if (result?._tag !== "Added")
|
|
2560
|
+
return yield* Effect.die("A replacement returned no item");
|
|
2561
|
+
return result.handle;
|
|
2562
|
+
}).pipe(Effect.withSpan("Scheduler.replace", { attributes: { key } }, { captureStackTrace: false }));
|
|
2563
|
+
const insert = (input) => Effect.gen(function* () {
|
|
2564
|
+
if (yield* Deferred.isDone(stopped))
|
|
2565
|
+
return yield* closedCall;
|
|
2566
|
+
const captured = yield* captureInsert(input);
|
|
2567
|
+
yield* Effect.annotateCurrentSpan("reactor.scheduler.item.key", captured.key);
|
|
2568
|
+
const result = only(yield* send([{ _tag: "Insert", insert: captured }], false).pipe(
|
|
2569
|
+
// An insert places itself, so its lane's policy does not apply.
|
|
2570
|
+
Effect.catchTag("LaneBusy", (error) => Effect.die(error))));
|
|
2571
|
+
if (result?._tag !== "Added")
|
|
2572
|
+
return yield* Effect.die("An insert returned no item");
|
|
2573
|
+
return result.handle;
|
|
2574
|
+
}).pipe(Effect.withSpan("Scheduler.insert", {}, { captureStackTrace: false }));
|
|
2575
|
+
const edit = (input) => Effect.gen(function* () {
|
|
2576
|
+
if (yield* Deferred.isDone(stopped))
|
|
2577
|
+
return yield* closedCall;
|
|
2578
|
+
const edits = yield* captureEdits(input, laneSet);
|
|
2579
|
+
yield* Effect.annotateCurrentSpan("reactor.scheduler.edits", edits.length);
|
|
2580
|
+
return yield* send(edits, true);
|
|
2581
|
+
}).pipe(Effect.withSpan("Scheduler.edit", {}, { captureStackTrace: false }));
|
|
2582
|
+
const release = (key) => Effect.gen(function* () {
|
|
1648
2583
|
const reply = yield* Deferred.make();
|
|
1649
|
-
if (!(yield* Queue.offer(inbox, { _tag: "
|
|
2584
|
+
if (!(yield* Queue.offer(inbox, { _tag: "Release", key, reply })))
|
|
1650
2585
|
return yield* closedCall;
|
|
1651
2586
|
return yield* Deferred.await(reply).pipe(Effect.raceFirst(closedCall));
|
|
1652
|
-
}).pipe(Effect.withSpan("Scheduler.
|
|
2587
|
+
}).pipe(Effect.withSpan("Scheduler.release", { attributes: { key } }, { captureStackTrace: false }));
|
|
1653
2588
|
const withdraw = (key) => Effect.gen(function* () {
|
|
1654
2589
|
const reply = yield* Deferred.make();
|
|
1655
2590
|
if (!(yield* Queue.offer(inbox, { _tag: "Withdraw", key, reply })))
|
|
@@ -1669,11 +2604,17 @@ export const makeScheduler = (options) => Effect.gen(function* () {
|
|
|
1669
2604
|
}).pipe(Effect.withSpan("Scheduler.drain", {}, { captureStackTrace: false }));
|
|
1670
2605
|
return {
|
|
1671
2606
|
submit,
|
|
2607
|
+
submitGroup,
|
|
2608
|
+
replace,
|
|
2609
|
+
insert,
|
|
2610
|
+
edit,
|
|
2611
|
+
release,
|
|
1672
2612
|
withdraw,
|
|
1673
2613
|
drain,
|
|
1674
2614
|
failure: Deferred.await(stopped),
|
|
1675
2615
|
state: SubscriptionRef.get(stateRef),
|
|
1676
2616
|
asRun: Stream.fromPubSub(events),
|
|
2617
|
+
cues: Stream.fromPubSub(cueEvents),
|
|
1677
2618
|
};
|
|
1678
2619
|
}).pipe(Effect.provideServiceEffect(Scope.Scope, Effect.flatMap(Scope.Scope, (parent) => Scope.fork(parent, "sequential"))));
|
|
1679
2620
|
export const layerScheduler = (options) => Layer.effect(Scheduler, makeScheduler(options));
|