reactor-effect-client 0.5.0 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +29 -3
- package/dist/_internal/queue.d.ts +6 -0
- package/dist/_internal/queue.d.ts.map +1 -0
- package/dist/_internal/queue.js +30 -0
- package/dist/_internal/queue.js.map +1 -0
- package/dist/host.d.ts +1 -0
- package/dist/host.d.ts.map +1 -1
- package/dist/host.js +1 -0
- package/dist/host.js.map +1 -1
- package/dist/observation.d.ts +3 -0
- package/dist/observation.d.ts.map +1 -1
- package/dist/observation.js +9 -4
- package/dist/observation.js.map +1 -1
- package/dist/orchestration/index.d.ts +6 -6
- package/dist/orchestration/index.d.ts.map +1 -1
- package/dist/orchestration/index.js +3 -3
- package/dist/orchestration/index.js.map +1 -1
- package/dist/orchestration/media-buffer.d.ts +2 -1
- package/dist/orchestration/media-buffer.d.ts.map +1 -1
- package/dist/orchestration/media-buffer.js +5 -4
- package/dist/orchestration/media-buffer.js.map +1 -1
- package/dist/orchestration/renewal-state.d.ts +14 -2
- package/dist/orchestration/renewal-state.d.ts.map +1 -1
- package/dist/orchestration/renewal-state.js +34 -4
- package/dist/orchestration/renewal-state.js.map +1 -1
- package/dist/orchestration/renewal.d.ts +27 -3
- package/dist/orchestration/renewal.d.ts.map +1 -1
- package/dist/orchestration/renewal.js +400 -95
- package/dist/orchestration/renewal.js.map +1 -1
- package/dist/orchestration/retention.d.ts +100 -0
- package/dist/orchestration/retention.d.ts.map +1 -0
- package/dist/orchestration/retention.js +134 -0
- package/dist/orchestration/retention.js.map +1 -0
- 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 +153 -12
- package/dist/orchestration/scheduler-policy.d.ts.map +1 -1
- package/dist/orchestration/scheduler-policy.js +433 -54
- package/dist/orchestration/scheduler-policy.js.map +1 -1
- package/dist/orchestration/scheduler.d.ts +206 -5
- package/dist/orchestration/scheduler.d.ts.map +1 -1
- package/dist/orchestration/scheduler.js +1451 -284
- package/dist/orchestration/scheduler.js.map +1 -1
- package/dist/orchestration/source-slot.d.ts +16 -4
- package/dist/orchestration/source-slot.d.ts.map +1 -1
- package/dist/orchestration/source-slot.js +122 -30
- package/dist/orchestration/source-slot.js.map +1 -1
- package/dist/orchestration/types.d.ts +503 -1
- package/dist/orchestration/types.d.ts.map +1 -1
- package/dist/orchestration/types.js +105 -1
- package/dist/orchestration/types.js.map +1 -1
- package/dist/session.d.ts.map +1 -1
- package/dist/session.js +2 -1
- package/dist/session.js.map +1 -1
- package/dist/simulation/_internal/source.d.ts.map +1 -1
- package/dist/simulation/_internal/source.js +5 -4
- package/dist/simulation/_internal/source.js.map +1 -1
- package/package.json +2 -2
|
@@ -10,15 +10,18 @@ import * as PubSub from "effect/PubSub";
|
|
|
10
10
|
import * as Queue from "effect/Queue";
|
|
11
11
|
import * as Result from "effect/Result";
|
|
12
12
|
import * as Schema from "effect/Schema";
|
|
13
|
+
import * as Scope from "effect/Scope";
|
|
13
14
|
import * as Stream from "effect/Stream";
|
|
14
15
|
import * as SubscriptionRef from "effect/SubscriptionRef";
|
|
16
|
+
import { take as takeQueue } from "../_internal/queue.js";
|
|
15
17
|
import { duration } from "../duration.js";
|
|
16
18
|
import { parsedInput, ReactorError } from "../errors.js";
|
|
17
19
|
import { monotonicMillis } from "./elapsed.js";
|
|
20
|
+
import { requestSeconds as h3RequestSeconds } from "../h3/profile.js";
|
|
18
21
|
import { captureRequest, PolicyFailure } from "./request.js";
|
|
19
22
|
import { activeIds } from "./routing.js";
|
|
20
23
|
import { fillerIndexFromKey, fillerKey, isReservedSchedulerKey, keyedRequest, keyFromProviderMetadata, } from "./scheduler-key.js";
|
|
21
|
-
import { plan, runwaySeconds } from "./scheduler-policy.js";
|
|
24
|
+
import { dueCues, estimatesFrom, plan, projectedStartMs, runwaySeconds, } from "./scheduler-policy.js";
|
|
22
25
|
import { Engine } from "./types.js";
|
|
23
26
|
/** Stable caller identity for a scheduled item. */
|
|
24
27
|
export const ItemKey = Schema.NonEmptyString.pipe(Schema.brand("ItemKey"));
|
|
@@ -35,6 +38,14 @@ export class KeyMismatch extends Schema.TaggedError()("KeyMismatch", {
|
|
|
35
38
|
return new KeyMismatch({ key });
|
|
36
39
|
}
|
|
37
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
|
+
}
|
|
38
49
|
export class WouldMissDeadline extends Schema.TaggedError()("WouldMissDeadline", { key: ItemKey }) {
|
|
39
50
|
static of(key) {
|
|
40
51
|
return new WouldMissDeadline({ key });
|
|
@@ -51,9 +62,8 @@ const ownedData = (input, fields, name) => {
|
|
|
51
62
|
return Result.fail(invalid(`${name} has unsupported fields or accessors`));
|
|
52
63
|
return Result.succeed(Object.fromEntries(Object.entries(descriptors).map(([field, descriptor]) => [field, descriptor.value])));
|
|
53
64
|
};
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
return input;
|
|
65
|
+
/** A Duration tuple or object read from own data properties; no accessor runs. */
|
|
66
|
+
const durationData = (input) => {
|
|
57
67
|
if (Array.isArray(input)) {
|
|
58
68
|
const descriptors = Object.getOwnPropertyDescriptors(input);
|
|
59
69
|
if (Object.getOwnPropertySymbols(input).length > 0 ||
|
|
@@ -61,51 +71,107 @@ const captureDuration = (input) => Effect.gen(function* () {
|
|
|
61
71
|
descriptors.length?.value !== 2 ||
|
|
62
72
|
descriptors["0"] === undefined ||
|
|
63
73
|
descriptors["1"] === undefined)
|
|
64
|
-
return
|
|
65
|
-
|
|
74
|
+
return Result.fail(invalid("Duration tuple has unsupported fields or accessors"));
|
|
75
|
+
const seconds = descriptors["0"].value;
|
|
76
|
+
const nanos = descriptors["1"].value;
|
|
77
|
+
return Result.succeed([seconds, nanos]);
|
|
66
78
|
}
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
"microseconds",
|
|
75
|
-
"nanoseconds",
|
|
76
|
-
], "Duration")));
|
|
79
|
+
const fields = ownedData(input, ["weeks", "days", "hours", "minutes", "seconds", "milliseconds", "microseconds", "nanoseconds"], "Duration");
|
|
80
|
+
return Result.isFailure(fields) ? Result.fail(fields.failure) : Result.succeed(fields.success);
|
|
81
|
+
};
|
|
82
|
+
const captureDuration = (input) => Effect.gen(function* () {
|
|
83
|
+
if (input === null || typeof input !== "object" || Duration.isDuration(input))
|
|
84
|
+
return input;
|
|
85
|
+
return yield* Effect.fromResult(durationData(input));
|
|
77
86
|
});
|
|
87
|
+
/**
|
|
88
|
+
* A Duration option read as caller data: a data property, own or inherited,
|
|
89
|
+
* captured without running any accessor. Rejections throw for `parsedInput`.
|
|
90
|
+
*/
|
|
91
|
+
const optionDuration = (options, field, name) => {
|
|
92
|
+
let descriptor;
|
|
93
|
+
for (let owner = options; descriptor === undefined && owner !== null; owner = Object.getPrototypeOf(owner))
|
|
94
|
+
descriptor = Object.getOwnPropertyDescriptor(owner, field);
|
|
95
|
+
if (descriptor === undefined)
|
|
96
|
+
return undefined;
|
|
97
|
+
const reject = () => ReactorError.fromCode("InvalidInput", `${name} must be a Duration of data properties`);
|
|
98
|
+
if (!("value" in descriptor))
|
|
99
|
+
throw reject();
|
|
100
|
+
const value = descriptor.value;
|
|
101
|
+
if (value === undefined)
|
|
102
|
+
return undefined;
|
|
103
|
+
if (value === null)
|
|
104
|
+
throw reject();
|
|
105
|
+
if (typeof value !== "object" || Duration.isDuration(value))
|
|
106
|
+
return value;
|
|
107
|
+
const captured = durationData(value);
|
|
108
|
+
if (Result.isFailure(captured))
|
|
109
|
+
throw reject();
|
|
110
|
+
return captured.success;
|
|
111
|
+
};
|
|
78
112
|
const requestDuration = (input, name, allowZero = false) => Effect.gen(function* () {
|
|
79
113
|
const captured = yield* captureDuration(input);
|
|
80
114
|
return yield* parsedInput(() => Duration.toMillis(duration(captured, name, { allowZero, allowNegative: name === "startBy" })), "submit").pipe(Effect.mapError((error) => invalid(error.message)));
|
|
81
115
|
});
|
|
82
|
-
/**
|
|
83
|
-
const
|
|
84
|
-
if (input ===
|
|
85
|
-
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.
|
|
86
135
|
const descriptors = Object.getOwnPropertyDescriptors(input);
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
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);
|
|
99
162
|
if (request.position !== undefined ||
|
|
100
163
|
request.before !== undefined ||
|
|
101
164
|
request.sameSessionAs !== undefined ||
|
|
102
165
|
request.continueFrom !== undefined ||
|
|
103
166
|
request.sequence !== undefined)
|
|
104
167
|
return yield* invalid("Scheduler owns placement and source selection");
|
|
105
|
-
|
|
106
|
-
|
|
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
|
|
107
173
|
? undefined
|
|
108
|
-
: yield* Effect.fromResult(ownedData(
|
|
174
|
+
: yield* Effect.fromResult(ownedData(input, ["notBefore", "startBy", "firm"], "Scheduled window"));
|
|
109
175
|
const notBeforeOffsetMs = window?.notBefore === undefined
|
|
110
176
|
? undefined
|
|
111
177
|
: yield* requestDuration(window.notBefore, "notBefore", true);
|
|
@@ -119,15 +185,37 @@ const captureItem = (input, lanes) => Effect.gen(function* () {
|
|
|
119
185
|
startByOffsetMs >= 0 &&
|
|
120
186
|
notBeforeOffsetMs > startByOffsetMs)))
|
|
121
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);
|
|
122
208
|
const rawStart = descriptors.start?.value;
|
|
123
209
|
const start = rawStart === undefined
|
|
124
210
|
? undefined
|
|
125
211
|
: yield* Effect.fromResult(ownedData(rawStart, ["_tag", "time", "late"], "Scheduled start mode"));
|
|
126
212
|
let atWallMs;
|
|
127
213
|
let late = "nextBoundary";
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
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) {
|
|
131
219
|
if (start._tag !== "At" || !Number.isFinite(start.time))
|
|
132
220
|
return yield* invalid("Scheduled start mode is invalid");
|
|
133
221
|
atWallMs = start.time;
|
|
@@ -147,20 +235,166 @@ const captureItem = (input, lanes) => Effect.gen(function* () {
|
|
|
147
235
|
request,
|
|
148
236
|
notBeforeOffsetMs,
|
|
149
237
|
startByOffsetMs,
|
|
150
|
-
firm
|
|
238
|
+
firm,
|
|
151
239
|
atWallMs,
|
|
152
240
|
late,
|
|
241
|
+
...(mode === undefined ? {} : { mode }),
|
|
242
|
+
...(cues.length === 0 ? {} : { cues }),
|
|
243
|
+
...(continuity === undefined ? {} : { continuity }),
|
|
153
244
|
fingerprint: JSON.stringify({
|
|
154
245
|
lane,
|
|
155
246
|
request,
|
|
156
247
|
notBeforeOffsetMs,
|
|
157
248
|
startByOffsetMs,
|
|
158
|
-
firm
|
|
249
|
+
firm,
|
|
159
250
|
atWallMs,
|
|
160
251
|
late,
|
|
252
|
+
mode,
|
|
253
|
+
...(cues.length === 0 ? {} : { cues }),
|
|
254
|
+
...(continuity === undefined ? {} : { continuity }),
|
|
255
|
+
}),
|
|
256
|
+
};
|
|
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 }),
|
|
161
336
|
}),
|
|
162
337
|
};
|
|
163
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
|
+
});
|
|
164
398
|
export const lineup = (filler) => ({
|
|
165
399
|
lanes: [{ name: "line" }],
|
|
166
400
|
filler,
|
|
@@ -176,13 +410,21 @@ export const makeScheduler = (options) => Effect.gen(function* () {
|
|
|
176
410
|
options.lanes.some((lane) => typeof lane.name !== "string" || lane.name.length === 0) ||
|
|
177
411
|
new Set(options.lanes.map((lane) => lane.name)).size !== options.lanes.length)
|
|
178
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");
|
|
179
417
|
return options.lanes.map((lane) => lane.name);
|
|
180
418
|
}, "makeScheduler");
|
|
181
|
-
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(() => {
|
|
182
422
|
const floorSeconds = Duration.toMillis(duration(options.filler.runway.floor, "runway floor", { allowZero: true })) / 1000;
|
|
183
423
|
const targetSeconds = Duration.toMillis(duration(options.filler.runway.target, "runway target")) / 1000;
|
|
184
424
|
const maxBuildsInFlight = options.maxBuildsInFlight ?? 1;
|
|
185
425
|
const maxHistory = options.maxHistory ?? 4096;
|
|
426
|
+
const unknownRecoveryMs = Duration.toMillis(duration(optionDuration(options, "unknownRecoveryTimeout", "Unknown recovery timeout") ??
|
|
427
|
+
"60 seconds", "unknown recovery timeout", { maximum: "10 minutes" }));
|
|
186
428
|
if (floorSeconds > targetSeconds ||
|
|
187
429
|
!Number.isSafeInteger(maxBuildsInFlight) ||
|
|
188
430
|
maxBuildsInFlight < 1 ||
|
|
@@ -191,12 +433,31 @@ export const makeScheduler = (options) => Effect.gen(function* () {
|
|
|
191
433
|
maxHistory < 0 ||
|
|
192
434
|
maxHistory > 65_536)
|
|
193
435
|
throw ReactorError.fromCode("InvalidInput", "Scheduler runway or build cap is invalid");
|
|
194
|
-
|
|
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
|
+
};
|
|
195
455
|
}, "makeScheduler");
|
|
196
|
-
const captureFiller = (index, runway, targetSeconds) => Effect.gen(function* () {
|
|
456
|
+
const captureFiller = (index, runway, durationSeconds, targetSeconds) => Effect.gen(function* () {
|
|
197
457
|
const request = yield* captureRequest(options.filler.clip({
|
|
198
458
|
index,
|
|
199
459
|
runwaySeconds: runway,
|
|
460
|
+
durationSeconds,
|
|
200
461
|
...(targetSeconds === undefined ? {} : { targetSeconds }),
|
|
201
462
|
}));
|
|
202
463
|
if (request.position !== undefined ||
|
|
@@ -209,15 +470,19 @@ export const makeScheduler = (options) => Effect.gen(function* () {
|
|
|
209
470
|
});
|
|
210
471
|
let fillerIndex = 0;
|
|
211
472
|
let fillerRetryAtMs = 0;
|
|
212
|
-
|
|
213
|
-
|
|
473
|
+
const unknownFillers = new Map();
|
|
474
|
+
const maxUnknownFillers = 4096;
|
|
475
|
+
let upcomingFiller = yield* captureFiller(0, 0, fillLengths.min);
|
|
476
|
+
const actorScope = yield* Effect.scope;
|
|
214
477
|
const inbox = yield* Queue.unbounded();
|
|
215
478
|
const commandQueue = yield* Queue.unbounded();
|
|
216
479
|
const events = yield* PubSub.unbounded();
|
|
480
|
+
const cueEvents = yield* PubSub.unbounded();
|
|
217
481
|
yield* Effect.addFinalizer(() => Effect.gen(function* () {
|
|
218
482
|
yield* Queue.shutdown(commandQueue);
|
|
219
483
|
yield* Queue.shutdown(inbox);
|
|
220
484
|
yield* PubSub.shutdown(events);
|
|
485
|
+
yield* PubSub.shutdown(cueEvents);
|
|
221
486
|
}));
|
|
222
487
|
const stateRef = yield* SubscriptionRef.make({
|
|
223
488
|
accepting: true,
|
|
@@ -226,12 +491,16 @@ export const makeScheduler = (options) => Effect.gen(function* () {
|
|
|
226
491
|
lanes: lanes.map((name) => ({ name, keys: [] })),
|
|
227
492
|
sessions: [],
|
|
228
493
|
starved: 0,
|
|
494
|
+
estimates: { build: undefined, length: 1 },
|
|
229
495
|
});
|
|
230
496
|
const initialized = yield* Deferred.make();
|
|
231
497
|
const stopped = yield* Deferred.make();
|
|
498
|
+
const settled = yield* Deferred.make();
|
|
232
499
|
const closedCall = Deferred.await(stopped).pipe(Effect.flatMap((cause) => PolicyFailure.refuse("SessionClosed", cause.message)));
|
|
233
500
|
const items = new Map();
|
|
234
501
|
const history = new Map();
|
|
502
|
+
/** Kept while any part is active or in history, for idempotency and withdrawal. */
|
|
503
|
+
const groups = new Map();
|
|
235
504
|
const owned = new Map();
|
|
236
505
|
// An event can overtake its enqueue reply. Keep only the evidence needed to
|
|
237
506
|
// decide that clip's fate until the one in-flight command returns its ID.
|
|
@@ -247,7 +516,13 @@ export const makeScheduler = (options) => Effect.gen(function* () {
|
|
|
247
516
|
let drainFailure;
|
|
248
517
|
const drainReplies = [];
|
|
249
518
|
const pendingWithdrawals = new Map();
|
|
519
|
+
/** Edit batches that have not taken effect yet. */
|
|
520
|
+
const batches = new Map();
|
|
521
|
+
let batchCount = 0;
|
|
250
522
|
const pendingBuilds = new Set();
|
|
523
|
+
// A Build whose enqueue has started and whose result the worker has not
|
|
524
|
+
// yet seen. The provider may still accept it after the scheduler ends.
|
|
525
|
+
let dispatching;
|
|
251
526
|
const pendingAtDeferrals = new Set();
|
|
252
527
|
const pendingItemRemovals = new Set();
|
|
253
528
|
const pendingFillerRemovals = new Set();
|
|
@@ -262,9 +537,56 @@ export const makeScheduler = (options) => Effect.gen(function* () {
|
|
|
262
537
|
let drained = false;
|
|
263
538
|
let observationReady = false;
|
|
264
539
|
let blockedMove;
|
|
265
|
-
|
|
540
|
+
let blockedCut;
|
|
541
|
+
// Recent measured builds: build seconds per requested second, and actual over requested length.
|
|
542
|
+
const buildSamples = [];
|
|
543
|
+
const lengthSamples = [];
|
|
544
|
+
const maxSamples = 32;
|
|
545
|
+
let lastFillerSeconds;
|
|
546
|
+
/** Filler builds sent and not yet Ready, by filler index. */
|
|
547
|
+
const fillerDispatch = new Map();
|
|
548
|
+
const measure = (atMs, requested, actual) => {
|
|
549
|
+
if (!(requested > 0))
|
|
550
|
+
return;
|
|
551
|
+
buildSamples.push((monotonicMillis(clock) - atMs) / 1000 / requested);
|
|
552
|
+
lengthSamples.push(actual / requested);
|
|
553
|
+
if (buildSamples.length > maxSamples)
|
|
554
|
+
buildSamples.shift();
|
|
555
|
+
if (lengthSamples.length > maxSamples)
|
|
556
|
+
lengthSamples.shift();
|
|
557
|
+
};
|
|
558
|
+
/** An item's build is measured once, when its clip is first seen Ready. */
|
|
559
|
+
const measureItem = (item, actual) => {
|
|
560
|
+
if (item.dispatchedAtMs === undefined)
|
|
561
|
+
return;
|
|
562
|
+
measure(item.dispatchedAtMs, item.request.durationSeconds, actual);
|
|
563
|
+
delete item.dispatchedAtMs;
|
|
564
|
+
};
|
|
565
|
+
/** Fires the cues of a playing item that are due by `untilMs`, in the order they fall. */
|
|
566
|
+
const fireCues = (entry, untilMs) => {
|
|
567
|
+
if (entry.status._tag !== "Started" || entry.startedAtMonoMs === undefined)
|
|
568
|
+
return;
|
|
569
|
+
for (const due of dueCues(entry.cues, entry.firedCues, entry.startedAtMonoMs, entry.status.durationSeconds, untilMs)) {
|
|
570
|
+
entry.firedCues.add(due);
|
|
571
|
+
PubSub.publishUnsafe(cueEvents, {
|
|
572
|
+
key: entry.key,
|
|
573
|
+
name: entry.cues[due].name,
|
|
574
|
+
at: clock.currentTimeMillisUnsafe(),
|
|
575
|
+
});
|
|
576
|
+
}
|
|
577
|
+
};
|
|
578
|
+
const emit = (entry, status, terminal = false) => Effect.gen(function* () {
|
|
579
|
+
if (ended !== undefined && !terminal)
|
|
580
|
+
return;
|
|
266
581
|
if (JSON.stringify(entry.status) === JSON.stringify(status))
|
|
267
582
|
return;
|
|
583
|
+
// A clip that ends fires only the cues that fell within the airtime it had.
|
|
584
|
+
if (status._tag === "Ended" && entry.startedAtMonoMs !== undefined)
|
|
585
|
+
fireCues(entry, entry.startedAtMonoMs +
|
|
586
|
+
1000 *
|
|
587
|
+
(status.termination === "finished" && entry.status._tag === "Started"
|
|
588
|
+
? Math.max(status.airedSeconds, entry.status.durationSeconds)
|
|
589
|
+
: status.airedSeconds));
|
|
268
590
|
entry.status = status;
|
|
269
591
|
entry.phase =
|
|
270
592
|
status._tag === "Accepted" ||
|
|
@@ -275,7 +597,20 @@ export const makeScheduler = (options) => Effect.gen(function* () {
|
|
|
275
597
|
: status._tag === "Unknown" && status.terminal !== true
|
|
276
598
|
? "Unknown"
|
|
277
599
|
: "Terminal";
|
|
278
|
-
|
|
600
|
+
const replaced = entry.replaces === undefined ? undefined : items.get(entry.replaces);
|
|
601
|
+
if (entry.group !== undefined &&
|
|
602
|
+
entry.inserted !== true &&
|
|
603
|
+
(status._tag === "Failed" || status._tag === "Dropped") &&
|
|
604
|
+
!(status._tag === "Dropped" && status.reason === "replaced") &&
|
|
605
|
+
(replaced === undefined || replaced.phase === "Terminal")) {
|
|
606
|
+
const group = groups.get(entry.group.key);
|
|
607
|
+
if (group !== undefined)
|
|
608
|
+
group.brokenAt = Math.min(group.brokenAt ?? entry.group.index, entry.group.index);
|
|
609
|
+
}
|
|
610
|
+
// Publish in the step that records it (the PubSub is unbounded): a claim
|
|
611
|
+
// from another fiber cannot land between the two, so asRun never carries
|
|
612
|
+
// a settlement ahead of a recorded status, nor omits one.
|
|
613
|
+
PubSub.publishUnsafe(events, {
|
|
279
614
|
key: entry.key,
|
|
280
615
|
at: clock.currentTimeMillisUnsafe(),
|
|
281
616
|
status,
|
|
@@ -293,31 +628,172 @@ export const makeScheduler = (options) => Effect.gen(function* () {
|
|
|
293
628
|
yield* Deferred.succeed(entry.outcomeWaiter, status);
|
|
294
629
|
}
|
|
295
630
|
});
|
|
296
|
-
|
|
631
|
+
// The claim is synchronous; interruption masking alone would still allow
|
|
632
|
+
// two closers to publish conflicting results. Join only local bookkeeping,
|
|
633
|
+
// never the worker scope (a worker can itself own terminal settlement).
|
|
634
|
+
const terminate = (terminal) => Effect.suspend(() => {
|
|
297
635
|
if (ended !== undefined)
|
|
298
|
-
return;
|
|
299
|
-
ended =
|
|
636
|
+
return Deferred.await(settled);
|
|
637
|
+
ended = terminal;
|
|
300
638
|
accepting = false;
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
639
|
+
// Wake the watchdog so an episode left armed by another owner cannot
|
|
640
|
+
// keep re-arming its timer.
|
|
641
|
+
Deferred.doneUnsafe(recoveryChanged, Exit.void);
|
|
642
|
+
return Effect.gen(function* () {
|
|
643
|
+
for (const item of items.values()) {
|
|
644
|
+
if (Exit.isFailure(terminal)) {
|
|
645
|
+
yield* Deferred.failCause(item.startedWaiter, terminal.cause);
|
|
646
|
+
yield* Deferred.failCause(item.outcomeWaiter, terminal.cause);
|
|
647
|
+
yield* Deferred.failCause(item.firstDecisiveWaiter, terminal.cause);
|
|
648
|
+
continue;
|
|
649
|
+
}
|
|
650
|
+
// emit records and publishes a status before completing its waits,
|
|
651
|
+
// and a claim can land in between: a completion can resume an
|
|
652
|
+
// observer that closes the owner, and another fiber can claim while
|
|
653
|
+
// the actor is preempted. Finish those waits from the record.
|
|
654
|
+
const recorded = item.status;
|
|
655
|
+
if (item.phase === "Started" || item.phase === "Terminal") {
|
|
656
|
+
if (isFirstDecisive(recorded))
|
|
657
|
+
yield* Deferred.succeed(item.firstDecisiveWaiter, recorded);
|
|
658
|
+
yield* Deferred.succeed(item.startedWaiter, recorded);
|
|
659
|
+
}
|
|
660
|
+
if (item.phase === "Terminal")
|
|
661
|
+
yield* Deferred.succeed(item.outcomeWaiter, recorded);
|
|
662
|
+
else
|
|
663
|
+
yield* emit(item, item.phase === "Unknown" ||
|
|
664
|
+
(item.clipId === undefined &&
|
|
665
|
+
(item.unknownAtMs !== undefined || item.key === dispatching))
|
|
666
|
+
? { _tag: "Unknown", terminal: true }
|
|
667
|
+
: { _tag: "Failed", reason: { _tag: "Scheduler", cause: terminal.value } }, true);
|
|
308
668
|
}
|
|
309
|
-
|
|
310
|
-
|
|
669
|
+
const closed = Exit.isSuccess(terminal)
|
|
670
|
+
? Exit.fail(PolicyFailure.refuse("SessionClosed", terminal.value.message))
|
|
671
|
+
: Exit.failCause(terminal.cause);
|
|
672
|
+
for (const [key, pending] of pendingWithdrawals) {
|
|
673
|
+
// A drop recorded before the claim is the withdrawal's actual result.
|
|
674
|
+
const reply = Exit.isSuccess(terminal) && items.get(key)?.status._tag === "Dropped"
|
|
675
|
+
? Exit.succeed("withdrawn")
|
|
676
|
+
: closed;
|
|
677
|
+
for (const waiter of pending.replies)
|
|
678
|
+
yield* Deferred.done(waiter, reply);
|
|
679
|
+
}
|
|
680
|
+
pendingWithdrawals.clear();
|
|
681
|
+
for (const batch of batches.values()) {
|
|
682
|
+
for (const target of batch.targets.values())
|
|
683
|
+
if (target.reply !== undefined)
|
|
684
|
+
yield* Deferred.done(target.reply, closed);
|
|
685
|
+
yield* Deferred.done(batch.committed, closed);
|
|
686
|
+
}
|
|
687
|
+
batches.clear();
|
|
688
|
+
for (const reply of drainReplies.splice(0))
|
|
689
|
+
yield* Deferred.done(reply, closed);
|
|
690
|
+
yield* SubscriptionRef.update(stateRef, (state) => ({ ...state, accepting: false }));
|
|
691
|
+
yield* Deferred.done(initialized, Exit.isFailure(terminal)
|
|
692
|
+
? Exit.failCause(terminal.cause)
|
|
693
|
+
: Exit.fail(ReactorError.fromCode("Closed", terminal.value.message)));
|
|
694
|
+
// Supervisors may close the owner immediately when this wakes them.
|
|
695
|
+
yield* Deferred.done(stopped, terminal);
|
|
696
|
+
}).pipe(Effect.onExit((exit) => Effect.gen(function* () {
|
|
697
|
+
// A bookkeeping defect must release joiners with that Cause, never
|
|
698
|
+
// leave an unfinished latch or report a partial settlement as success.
|
|
699
|
+
yield* Deferred.done(settled, exit);
|
|
700
|
+
if (Exit.isFailure(exit)) {
|
|
701
|
+
yield* Deferred.failCause(initialized, exit.cause);
|
|
702
|
+
yield* Deferred.failCause(stopped, exit.cause);
|
|
703
|
+
}
|
|
704
|
+
})));
|
|
705
|
+
}).pipe(Effect.uninterruptible);
|
|
706
|
+
const closeActor = (cause) => terminate(Exit.succeed(cause));
|
|
707
|
+
let recoveryState;
|
|
708
|
+
let capacityRecovery;
|
|
709
|
+
let recoveryDeadline;
|
|
710
|
+
let recoveryChanged = Deferred.makeUnsafe();
|
|
711
|
+
// Retain the original age while Closing hides the old source and acquisition
|
|
712
|
+
// is still pending. Only accepted evidence or a Ready replacement clears it.
|
|
713
|
+
const updateRecovery = (state = recoveryState, fresh) => {
|
|
714
|
+
if (ended !== undefined)
|
|
715
|
+
return;
|
|
716
|
+
recoveryState = state;
|
|
717
|
+
const preferred = state === undefined ? undefined : Option.getOrUndefined(state.preferredSessionId);
|
|
718
|
+
const ready = preferred !== undefined &&
|
|
719
|
+
state?.sessions.some((source) => source.sessionId === preferred && source.availability === "Ready") === true;
|
|
720
|
+
let earliest;
|
|
721
|
+
let drainDeadline;
|
|
722
|
+
const consider = (atMs, sessionId, cause, newlyObserved = false) => {
|
|
723
|
+
const deadlineMs = atMs + unknownRecoveryMs;
|
|
724
|
+
if (((!ready && newlyObserved) || sessionId === undefined || sessionId === preferred) &&
|
|
725
|
+
(earliest === undefined || deadlineMs < earliest.deadlineMs))
|
|
726
|
+
earliest = { deadlineMs, sessionId, cause };
|
|
727
|
+
if (draining && (drainDeadline === undefined || deadlineMs < drainDeadline.deadlineMs))
|
|
728
|
+
drainDeadline = { deadlineMs, sessionId, cause };
|
|
729
|
+
};
|
|
730
|
+
for (const entry of unknownFillers.values())
|
|
731
|
+
consider(entry.atMs, entry.sessionId, entry.cause);
|
|
732
|
+
for (const item of items.values())
|
|
733
|
+
if (item.phase !== "Terminal" &&
|
|
734
|
+
item.clipId === undefined &&
|
|
735
|
+
item.unknownAtMs !== undefined &&
|
|
736
|
+
item.unknownCause !== undefined)
|
|
737
|
+
consider(item.unknownAtMs, item.unknownSessionId, item.unknownCause);
|
|
738
|
+
if (fresh !== undefined)
|
|
739
|
+
consider(fresh.atMs, fresh.sessionId, fresh.cause, true);
|
|
740
|
+
if (ready &&
|
|
741
|
+
(earliest === undefined ||
|
|
742
|
+
(capacityRecovery?.sessionId !== undefined && capacityRecovery.sessionId !== preferred)))
|
|
743
|
+
capacityRecovery = undefined;
|
|
744
|
+
if (earliest !== undefined &&
|
|
745
|
+
(capacityRecovery === undefined || earliest.deadlineMs < capacityRecovery.deadlineMs))
|
|
746
|
+
capacityRecovery = earliest;
|
|
747
|
+
const next = drainDeadline !== undefined &&
|
|
748
|
+
(capacityRecovery === undefined || drainDeadline.deadlineMs < capacityRecovery.deadlineMs)
|
|
749
|
+
? drainDeadline
|
|
750
|
+
: capacityRecovery;
|
|
751
|
+
if (next?.deadlineMs !== recoveryDeadline?.deadlineMs ||
|
|
752
|
+
next?.cause !== recoveryDeadline?.cause) {
|
|
753
|
+
recoveryDeadline = next;
|
|
754
|
+
Deferred.doneUnsafe(recoveryChanged, Exit.void);
|
|
311
755
|
}
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
756
|
+
};
|
|
757
|
+
const provedUnknown = (atMs) => {
|
|
758
|
+
if (atMs !== undefined && capacityRecovery?.deadlineMs === atMs + unknownRecoveryMs)
|
|
759
|
+
capacityRecovery = undefined;
|
|
760
|
+
};
|
|
761
|
+
const watchdog = Effect.gen(function* () {
|
|
762
|
+
for (;;) {
|
|
763
|
+
// A committed result and its queued actor evidence can arrive in one
|
|
764
|
+
// turn. Coalesce those revisions before allocating a replacement timer;
|
|
765
|
+
// the original monotonic deadline remains unchanged.
|
|
766
|
+
yield* Effect.yieldNow;
|
|
767
|
+
if (ended !== undefined)
|
|
768
|
+
return;
|
|
769
|
+
recoveryChanged = Deferred.makeUnsafe();
|
|
770
|
+
const episode = recoveryDeadline;
|
|
771
|
+
if (episode === undefined) {
|
|
772
|
+
yield* Deferred.await(recoveryChanged);
|
|
773
|
+
continue;
|
|
774
|
+
}
|
|
775
|
+
yield* Deferred.await(recoveryChanged).pipe(Effect.timeoutOrElse({
|
|
776
|
+
duration: Math.max(0, episode.deadlineMs - monotonicMillis(clock)),
|
|
777
|
+
orElse: () => Effect.void,
|
|
778
|
+
}));
|
|
779
|
+
// A canceled timer may already have resumed. Recheck the retained
|
|
780
|
+
// identity without awaiting the actor, Engine state, or its permits.
|
|
781
|
+
if (ended === undefined &&
|
|
782
|
+
recoveryDeadline === episode &&
|
|
783
|
+
monotonicMillis(clock) >= episode.deadlineMs) {
|
|
784
|
+
const causes = new Set([episode.cause]);
|
|
785
|
+
for (const item of items.values())
|
|
786
|
+
if (item.unknownCause !== undefined)
|
|
787
|
+
causes.add(item.unknownCause);
|
|
788
|
+
for (const entry of unknownFillers.values())
|
|
789
|
+
causes.add(entry.cause);
|
|
790
|
+
yield* closeActor(ReactorError.fromCode("Timeout", "Scheduler unknown recovery deadline elapsed", {
|
|
791
|
+
operation: "scheduler.unknownRecovery",
|
|
792
|
+
detail: [...causes],
|
|
793
|
+
}));
|
|
794
|
+
return;
|
|
795
|
+
}
|
|
317
796
|
}
|
|
318
|
-
for (const reply of drainReplies.splice(0))
|
|
319
|
-
yield* Deferred.fail(reply, closed);
|
|
320
|
-
yield* SubscriptionRef.update(stateRef, (state) => ({ ...state, accepting: false }));
|
|
321
797
|
});
|
|
322
798
|
const prune = (state) => {
|
|
323
799
|
const active = new Set(activeIds(state));
|
|
@@ -338,6 +814,10 @@ export const makeScheduler = (options) => Effect.gen(function* () {
|
|
|
338
814
|
pendingWithdrawals.has(key) ||
|
|
339
815
|
pendingAtDeferrals.has(key) ||
|
|
340
816
|
pendingItemRemovals.has(key) ||
|
|
817
|
+
[...batches.values()].some((batch) => batch.targets.has(key)) ||
|
|
818
|
+
// A replacement dropped before it was built still links its own replacement to
|
|
819
|
+
// the item in the place.
|
|
820
|
+
[...items.values()].some((other) => other.replaces === key && other.phase !== "Terminal") ||
|
|
341
821
|
(item.clipId !== undefined && active.has(item.clipId)))
|
|
342
822
|
continue;
|
|
343
823
|
if (item.clipId !== undefined) {
|
|
@@ -349,8 +829,48 @@ export const makeScheduler = (options) => Effect.gen(function* () {
|
|
|
349
829
|
}
|
|
350
830
|
while (history.size > maxHistory)
|
|
351
831
|
history.delete(history.keys().next().value);
|
|
832
|
+
for (const [key, group] of groups)
|
|
833
|
+
if (!group.parts.some((part) => items.has(part) || history.has(part)))
|
|
834
|
+
groups.delete(key);
|
|
835
|
+
};
|
|
836
|
+
// Capture membership and public rows in one pass. A fresh token reads every
|
|
837
|
+
// source on every observation, including in-place changes to Engine arrays;
|
|
838
|
+
// frozen empty rows can be shared by every published state without treating
|
|
839
|
+
// array identity as proof.
|
|
840
|
+
const noReady = Object.freeze([]);
|
|
841
|
+
const sourceRows = new Map();
|
|
842
|
+
const projectSources = (state) => {
|
|
843
|
+
const seen = Symbol();
|
|
844
|
+
const sessions = state.sessions.map(({ sessionId }) => {
|
|
845
|
+
let cached = sourceRows.get(sessionId);
|
|
846
|
+
if (cached === undefined) {
|
|
847
|
+
cached = { empty: Object.freeze({ sessionId, ready: noReady }), seen };
|
|
848
|
+
sourceRows.set(sessionId, cached);
|
|
849
|
+
}
|
|
850
|
+
else
|
|
851
|
+
cached.seen = seen;
|
|
852
|
+
if (state.ready.length === 0)
|
|
853
|
+
return cached.empty;
|
|
854
|
+
return {
|
|
855
|
+
sessionId,
|
|
856
|
+
ready: state.ready
|
|
857
|
+
.filter((clip) => clip.sessionId === sessionId)
|
|
858
|
+
.map((clip) => {
|
|
859
|
+
const owner = owned.get(clip.clipId);
|
|
860
|
+
return owner === undefined ? "other" : owner._tag === "Filler" ? "filler" : owner.key;
|
|
861
|
+
}),
|
|
862
|
+
};
|
|
863
|
+
});
|
|
864
|
+
// Bound cached rows by snapshot size without scanning unchanged overlap.
|
|
865
|
+
// Membership still requires this observation's token, even before pruning.
|
|
866
|
+
if (sourceRows.size > state.sessions.length)
|
|
867
|
+
for (const [id, cached] of sourceRows)
|
|
868
|
+
if (cached.seen !== seen)
|
|
869
|
+
sourceRows.delete(id);
|
|
870
|
+
// Only the serialized actor uses this membership view, before its next projection.
|
|
871
|
+
return { sessions, has: (id) => sourceRows.get(id)?.seen === seen };
|
|
352
872
|
};
|
|
353
|
-
const publishState = (state) => {
|
|
873
|
+
const publishState = (state, sessions = projectSources(state).sessions) => {
|
|
354
874
|
const playing = Option.getOrUndefined(state.playing);
|
|
355
875
|
const playingOwner = playing === undefined ? undefined : owned.get(playing.clipId);
|
|
356
876
|
const playingKey = playing === undefined
|
|
@@ -372,18 +892,18 @@ export const makeScheduler = (options) => Effect.gen(function* () {
|
|
|
372
892
|
.filter((item) => item.lane === name && item.phase !== "Terminal")
|
|
373
893
|
.map((item) => item.key),
|
|
374
894
|
})),
|
|
375
|
-
sessions
|
|
376
|
-
sessionId,
|
|
377
|
-
ready: state.ready
|
|
378
|
-
.filter((clip) => clip.sessionId === sessionId)
|
|
379
|
-
.map((clip) => {
|
|
380
|
-
const owner = owned.get(clip.clipId);
|
|
381
|
-
return owner === undefined ? "other" : owner._tag === "Filler" ? "filler" : owner.key;
|
|
382
|
-
}),
|
|
383
|
-
})),
|
|
895
|
+
sessions,
|
|
384
896
|
starved,
|
|
897
|
+
estimates: estimatesFrom(buildSamples, lengthSamples),
|
|
385
898
|
});
|
|
386
899
|
};
|
|
900
|
+
/** When the filler build in flight was sent, and the next filler clip's requested length. */
|
|
901
|
+
const fillerView = () => ({
|
|
902
|
+
fillerDispatchedAtMs: fillerDispatch.size === 0
|
|
903
|
+
? undefined
|
|
904
|
+
: Math.min(...[...fillerDispatch.values()].map((sent) => sent.atMs)),
|
|
905
|
+
fillerSeconds: upcomingFiller?.durationSeconds ?? lastFillerSeconds,
|
|
906
|
+
});
|
|
387
907
|
const activeRecords = (state) => [
|
|
388
908
|
...state.queued,
|
|
389
909
|
...state.ready,
|
|
@@ -454,8 +974,8 @@ export const makeScheduler = (options) => Effect.gen(function* () {
|
|
|
454
974
|
filler.set(index, { index, clipId: record.clipId, sessionId: record.sessionId });
|
|
455
975
|
const priorIndex = fillerIndex;
|
|
456
976
|
fillerIndex = Math.max(fillerIndex, index + 1);
|
|
457
|
-
|
|
458
|
-
|
|
977
|
+
provedUnknown(unknownFillers.get(index)?.atMs);
|
|
978
|
+
unknownFillers.delete(index);
|
|
459
979
|
if (fillerIndex !== priorIndex)
|
|
460
980
|
upcomingFiller = undefined;
|
|
461
981
|
}
|
|
@@ -467,6 +987,9 @@ export const makeScheduler = (options) => Effect.gen(function* () {
|
|
|
467
987
|
if (resumed !== undefined) {
|
|
468
988
|
item.clipId = resumed.clipId;
|
|
469
989
|
item.sessionId = resumed.sessionId;
|
|
990
|
+
provedUnknown(item.unknownAtMs);
|
|
991
|
+
item.unknownAtMs = undefined;
|
|
992
|
+
item.unknownCause = undefined;
|
|
470
993
|
item.unknownSessionId = undefined;
|
|
471
994
|
owned.set(resumed.clipId, { _tag: "Item", key: item.key });
|
|
472
995
|
}
|
|
@@ -505,6 +1028,7 @@ export const makeScheduler = (options) => Effect.gen(function* () {
|
|
|
505
1028
|
}
|
|
506
1029
|
else if (state.ready.some((record) => record.clipId === clipId)) {
|
|
507
1030
|
const record = knownRecord(state, clipId);
|
|
1031
|
+
measureItem(item, record.durationSeconds);
|
|
508
1032
|
item.sessionId = record.sessionId;
|
|
509
1033
|
if (item.phase !== "Started")
|
|
510
1034
|
yield* emit(item, { _tag: "Ready", sessionId: record.sessionId });
|
|
@@ -530,7 +1054,7 @@ export const makeScheduler = (options) => Effect.gen(function* () {
|
|
|
530
1054
|
lastProcessedSnapshotSerial > item.acknowledgedAtSnapshotSerial))
|
|
531
1055
|
yield* emit(item, { _tag: "Unobserved" });
|
|
532
1056
|
}
|
|
533
|
-
|
|
1057
|
+
updateRecovery(state);
|
|
534
1058
|
});
|
|
535
1059
|
const onEvent = (event) => Effect.gen(function* () {
|
|
536
1060
|
if (event._tag === "Starved")
|
|
@@ -568,6 +1092,11 @@ export const makeScheduler = (options) => Effect.gen(function* () {
|
|
|
568
1092
|
if (owner === undefined)
|
|
569
1093
|
return;
|
|
570
1094
|
if (owner._tag === "Filler") {
|
|
1095
|
+
const sent = fillerDispatch.get(owner.index);
|
|
1096
|
+
if (event._tag === "Ready" && sent !== undefined)
|
|
1097
|
+
measure(sent.atMs, sent.seconds, event.durationSeconds);
|
|
1098
|
+
if (event._tag === "Ready" || event._tag === "Ended" || event._tag === "Failed")
|
|
1099
|
+
fillerDispatch.delete(owner.index);
|
|
571
1100
|
if (event._tag === "Started")
|
|
572
1101
|
playingStartedMs.set(event.clipId, event.atMonotonicMillis ?? monotonicMillis(clock));
|
|
573
1102
|
if (event._tag === "Ended" || event._tag === "Failed") {
|
|
@@ -588,6 +1117,7 @@ export const makeScheduler = (options) => Effect.gen(function* () {
|
|
|
588
1117
|
yield* emit(item, { _tag: "Building" });
|
|
589
1118
|
break;
|
|
590
1119
|
case "Ready": {
|
|
1120
|
+
measureItem(item, event.durationSeconds);
|
|
591
1121
|
const record = knownRecord(yield* engine.state, event.clipId);
|
|
592
1122
|
if (record !== undefined) {
|
|
593
1123
|
item.sessionId = record.sessionId;
|
|
@@ -639,9 +1169,33 @@ export const makeScheduler = (options) => Effect.gen(function* () {
|
|
|
639
1169
|
});
|
|
640
1170
|
playingStartedMs.delete(event.clipId);
|
|
641
1171
|
break;
|
|
642
|
-
case "Failed":
|
|
1172
|
+
case "Failed": {
|
|
1173
|
+
// A clip lost with a replaced session before it aired is rebuilt from the plan.
|
|
1174
|
+
if (event.lost === true &&
|
|
1175
|
+
(item.phase === "Accepted" || item.phase === "Building" || item.phase === "Ready")) {
|
|
1176
|
+
const sessionId = event.sessionId ?? item.sessionId;
|
|
1177
|
+
// Its session may still list the clip until it closes; it is not adopted again.
|
|
1178
|
+
removedIds.add(event.clipId);
|
|
1179
|
+
owned.delete(event.clipId);
|
|
1180
|
+
playingStartedMs.delete(event.clipId);
|
|
1181
|
+
delete item.clipId;
|
|
1182
|
+
delete item.dispatchedAtMs;
|
|
1183
|
+
delete item.acknowledgedAtSnapshotSerial;
|
|
1184
|
+
item.sessionId = undefined;
|
|
1185
|
+
const pending = pendingWithdrawals.get(item.key);
|
|
1186
|
+
if (pending !== undefined) {
|
|
1187
|
+
yield* emit(item, { _tag: "Dropped", reason: pending.reason });
|
|
1188
|
+
yield* finishWithdrawal(item.key, Result.succeed("withdrawn"));
|
|
1189
|
+
}
|
|
1190
|
+
else
|
|
1191
|
+
yield* emit(item, sessionId === undefined
|
|
1192
|
+
? { _tag: "Accepted" }
|
|
1193
|
+
: { _tag: "Accepted", carried: { sessionId } });
|
|
1194
|
+
break;
|
|
1195
|
+
}
|
|
643
1196
|
yield* emit(item, { _tag: "Failed", reason: { _tag: "Clip", message: event.reason } });
|
|
644
1197
|
break;
|
|
1198
|
+
}
|
|
645
1199
|
default:
|
|
646
1200
|
break;
|
|
647
1201
|
}
|
|
@@ -662,6 +1216,12 @@ export const makeScheduler = (options) => Effect.gen(function* () {
|
|
|
662
1216
|
if (command.sessionId === undefined ||
|
|
663
1217
|
Option.getOrUndefined((yield* engine.state).preferredSessionId) !== command.sessionId)
|
|
664
1218
|
return yield* PolicyFailure.refuse("RouteChanged", "The preferred source changed before dispatch");
|
|
1219
|
+
// Termination can be claimed while the route is read; its settlement
|
|
1220
|
+
// must not be followed by a new dispatch.
|
|
1221
|
+
if (ended !== undefined)
|
|
1222
|
+
return yield* PolicyFailure.refuse("SessionClosed", "The scheduler closed");
|
|
1223
|
+
if (command._tag === "Build")
|
|
1224
|
+
dispatching = command.key;
|
|
665
1225
|
return yield* engine.enqueueOnSource === undefined
|
|
666
1226
|
? engine.enqueue(command.request)
|
|
667
1227
|
: engine.enqueueOnSource(command.request, command.sessionId);
|
|
@@ -675,18 +1235,67 @@ export const makeScheduler = (options) => Effect.gen(function* () {
|
|
|
675
1235
|
return engine.move(command.clipId, command.position, "playout");
|
|
676
1236
|
case "PauseAutoplay":
|
|
677
1237
|
return engine.setAutoplay(false);
|
|
1238
|
+
case "Cut":
|
|
1239
|
+
return engine.cut(command.clipId);
|
|
678
1240
|
}
|
|
679
1241
|
};
|
|
680
1242
|
const commandWorker = Effect.gen(function* () {
|
|
681
1243
|
for (;;) {
|
|
682
|
-
const command = yield*
|
|
1244
|
+
const command = yield* takeQueue(commandQueue);
|
|
1245
|
+
if (ended !== undefined)
|
|
1246
|
+
continue;
|
|
683
1247
|
const result = yield* Effect.result(executeCommand(command));
|
|
1248
|
+
dispatching = undefined;
|
|
1249
|
+
// Capture uncertainty before actor delivery: drain may be awaiting a
|
|
1250
|
+
// permit in stopRenewal while this committed command finishes.
|
|
1251
|
+
if (ended === undefined &&
|
|
1252
|
+
Result.isFailure(result) &&
|
|
1253
|
+
result.failure.context.outcome === "unknown") {
|
|
1254
|
+
let fresh;
|
|
1255
|
+
const sessionId = engine.enqueueOnSource === undefined ||
|
|
1256
|
+
(command._tag !== "Build" && command._tag !== "BuildFiller")
|
|
1257
|
+
? undefined
|
|
1258
|
+
: command.sessionId;
|
|
1259
|
+
if (command._tag === "Build") {
|
|
1260
|
+
const item = items.get(command.key);
|
|
1261
|
+
if (item !== undefined && item.phase !== "Terminal" && item.clipId === undefined) {
|
|
1262
|
+
item.unknownAtMs ??= monotonicMillis(clock);
|
|
1263
|
+
item.unknownCause = result.failure;
|
|
1264
|
+
item.unknownSessionId = sessionId;
|
|
1265
|
+
fresh = { sessionId, atMs: item.unknownAtMs, cause: result.failure };
|
|
1266
|
+
}
|
|
1267
|
+
}
|
|
1268
|
+
else if (command._tag === "BuildFiller" &&
|
|
1269
|
+
!filler.has(command.index) &&
|
|
1270
|
+
!unknownFillers.has(command.index)) {
|
|
1271
|
+
fresh = { sessionId, atMs: monotonicMillis(clock), cause: result.failure };
|
|
1272
|
+
unknownFillers.set(command.index, { ...fresh, index: command.index });
|
|
1273
|
+
}
|
|
1274
|
+
updateRecovery(recoveryState, fresh);
|
|
1275
|
+
}
|
|
684
1276
|
yield* Queue.offer(inbox, { _tag: "CommandDone", command, result });
|
|
685
1277
|
}
|
|
686
1278
|
});
|
|
687
1279
|
const sendCommand = (command) => Effect.gen(function* () {
|
|
688
|
-
if (
|
|
1280
|
+
if (ended !== undefined)
|
|
1281
|
+
return;
|
|
1282
|
+
if (command._tag === "Build") {
|
|
689
1283
|
pendingBuilds.add(command.key);
|
|
1284
|
+
const item = items.get(command.key);
|
|
1285
|
+
if (item !== undefined)
|
|
1286
|
+
item.dispatchedAtMs = monotonicMillis(clock);
|
|
1287
|
+
}
|
|
1288
|
+
if (command._tag === "BuildFiller") {
|
|
1289
|
+
lastFillerSeconds = command.request.durationSeconds;
|
|
1290
|
+
fillerDispatch.set(command.index, {
|
|
1291
|
+
atMs: monotonicMillis(clock),
|
|
1292
|
+
seconds: command.request.durationSeconds,
|
|
1293
|
+
});
|
|
1294
|
+
// In-flight filler is bounded like the samples it feeds.
|
|
1295
|
+
for (const index of fillerDispatch.keys())
|
|
1296
|
+
if (fillerDispatch.size > maxSamples)
|
|
1297
|
+
fillerDispatch.delete(index);
|
|
1298
|
+
}
|
|
690
1299
|
if (command._tag === "DeferAt")
|
|
691
1300
|
pendingAtDeferrals.add(command.key);
|
|
692
1301
|
commandCount++;
|
|
@@ -714,10 +1323,16 @@ export const makeScheduler = (options) => Effect.gen(function* () {
|
|
|
714
1323
|
pendingItemRemovals.add(item.key);
|
|
715
1324
|
yield* sendCommand({ _tag: "RemoveItem", key: item.key, clipId: item.clipId, reason });
|
|
716
1325
|
});
|
|
1326
|
+
/** A withdrawal's answer for an item that has started or settled another way. */
|
|
1327
|
+
const settledOutcome = (item) => item.phase === "Started" || item.status._tag === "Ended" || item.status._tag === "Unobserved"
|
|
1328
|
+
? "already-started"
|
|
1329
|
+
: item.status._tag === "Dropped"
|
|
1330
|
+
? "withdrawn"
|
|
1331
|
+
: "not-found";
|
|
717
1332
|
const requestWithdrawal = (item, reason, reply) => Effect.gen(function* () {
|
|
718
1333
|
if (item.phase === "Started" || item.phase === "Terminal") {
|
|
719
1334
|
if (reply !== undefined)
|
|
720
|
-
yield* Deferred.succeed(reply, item
|
|
1335
|
+
yield* Deferred.succeed(reply, settledOutcome(item));
|
|
721
1336
|
return;
|
|
722
1337
|
}
|
|
723
1338
|
const pending = pendingWithdrawals.get(item.key);
|
|
@@ -740,22 +1355,12 @@ export const makeScheduler = (options) => Effect.gen(function* () {
|
|
|
740
1355
|
});
|
|
741
1356
|
const applyPolicy = (action, state) => Effect.gen(function* () {
|
|
742
1357
|
switch (action._tag) {
|
|
743
|
-
case "Withdraw": {
|
|
744
|
-
const item = items.get(action.key);
|
|
745
|
-
if (item !== undefined)
|
|
746
|
-
yield* requestWithdrawal(item, action.reason);
|
|
747
|
-
break;
|
|
748
|
-
}
|
|
749
1358
|
case "DeferAt":
|
|
750
1359
|
if (canRetryRemoval(action.clipId))
|
|
751
1360
|
yield* sendCommand({ _tag: "DeferAt", key: action.key, clipId: action.clipId });
|
|
752
1361
|
break;
|
|
753
|
-
case "
|
|
754
|
-
|
|
755
|
-
if (!pendingFillerRemovals.has(clipId) && canRetryRemoval(clipId)) {
|
|
756
|
-
pendingFillerRemovals.add(clipId);
|
|
757
|
-
yield* sendCommand({ _tag: "RemoveFiller", clipId });
|
|
758
|
-
}
|
|
1362
|
+
case "Cut":
|
|
1363
|
+
yield* sendCommand({ _tag: "Cut", clipId: action.clipId });
|
|
759
1364
|
break;
|
|
760
1365
|
case "Order": {
|
|
761
1366
|
const signature = state.ready.map((record) => record.clipId).join(",") +
|
|
@@ -776,7 +1381,7 @@ export const makeScheduler = (options) => Effect.gen(function* () {
|
|
|
776
1381
|
const item = items.get(action.key);
|
|
777
1382
|
if (item?.phase !== "Accepted")
|
|
778
1383
|
break;
|
|
779
|
-
const prepared = yield* Effect.result(keyedRequest(item.request, item.key));
|
|
1384
|
+
const prepared = yield* Effect.result(keyedRequest(item.request, item.key, action.continueFrom));
|
|
780
1385
|
if (Result.isFailure(prepared)) {
|
|
781
1386
|
yield* emit(item, {
|
|
782
1387
|
_tag: "Failed",
|
|
@@ -793,10 +1398,16 @@ export const makeScheduler = (options) => Effect.gen(function* () {
|
|
|
793
1398
|
break;
|
|
794
1399
|
}
|
|
795
1400
|
case "BuildFiller": {
|
|
1401
|
+
if (unknownFillers.size >= maxUnknownFillers) {
|
|
1402
|
+
yield* closeActor(ReactorError.fromCode("Overflow", "Scheduler uncertain filler ledger is full", {
|
|
1403
|
+
operation: "scheduler.unknownRecovery",
|
|
1404
|
+
}));
|
|
1405
|
+
break;
|
|
1406
|
+
}
|
|
796
1407
|
const captured = upcomingFiller === undefined
|
|
797
1408
|
? yield* Effect.result(captureFiller(fillerIndex, runwaySeconds(state, monotonicMillis(clock), playingStartedMs, owned, [
|
|
798
1409
|
...items.values(),
|
|
799
|
-
]), action.targetSeconds))
|
|
1410
|
+
]), action.durationSeconds, action.targetSeconds))
|
|
800
1411
|
: Result.succeed(upcomingFiller);
|
|
801
1412
|
if (Result.isFailure(captured)) {
|
|
802
1413
|
yield* closeActor(captured.failure);
|
|
@@ -862,17 +1473,21 @@ export const makeScheduler = (options) => Effect.gen(function* () {
|
|
|
862
1473
|
item.sessionId =
|
|
863
1474
|
knownRecord(yield* engine.state, clipId)?.sessionId ??
|
|
864
1475
|
(engine.enqueueOnSource === undefined ? undefined : command.sessionId);
|
|
1476
|
+
provedUnknown(item.unknownAtMs);
|
|
1477
|
+
item.unknownAtMs = undefined;
|
|
865
1478
|
item.unknownSessionId = undefined;
|
|
866
1479
|
item.unknownCause = undefined;
|
|
867
1480
|
yield* replayEarlyClipEvents(clipId);
|
|
1481
|
+
// Evidence that overtook the reply may already have carried the clip away
|
|
1482
|
+
// with a lost session; the item then waits to be rebuilt, not Building.
|
|
1483
|
+
const carried = item.clipId === undefined;
|
|
868
1484
|
const pending = pendingWithdrawals.get(item.key);
|
|
869
|
-
if (
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
yield* finishWithdrawal(item.key, Result.succeed("not-found"));
|
|
1485
|
+
if (carried) {
|
|
1486
|
+
// The carry settled any withdrawal that was waiting.
|
|
1487
|
+
}
|
|
1488
|
+
else if (pending !== undefined) {
|
|
1489
|
+
if (item.phase === "Started" || item.phase === "Terminal")
|
|
1490
|
+
yield* finishWithdrawal(item.key, Result.succeed(settledOutcome(item)));
|
|
876
1491
|
else
|
|
877
1492
|
yield* sendItemRemoval(item, pending.reason);
|
|
878
1493
|
}
|
|
@@ -881,6 +1496,8 @@ export const makeScheduler = (options) => Effect.gen(function* () {
|
|
|
881
1496
|
yield* observed(yield* engine.state);
|
|
882
1497
|
}
|
|
883
1498
|
else if (result.failure.context.outcome === "unknown") {
|
|
1499
|
+
// Its time to Ready spans the uncertainty, so it is never measured.
|
|
1500
|
+
delete item.dispatchedAtMs;
|
|
884
1501
|
if (item.clipId === undefined) {
|
|
885
1502
|
// A generic engine may re-route between the state read and dispatch.
|
|
886
1503
|
// Only an owner-fenced enqueue can attribute its unknown result.
|
|
@@ -904,6 +1521,7 @@ export const makeScheduler = (options) => Effect.gen(function* () {
|
|
|
904
1521
|
});
|
|
905
1522
|
else
|
|
906
1523
|
item.retryAtMs = monotonicMillis(clock) + 1_000;
|
|
1524
|
+
delete item.dispatchedAtMs;
|
|
907
1525
|
}
|
|
908
1526
|
break;
|
|
909
1527
|
}
|
|
@@ -928,13 +1546,10 @@ export const makeScheduler = (options) => Effect.gen(function* () {
|
|
|
928
1546
|
yield* replayEarlyClipEvents(clipId);
|
|
929
1547
|
}
|
|
930
1548
|
else if (result.failure.context.outcome === "unknown") {
|
|
931
|
-
|
|
932
|
-
unknownFiller = {
|
|
933
|
-
index: command.index,
|
|
934
|
-
sessionId: engine.enqueueOnSource === undefined ? undefined : command.sessionId,
|
|
935
|
-
};
|
|
1549
|
+
// The worker already retained this identity and its original age.
|
|
936
1550
|
}
|
|
937
1551
|
else {
|
|
1552
|
+
fillerDispatch.delete(command.index);
|
|
938
1553
|
fillerRetryAtMs = monotonicMillis(clock) + 1_000;
|
|
939
1554
|
if (result.failure.context.outcome === "replied") {
|
|
940
1555
|
fillerIndex = Math.max(fillerIndex, command.index + 1);
|
|
@@ -967,7 +1582,9 @@ export const makeScheduler = (options) => Effect.gen(function* () {
|
|
|
967
1582
|
removalUnknownAtSerial.set(command.clipId, snapshotAcquiredSerial);
|
|
968
1583
|
if (item?.phase === "Started")
|
|
969
1584
|
yield* finishWithdrawal(command.key, Result.succeed("already-started"));
|
|
970
|
-
|
|
1585
|
+
// A refusal is not an outcome: the withdrawal stays pending and is retried.
|
|
1586
|
+
// An unknown removal may already have taken effect, so it ends the wait.
|
|
1587
|
+
else if (result.failure.context.outcome === "unknown")
|
|
971
1588
|
yield* finishWithdrawal(command.key, Result.fail(result.failure));
|
|
972
1589
|
}
|
|
973
1590
|
break;
|
|
@@ -1025,6 +1642,10 @@ export const makeScheduler = (options) => Effect.gen(function* () {
|
|
|
1025
1642
|
case "Order":
|
|
1026
1643
|
blockedMove = Result.isFailure(result) ? command.signature : undefined;
|
|
1027
1644
|
break;
|
|
1645
|
+
case "Cut":
|
|
1646
|
+
if (Result.isFailure(result))
|
|
1647
|
+
blockedCut = command.clipId;
|
|
1648
|
+
break;
|
|
1028
1649
|
case "PauseAutoplay":
|
|
1029
1650
|
if (Result.isFailure(result)) {
|
|
1030
1651
|
drainFailure = result.failure;
|
|
@@ -1035,90 +1656,63 @@ export const makeScheduler = (options) => Effect.gen(function* () {
|
|
|
1035
1656
|
break;
|
|
1036
1657
|
}
|
|
1037
1658
|
});
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
const
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
(item.phase === "Ready" && item.atMs !== undefined && item.atMs > nowMs));
|
|
1049
|
-
const drainPending = (state) => Effect.gen(function* () {
|
|
1050
|
-
if (!draining)
|
|
1051
|
-
return;
|
|
1052
|
-
const playingId = Option.getOrUndefined(state.playing)?.clipId;
|
|
1053
|
-
for (const item of items.values()) {
|
|
1054
|
-
if (finishAccepted)
|
|
1055
|
-
break;
|
|
1056
|
-
if (item.phase === "Terminal" || item.phase === "Started")
|
|
1057
|
-
continue;
|
|
1058
|
-
if (item.clipId === playingId)
|
|
1659
|
+
const pendingBatches = () => [...batches.values()].map((batch) => ({
|
|
1660
|
+
id: batch.id,
|
|
1661
|
+
waitFor: batch.waitFor,
|
|
1662
|
+
targets: [...batch.targets].map(([key, target]) => ({ key, reason: target.reason })),
|
|
1663
|
+
}));
|
|
1664
|
+
/** The reply a batch keeps for its withdrawal of `key`, taken once. */
|
|
1665
|
+
const takeBatchReply = (key) => {
|
|
1666
|
+
for (const batch of batches.values()) {
|
|
1667
|
+
const target = batch.targets.get(key);
|
|
1668
|
+
if (target === undefined)
|
|
1059
1669
|
continue;
|
|
1060
|
-
|
|
1670
|
+
batch.targets.delete(key);
|
|
1671
|
+
return target.reply;
|
|
1061
1672
|
}
|
|
1062
|
-
|
|
1673
|
+
return undefined;
|
|
1674
|
+
};
|
|
1675
|
+
/** A batch takes effect: what it withdraws goes at once, and its replacements take over. */
|
|
1676
|
+
const commitBatch = (id) => Effect.gen(function* () {
|
|
1677
|
+
const batch = batches.get(id);
|
|
1678
|
+
if (batch === undefined)
|
|
1063
1679
|
return;
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
yield*
|
|
1071
|
-
}
|
|
1680
|
+
batches.delete(id);
|
|
1681
|
+
for (const [key, target] of batch.targets) {
|
|
1682
|
+
const item = items.get(key);
|
|
1683
|
+
if (item !== undefined)
|
|
1684
|
+
yield* requestWithdrawal(item, target.reason, target.reply);
|
|
1685
|
+
else if (target.reply !== undefined)
|
|
1686
|
+
yield* Deferred.succeed(target.reply, "not-found");
|
|
1072
1687
|
}
|
|
1688
|
+
yield* Deferred.succeed(batch.committed, undefined);
|
|
1073
1689
|
});
|
|
1074
|
-
const projectedStartMs = (state, lane, nowMs) => {
|
|
1075
|
-
const playing = Option.getOrUndefined(state.playing);
|
|
1076
|
-
const playingRecord = playing === undefined ? undefined : Option.getOrUndefined(playing.record);
|
|
1077
|
-
const started = playing === undefined ? undefined : playingStartedMs.get(playing.clipId);
|
|
1078
|
-
const restMs = playingRecord === undefined || started === undefined
|
|
1079
|
-
? 0
|
|
1080
|
-
: Math.max(0, playingRecord.durationSeconds * 1000 - (nowMs - started));
|
|
1081
|
-
const laneRank = lanes.indexOf(lane);
|
|
1082
|
-
const preferred = Option.getOrUndefined(state.preferredSessionId);
|
|
1083
|
-
const aheadMs = state.ready.reduce((total, record) => {
|
|
1084
|
-
if (record.sessionId !== preferred)
|
|
1085
|
-
return total;
|
|
1086
|
-
const owner = owned.get(record.clipId);
|
|
1087
|
-
if (owner?._tag === "Filler")
|
|
1088
|
-
return total;
|
|
1089
|
-
if (owner?._tag === "Item") {
|
|
1090
|
-
const prior = items.get(owner.key);
|
|
1091
|
-
if (prior !== undefined && lanes.indexOf(prior.lane) > laneRank)
|
|
1092
|
-
return total;
|
|
1093
|
-
}
|
|
1094
|
-
return total + record.durationSeconds * 1000;
|
|
1095
|
-
}, 0);
|
|
1096
|
-
return nowMs + restMs + aheadMs;
|
|
1097
|
-
};
|
|
1098
1690
|
const reconcile = Effect.gen(function* () {
|
|
1099
1691
|
const state = yield* engine.state;
|
|
1692
|
+
if (ended !== undefined)
|
|
1693
|
+
return;
|
|
1100
1694
|
prune(state);
|
|
1101
1695
|
refreshAnchors();
|
|
1696
|
+
const sources = projectSources(state);
|
|
1102
1697
|
if (!observationReady) {
|
|
1103
|
-
yield* publishState(state);
|
|
1698
|
+
yield* publishState(state, sources.sessions);
|
|
1104
1699
|
return;
|
|
1105
1700
|
}
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
|
|
1113
|
-
|
|
1114
|
-
|
|
1115
|
-
|
|
1116
|
-
|
|
1117
|
-
fillerIndex <= uncertain.index) {
|
|
1118
|
-
|
|
1119
|
-
|
|
1120
|
-
|
|
1121
|
-
upcomingFiller = undefined;
|
|
1701
|
+
const nowMs = monotonicMillis(clock);
|
|
1702
|
+
for (const item of items.values())
|
|
1703
|
+
if (item.phase === "Started")
|
|
1704
|
+
fireCues(item, nowMs);
|
|
1705
|
+
for (const uncertain of unknownFillers.values()) {
|
|
1706
|
+
const sourceRetired = uncertain.sessionId !== undefined && !sources.has(uncertain.sessionId);
|
|
1707
|
+
if (sourceRetired)
|
|
1708
|
+
unknownFillers.delete(uncertain.index);
|
|
1709
|
+
if (sourceRetired ||
|
|
1710
|
+
(uncertain.sessionId !== undefined &&
|
|
1711
|
+
Option.getOrUndefined(state.preferredSessionId) !== uncertain.sessionId)) {
|
|
1712
|
+
if (fillerIndex <= uncertain.index) {
|
|
1713
|
+
fillerIndex = uncertain.index + 1;
|
|
1714
|
+
upcomingFiller = undefined;
|
|
1715
|
+
}
|
|
1122
1716
|
}
|
|
1123
1717
|
}
|
|
1124
1718
|
for (const [key, pending] of pendingWithdrawals) {
|
|
@@ -1127,6 +1721,10 @@ export const makeScheduler = (options) => Effect.gen(function* () {
|
|
|
1127
1721
|
continue;
|
|
1128
1722
|
if (item.phase === "Started")
|
|
1129
1723
|
yield* finishWithdrawal(key, Result.succeed("already-started"));
|
|
1724
|
+
// A withdrawal still being retried when its item settles another way. Terminal
|
|
1725
|
+
// Unknown keeps its uncertain result from the retirement or terminal settlement.
|
|
1726
|
+
else if (item.phase === "Terminal" && item.status._tag !== "Unknown")
|
|
1727
|
+
yield* finishWithdrawal(key, Result.succeed(settledOutcome(item)));
|
|
1130
1728
|
else if (item.clipId !== undefined)
|
|
1131
1729
|
yield* sendItemRemoval(item, pending.reason);
|
|
1132
1730
|
}
|
|
@@ -1134,11 +1732,21 @@ export const makeScheduler = (options) => Effect.gen(function* () {
|
|
|
1134
1732
|
if (item.phase === "Unknown" &&
|
|
1135
1733
|
item.clipId === undefined &&
|
|
1136
1734
|
item.unknownSessionId !== undefined &&
|
|
1137
|
-
!
|
|
1735
|
+
!sources.has(item.unknownSessionId))
|
|
1138
1736
|
yield* resolveRetiredUnknown(item);
|
|
1139
|
-
|
|
1140
|
-
|
|
1141
|
-
|
|
1737
|
+
// After retirement, so a deadline no longer outlives the uncertainty that
|
|
1738
|
+
// set it until the next message.
|
|
1739
|
+
updateRecovery(state);
|
|
1740
|
+
const preferred = Option.getOrUndefined(state.preferredSessionId);
|
|
1741
|
+
let unknownFillerCount = 0;
|
|
1742
|
+
for (const entry of unknownFillers.values())
|
|
1743
|
+
if (entry.sessionId === undefined || entry.sessionId === preferred)
|
|
1744
|
+
unknownFillerCount++;
|
|
1745
|
+
const brokenGroups = new Map();
|
|
1746
|
+
for (const [key, group] of groups)
|
|
1747
|
+
if (group.brokenAt !== undefined)
|
|
1748
|
+
brokenGroups.set(key, group.brokenAt);
|
|
1749
|
+
const decide = () => plan({
|
|
1142
1750
|
engine: state,
|
|
1143
1751
|
items: [...items.values()],
|
|
1144
1752
|
owned,
|
|
@@ -1149,15 +1757,40 @@ export const makeScheduler = (options) => Effect.gen(function* () {
|
|
|
1149
1757
|
targetSeconds,
|
|
1150
1758
|
refillActive,
|
|
1151
1759
|
maxBuildsInFlight,
|
|
1152
|
-
accepting
|
|
1153
|
-
|
|
1760
|
+
accepting,
|
|
1761
|
+
drain: draining ? (finishAccepted ? "accepted" : "playing") : undefined,
|
|
1154
1762
|
fillerRetryAtMs,
|
|
1155
|
-
|
|
1156
|
-
fillerUnknownSessionId: unknownFiller?.sessionId,
|
|
1763
|
+
unknownFillerCount,
|
|
1157
1764
|
blockedMove,
|
|
1765
|
+
withdrawing: new Set(pendingWithdrawals.keys()),
|
|
1766
|
+
dispatched: pendingBuilds,
|
|
1767
|
+
brokenGroups,
|
|
1768
|
+
batches: pendingBatches(),
|
|
1769
|
+
estimates: estimatesFrom(buildSamples, lengthSamples),
|
|
1770
|
+
...fillerView(),
|
|
1771
|
+
cutLanes,
|
|
1772
|
+
fillLengths,
|
|
1773
|
+
blockedCut,
|
|
1158
1774
|
});
|
|
1775
|
+
let decision = decide();
|
|
1776
|
+
// A commit changes what is withdrawn and what takes over, so the plan is read again.
|
|
1777
|
+
if (decision.commit.length > 0) {
|
|
1778
|
+
for (const id of decision.commit)
|
|
1779
|
+
yield* commitBatch(id);
|
|
1780
|
+
decision = decide();
|
|
1781
|
+
}
|
|
1159
1782
|
refillActive = decision.refillActive;
|
|
1160
|
-
|
|
1783
|
+
for (const { key, reason } of decision.withdraw) {
|
|
1784
|
+
const item = items.get(key);
|
|
1785
|
+
if (item !== undefined)
|
|
1786
|
+
yield* requestWithdrawal(item, reason, takeBatchReply(key));
|
|
1787
|
+
}
|
|
1788
|
+
for (const clipId of decision.withdrawFiller)
|
|
1789
|
+
if (!pendingFillerRemovals.has(clipId) && canRetryRemoval(clipId)) {
|
|
1790
|
+
pendingFillerRemovals.add(clipId);
|
|
1791
|
+
yield* sendCommand({ _tag: "RemoveFiller", clipId });
|
|
1792
|
+
}
|
|
1793
|
+
yield* publishState(state, sources.sessions);
|
|
1161
1794
|
if ((!draining || finishAccepted) && commandCount === 0 && decision.action !== undefined)
|
|
1162
1795
|
yield* applyPolicy(decision.action, state);
|
|
1163
1796
|
if (draining &&
|
|
@@ -1173,105 +1806,585 @@ export const makeScheduler = (options) => Effect.gen(function* () {
|
|
|
1173
1806
|
commandCount === 0 &&
|
|
1174
1807
|
[...items.values()].every((item) => item.phase === "Terminal") &&
|
|
1175
1808
|
pendingWithdrawals.size === 0 &&
|
|
1176
|
-
|
|
1809
|
+
unknownFillers.size === 0) {
|
|
1177
1810
|
const after = yield* engine.state;
|
|
1178
1811
|
if (Option.isNone(after.playing) && !activeIds(after).some((clipId) => owned.has(clipId))) {
|
|
1179
1812
|
drained = true;
|
|
1813
|
+
// A truthful completed drain no longer needs a replacement to resume
|
|
1814
|
+
// service, even when retirement left a capacity episode behind.
|
|
1815
|
+
capacityRecovery = undefined;
|
|
1816
|
+
updateRecovery(after);
|
|
1180
1817
|
const replies = drainReplies.splice(0);
|
|
1181
1818
|
for (const reply of replies)
|
|
1182
1819
|
yield* Deferred.succeed(reply, undefined);
|
|
1183
1820
|
}
|
|
1184
1821
|
}
|
|
1185
1822
|
});
|
|
1823
|
+
/** Registers a captured item, without yielding; its caller publishes the Accepted evidence. */
|
|
1824
|
+
const accept = (captured, nowMs, startByMs, group, place) => {
|
|
1825
|
+
const startedWaiter = Deferred.makeUnsafe();
|
|
1826
|
+
const outcomeWaiter = Deferred.makeUnsafe();
|
|
1827
|
+
const firstDecisiveWaiter = Deferred.makeUnsafe();
|
|
1828
|
+
const item = {
|
|
1829
|
+
key: captured.key,
|
|
1830
|
+
lane: captured.lane,
|
|
1831
|
+
request: captured.request,
|
|
1832
|
+
fingerprint: captured.fingerprint,
|
|
1833
|
+
admission: place === undefined ? ++admission : place.admission,
|
|
1834
|
+
seconds: captured.request.durationSeconds,
|
|
1835
|
+
phase: "Accepted",
|
|
1836
|
+
status: { _tag: "Accepted" },
|
|
1837
|
+
...(place === undefined
|
|
1838
|
+
? captured.notBeforeOffsetMs === undefined
|
|
1839
|
+
? {}
|
|
1840
|
+
: { notBeforeMs: nowMs + captured.notBeforeOffsetMs }
|
|
1841
|
+
: place.notBeforeMs === undefined
|
|
1842
|
+
? {}
|
|
1843
|
+
: { notBeforeMs: place.notBeforeMs }),
|
|
1844
|
+
...(place?.replaces === undefined
|
|
1845
|
+
? {}
|
|
1846
|
+
: { replaces: place.replaces, generation: place.generation ?? 1 }),
|
|
1847
|
+
...(place?.inserted === true ? { inserted: true } : {}),
|
|
1848
|
+
...(startByMs === undefined ? {} : { startByMs }),
|
|
1849
|
+
firm: captured.firm,
|
|
1850
|
+
...(captured.atWallMs === undefined
|
|
1851
|
+
? {}
|
|
1852
|
+
: {
|
|
1853
|
+
atWallMs: captured.atWallMs,
|
|
1854
|
+
atMs: nowMs + captured.atWallMs - clock.currentTimeMillisUnsafe(),
|
|
1855
|
+
}),
|
|
1856
|
+
late: captured.late,
|
|
1857
|
+
cues: captured.cues ?? [],
|
|
1858
|
+
firedCues: new Set(),
|
|
1859
|
+
...(captured.continuity === undefined ? {} : { continuity: captured.continuity }),
|
|
1860
|
+
...(captured.mode === "asap" ? { asap: true } : {}),
|
|
1861
|
+
...(captured.mode === "manual" ? { held: true } : {}),
|
|
1862
|
+
...(group === undefined ? {} : { group }),
|
|
1863
|
+
sessionId: undefined,
|
|
1864
|
+
unknownSessionId: undefined,
|
|
1865
|
+
unknownCause: undefined,
|
|
1866
|
+
unknownAtMs: undefined,
|
|
1867
|
+
handle: {
|
|
1868
|
+
key: captured.key,
|
|
1869
|
+
started: Deferred.await(startedWaiter),
|
|
1870
|
+
outcome: Deferred.await(outcomeWaiter),
|
|
1871
|
+
firstDecisive: Deferred.await(firstDecisiveWaiter),
|
|
1872
|
+
},
|
|
1873
|
+
startedWaiter,
|
|
1874
|
+
outcomeWaiter,
|
|
1875
|
+
firstDecisiveWaiter,
|
|
1876
|
+
};
|
|
1877
|
+
items.set(item.key, item);
|
|
1878
|
+
return item;
|
|
1879
|
+
};
|
|
1880
|
+
/**
|
|
1881
|
+
* One reply for withdrawals across a group's parts. A group key reports
|
|
1882
|
+
* `withdrawn` if any part was; a part key reports that part's own outcome,
|
|
1883
|
+
* while the parts after it are withdrawn too.
|
|
1884
|
+
*/
|
|
1885
|
+
const settleWithdrawals = (replies, reply, wholeGroup) => Effect.gen(function* () {
|
|
1886
|
+
const outcomes = [];
|
|
1887
|
+
for (const pending of replies) {
|
|
1888
|
+
const result = yield* Effect.result(Deferred.await(pending));
|
|
1889
|
+
if (Result.isFailure(result)) {
|
|
1890
|
+
yield* Deferred.fail(reply, result.failure);
|
|
1891
|
+
return;
|
|
1892
|
+
}
|
|
1893
|
+
outcomes.push(result.success);
|
|
1894
|
+
}
|
|
1895
|
+
yield* Deferred.succeed(reply, wholeGroup && outcomes.includes("withdrawn") ? "withdrawn" : (outcomes[0] ?? "not-found"));
|
|
1896
|
+
});
|
|
1897
|
+
/**
|
|
1898
|
+
* The place an insert takes beside its anchor, or why it cannot: the anchor's lane, an
|
|
1899
|
+
* admission between the anchor and its neighbour in that lane, and a group place when
|
|
1900
|
+
* the insert falls between two parts of a group.
|
|
1901
|
+
*/
|
|
1902
|
+
const placeInsert = (insert) => {
|
|
1903
|
+
const live = (entry) => entry !== undefined && entry.phase !== "Terminal" && entry.replacedBy === undefined;
|
|
1904
|
+
const named = groups.get(insert.anchor);
|
|
1905
|
+
let anchor;
|
|
1906
|
+
if (named === undefined)
|
|
1907
|
+
anchor = items.get(insert.anchor);
|
|
1908
|
+
else {
|
|
1909
|
+
const parts = named.parts
|
|
1910
|
+
.map((key) => items.get(key))
|
|
1911
|
+
.filter(live)
|
|
1912
|
+
.sort((a, b) => a.group.index - b.group.index);
|
|
1913
|
+
if (insert.side === "before" &&
|
|
1914
|
+
(parts.some((part) => part.phase === "Started") || parts[0]?.group?.index !== 0))
|
|
1915
|
+
return "The anchor group already started";
|
|
1916
|
+
anchor = insert.side === "before" ? parts[0] : parts.at(-1);
|
|
1917
|
+
}
|
|
1918
|
+
if (!live(anchor))
|
|
1919
|
+
return "Nothing queued under the anchor key";
|
|
1920
|
+
if (anchor.phase === "Unknown")
|
|
1921
|
+
return "The anchor's admission is uncertain";
|
|
1922
|
+
if (anchor.held === true)
|
|
1923
|
+
return "The anchor is held until it is released";
|
|
1924
|
+
if (insert.side === "before" && anchor.phase === "Started")
|
|
1925
|
+
return "The anchor already started";
|
|
1926
|
+
const neighbours = [...items.values()]
|
|
1927
|
+
.filter((entry) => entry.lane === anchor.lane && entry.phase !== "Terminal")
|
|
1928
|
+
.map((entry) => entry.admission);
|
|
1929
|
+
const bound = insert.side === "before"
|
|
1930
|
+
? Math.max(...neighbours.filter((value) => value < anchor.admission))
|
|
1931
|
+
: Math.min(...neighbours.filter((value) => value > anchor.admission));
|
|
1932
|
+
const admission = Number.isFinite(bound)
|
|
1933
|
+
? (anchor.admission + bound) / 2
|
|
1934
|
+
: anchor.admission + (insert.side === "before" ? -0.5 : 0.5);
|
|
1935
|
+
if (admission === anchor.admission || admission === bound)
|
|
1936
|
+
return "No room is left to insert at that place";
|
|
1937
|
+
let group;
|
|
1938
|
+
if (anchor.group !== undefined) {
|
|
1939
|
+
const indexes = (groups.get(anchor.group.key)?.parts ?? [])
|
|
1940
|
+
.map((key) => items.get(key)?.group?.index)
|
|
1941
|
+
.filter((value) => value !== undefined);
|
|
1942
|
+
const index = anchor.group.index;
|
|
1943
|
+
const neighbour = insert.side === "before"
|
|
1944
|
+
? Math.max(...indexes.filter((value) => value < index))
|
|
1945
|
+
: Math.min(...indexes.filter((value) => value > index));
|
|
1946
|
+
// Before the first part or after the last one, the insert stays outside the group.
|
|
1947
|
+
// Once parts have aired and settled, the place before the first remaining part is
|
|
1948
|
+
// still inside the group.
|
|
1949
|
+
const between = insert.side === "before"
|
|
1950
|
+
? index > 0
|
|
1951
|
+
? (Number.isFinite(neighbour) ? neighbour : index - 1) + index
|
|
1952
|
+
: undefined
|
|
1953
|
+
: Number.isFinite(neighbour)
|
|
1954
|
+
? index + neighbour
|
|
1955
|
+
: undefined;
|
|
1956
|
+
if (between !== undefined) {
|
|
1957
|
+
if (between / 2 === index || between / 2 === neighbour)
|
|
1958
|
+
return "No room is left to insert at that place";
|
|
1959
|
+
group = { key: anchor.group.key, index: between / 2 };
|
|
1960
|
+
}
|
|
1961
|
+
}
|
|
1962
|
+
const timing = {
|
|
1963
|
+
...(anchor.atWallMs === undefined ? {} : { atWallMs: anchor.atWallMs }),
|
|
1964
|
+
late: anchor.late,
|
|
1965
|
+
asap: anchor.asap === true,
|
|
1966
|
+
};
|
|
1967
|
+
return group === undefined
|
|
1968
|
+
? { lane: anchor.lane, admission, ...timing }
|
|
1969
|
+
: { lane: anchor.lane, admission, group, ...timing };
|
|
1970
|
+
};
|
|
1971
|
+
/** The items a withdrawal of `key` names: a group's parts, a part and those after it, or one item. */
|
|
1972
|
+
const withdrawalTargets = (key) => {
|
|
1973
|
+
const item = items.get(key);
|
|
1974
|
+
const named = groups.get(key);
|
|
1975
|
+
const group = named ?? (item?.group === undefined ? undefined : groups.get(item.group.key));
|
|
1976
|
+
if (group === undefined || (named === undefined && item?.inserted === true))
|
|
1977
|
+
return item === undefined ? [] : [item];
|
|
1978
|
+
const from = named === undefined ? item.group.index : 0;
|
|
1979
|
+
const targets = group.parts
|
|
1980
|
+
.map((part) => items.get(part))
|
|
1981
|
+
.filter((part) => part?.group !== undefined && part.group.index >= from);
|
|
1982
|
+
// A part key's reply is that part's own outcome, so it goes first.
|
|
1983
|
+
if (named === undefined)
|
|
1984
|
+
targets.sort((a, b) => Number(b === item) - Number(a === item));
|
|
1985
|
+
return targets;
|
|
1986
|
+
};
|
|
1987
|
+
/**
|
|
1988
|
+
* Checks and admits a call's edits in order, without yielding. A refused edit undoes the
|
|
1989
|
+
* edits before it, so nothing of a refused batch is admitted or published.
|
|
1990
|
+
*/
|
|
1991
|
+
const stageEdits = (edits, batched, state, nowMs) => {
|
|
1992
|
+
const undo = [];
|
|
1993
|
+
const added = [];
|
|
1994
|
+
const waitFor = [];
|
|
1995
|
+
const replaced = [];
|
|
1996
|
+
const results = [];
|
|
1997
|
+
const refuse = (error) => {
|
|
1998
|
+
for (const step of undo.reverse())
|
|
1999
|
+
step();
|
|
2000
|
+
return Result.fail(error);
|
|
2001
|
+
};
|
|
2002
|
+
// Clips a batch withdraws or replaces do not count ahead of the clips it adds.
|
|
2003
|
+
const excluding = new Set();
|
|
2004
|
+
const named = [];
|
|
2005
|
+
for (const edit of edits)
|
|
2006
|
+
switch (edit._tag) {
|
|
2007
|
+
case "Submit":
|
|
2008
|
+
named.push(edit.item.key);
|
|
2009
|
+
break;
|
|
2010
|
+
case "SubmitGroup":
|
|
2011
|
+
named.push(edit.group.key, ...edit.group.parts.map((part) => part.key));
|
|
2012
|
+
break;
|
|
2013
|
+
case "Insert":
|
|
2014
|
+
named.push(edit.insert.key);
|
|
2015
|
+
break;
|
|
2016
|
+
case "Replace":
|
|
2017
|
+
named.push(edit.key, edit.next.key);
|
|
2018
|
+
excluding.add(edit.key);
|
|
2019
|
+
break;
|
|
2020
|
+
case "Withdraw": {
|
|
2021
|
+
const targets = withdrawalTargets(edit.key);
|
|
2022
|
+
named.push(edit.key, ...targets.map((target) => target.key).filter((key) => key !== edit.key));
|
|
2023
|
+
for (const target of targets)
|
|
2024
|
+
excluding.add(target.key);
|
|
2025
|
+
break;
|
|
2026
|
+
}
|
|
2027
|
+
}
|
|
2028
|
+
if (new Set(named).size !== named.length)
|
|
2029
|
+
return refuse(invalid("An edit batch names each key once"));
|
|
2030
|
+
for (const edit of edits)
|
|
2031
|
+
if (edit._tag === "Insert" && excluding.has(edit.insert.anchor))
|
|
2032
|
+
return refuse(invalid("An insert's anchor is withdrawn or replaced in the same batch"));
|
|
2033
|
+
const view = (place) => projectedStartMs({
|
|
2034
|
+
engine: state,
|
|
2035
|
+
items: [...items.values()],
|
|
2036
|
+
owned,
|
|
2037
|
+
lanes,
|
|
2038
|
+
playingStartedMs,
|
|
2039
|
+
nowMs,
|
|
2040
|
+
batches: pendingBatches(),
|
|
2041
|
+
estimates: estimatesFrom(buildSamples, lengthSamples),
|
|
2042
|
+
...fillerView(),
|
|
2043
|
+
withdrawing: new Set([...pendingWithdrawals.keys(), ...excluding]),
|
|
2044
|
+
}, place);
|
|
2045
|
+
/**
|
|
2046
|
+
* A new submission's lane policy: a skip lane refuses it while the lane has an item
|
|
2047
|
+
* waiting or playing; a replace lane supersedes the items still waiting there.
|
|
2048
|
+
*/
|
|
2049
|
+
const conflict = (key, lane) => {
|
|
2050
|
+
const others = [...items.values()].filter((entry) => entry.lane === lane &&
|
|
2051
|
+
entry.phase !== "Terminal" &&
|
|
2052
|
+
!excluding.has(entry.key) &&
|
|
2053
|
+
!added.includes(entry));
|
|
2054
|
+
switch (conflicts.get(lane)) {
|
|
2055
|
+
case "skip":
|
|
2056
|
+
return others.length > 0 ? LaneBusy.of(key, lane) : undefined;
|
|
2057
|
+
case "replace":
|
|
2058
|
+
for (const entry of others)
|
|
2059
|
+
if (entry.phase !== "Started" && !pendingWithdrawals.has(entry.key)) {
|
|
2060
|
+
replaced.push(entry.key);
|
|
2061
|
+
excluding.add(entry.key);
|
|
2062
|
+
}
|
|
2063
|
+
return undefined;
|
|
2064
|
+
default:
|
|
2065
|
+
return undefined;
|
|
2066
|
+
}
|
|
2067
|
+
};
|
|
2068
|
+
const admitted = (entry) => {
|
|
2069
|
+
added.push(entry);
|
|
2070
|
+
waitFor.push(entry.key);
|
|
2071
|
+
undo.push(() => items.delete(entry.key));
|
|
2072
|
+
};
|
|
2073
|
+
/** An existing key: the same spec returns its handle, and the batch waits for it too. */
|
|
2074
|
+
const repeated = (key, fingerprint) => {
|
|
2075
|
+
const existing = items.get(key) ?? history.get(key);
|
|
2076
|
+
if (existing === undefined && !groups.has(key))
|
|
2077
|
+
return Result.succeed(undefined);
|
|
2078
|
+
if (existing?.fingerprint !== fingerprint)
|
|
2079
|
+
return Result.fail(KeyMismatch.of(key));
|
|
2080
|
+
waitFor.push(key);
|
|
2081
|
+
return Result.succeed(existing.handle);
|
|
2082
|
+
};
|
|
2083
|
+
for (const edit of edits) {
|
|
2084
|
+
switch (edit._tag) {
|
|
2085
|
+
case "Submit": {
|
|
2086
|
+
const { item } = edit;
|
|
2087
|
+
const known = repeated(item.key, item.fingerprint);
|
|
2088
|
+
if (Result.isFailure(known))
|
|
2089
|
+
return refuse(known.failure);
|
|
2090
|
+
if (known.success !== undefined) {
|
|
2091
|
+
results.push({ _tag: "Added", handle: known.success });
|
|
2092
|
+
break;
|
|
2093
|
+
}
|
|
2094
|
+
const busy = conflict(item.key, item.lane);
|
|
2095
|
+
if (busy !== undefined)
|
|
2096
|
+
return refuse(busy);
|
|
2097
|
+
const startByMs = item.startByOffsetMs === undefined ? undefined : nowMs + item.startByOffsetMs;
|
|
2098
|
+
if (startByMs !== undefined &&
|
|
2099
|
+
view({
|
|
2100
|
+
lane: item.lane,
|
|
2101
|
+
admission: Infinity,
|
|
2102
|
+
seconds: item.request.durationSeconds,
|
|
2103
|
+
startByMs,
|
|
2104
|
+
...(item.mode === "asap" ? { asap: true } : {}),
|
|
2105
|
+
...(item.mode === "manual" ? { held: true } : {}),
|
|
2106
|
+
}) > startByMs)
|
|
2107
|
+
return refuse(WouldMissDeadline.of(item.key));
|
|
2108
|
+
const entry = accept(item, nowMs, startByMs, undefined);
|
|
2109
|
+
admitted(entry);
|
|
2110
|
+
results.push({ _tag: "Added", handle: entry.handle });
|
|
2111
|
+
break;
|
|
2112
|
+
}
|
|
2113
|
+
case "SubmitGroup": {
|
|
2114
|
+
const { group } = edit;
|
|
2115
|
+
const known = groups.get(group.key);
|
|
2116
|
+
if (known !== undefined) {
|
|
2117
|
+
if (known.fingerprint !== group.fingerprint)
|
|
2118
|
+
return refuse(KeyMismatch.of(group.key));
|
|
2119
|
+
waitFor.push(...known.parts);
|
|
2120
|
+
results.push({ _tag: "AddedGroup", handle: known.handle });
|
|
2121
|
+
break;
|
|
2122
|
+
}
|
|
2123
|
+
const taken = [group.key, ...group.parts.map((part) => part.key)].find((key) => items.has(key) || history.has(key) || groups.has(key));
|
|
2124
|
+
if (taken !== undefined)
|
|
2125
|
+
return refuse(KeyMismatch.of(taken));
|
|
2126
|
+
const [first, ...rest] = group.parts;
|
|
2127
|
+
const busy = conflict(group.key, first.lane);
|
|
2128
|
+
if (busy !== undefined)
|
|
2129
|
+
return refuse(busy);
|
|
2130
|
+
const startByMs = first.startByOffsetMs === undefined ? undefined : nowMs + first.startByOffsetMs;
|
|
2131
|
+
if (startByMs !== undefined &&
|
|
2132
|
+
view({
|
|
2133
|
+
lane: first.lane,
|
|
2134
|
+
admission: Infinity,
|
|
2135
|
+
seconds: first.request.durationSeconds,
|
|
2136
|
+
startByMs,
|
|
2137
|
+
}) > startByMs)
|
|
2138
|
+
return refuse(WouldMissDeadline.of(group.key));
|
|
2139
|
+
const parts = [
|
|
2140
|
+
accept(first, nowMs, startByMs, { key: group.key, index: 0 }),
|
|
2141
|
+
...rest.map((part, index) => accept(part, nowMs, undefined, { key: group.key, index: index + 1 })),
|
|
2142
|
+
];
|
|
2143
|
+
for (const part of parts)
|
|
2144
|
+
admitted(part);
|
|
2145
|
+
const handle = {
|
|
2146
|
+
key: group.key,
|
|
2147
|
+
parts: [parts[0].handle, ...parts.slice(1).map((part) => part.handle)],
|
|
2148
|
+
};
|
|
2149
|
+
groups.set(group.key, {
|
|
2150
|
+
fingerprint: group.fingerprint,
|
|
2151
|
+
handle,
|
|
2152
|
+
parts: group.parts.map((part) => part.key),
|
|
2153
|
+
brokenAt: undefined,
|
|
2154
|
+
});
|
|
2155
|
+
undo.push(() => groups.delete(group.key));
|
|
2156
|
+
results.push({ _tag: "AddedGroup", handle });
|
|
2157
|
+
break;
|
|
2158
|
+
}
|
|
2159
|
+
case "Insert": {
|
|
2160
|
+
const { insert } = edit;
|
|
2161
|
+
const known = repeated(insert.key, insert.fingerprint);
|
|
2162
|
+
if (Result.isFailure(known))
|
|
2163
|
+
return refuse(known.failure);
|
|
2164
|
+
if (known.success !== undefined) {
|
|
2165
|
+
results.push({ _tag: "Added", handle: known.success });
|
|
2166
|
+
break;
|
|
2167
|
+
}
|
|
2168
|
+
const placed = placeInsert(insert);
|
|
2169
|
+
if (typeof placed === "string")
|
|
2170
|
+
return refuse(invalid(placed));
|
|
2171
|
+
const startByMs = insert.startByOffsetMs === undefined ? undefined : nowMs + insert.startByOffsetMs;
|
|
2172
|
+
if (startByMs !== undefined &&
|
|
2173
|
+
view({ ...placed, seconds: insert.request.durationSeconds, startByMs }) > startByMs)
|
|
2174
|
+
return refuse(WouldMissDeadline.of(insert.key));
|
|
2175
|
+
const entry = accept({
|
|
2176
|
+
key: insert.key,
|
|
2177
|
+
lane: placed.lane,
|
|
2178
|
+
request: insert.request,
|
|
2179
|
+
fingerprint: insert.fingerprint,
|
|
2180
|
+
notBeforeOffsetMs: insert.notBeforeOffsetMs,
|
|
2181
|
+
startByOffsetMs: insert.startByOffsetMs,
|
|
2182
|
+
firm: insert.firm,
|
|
2183
|
+
atWallMs: placed.atWallMs,
|
|
2184
|
+
late: placed.late,
|
|
2185
|
+
...(placed.asap ? { mode: "asap" } : {}),
|
|
2186
|
+
cues: insert.cues,
|
|
2187
|
+
...(insert.continuity === undefined ? {} : { continuity: insert.continuity }),
|
|
2188
|
+
}, nowMs, startByMs, placed.group, {
|
|
2189
|
+
admission: placed.admission,
|
|
2190
|
+
inserted: true,
|
|
2191
|
+
notBeforeMs: insert.notBeforeOffsetMs === undefined
|
|
2192
|
+
? undefined
|
|
2193
|
+
: nowMs + insert.notBeforeOffsetMs,
|
|
2194
|
+
});
|
|
2195
|
+
admitted(entry);
|
|
2196
|
+
if (placed.group !== undefined) {
|
|
2197
|
+
const parts = groups.get(placed.group.key)?.parts;
|
|
2198
|
+
parts?.push(entry.key);
|
|
2199
|
+
undo.push(() => parts?.pop());
|
|
2200
|
+
}
|
|
2201
|
+
results.push({ _tag: "Added", handle: entry.handle });
|
|
2202
|
+
break;
|
|
2203
|
+
}
|
|
2204
|
+
case "Replace": {
|
|
2205
|
+
const { next } = edit;
|
|
2206
|
+
const fingerprint = JSON.stringify({
|
|
2207
|
+
replaces: edit.key,
|
|
2208
|
+
request: next.request,
|
|
2209
|
+
...(next.cues.length === 0 ? {} : { cues: next.cues }),
|
|
2210
|
+
...(next.continuity === undefined ? {} : { continuity: next.continuity }),
|
|
2211
|
+
});
|
|
2212
|
+
const known = repeated(next.key, fingerprint);
|
|
2213
|
+
if (Result.isFailure(known))
|
|
2214
|
+
return refuse(known.failure);
|
|
2215
|
+
if (known.success !== undefined) {
|
|
2216
|
+
results.push({ _tag: "Added", handle: known.success });
|
|
2217
|
+
break;
|
|
2218
|
+
}
|
|
2219
|
+
const old = items.get(edit.key);
|
|
2220
|
+
if (old === undefined ||
|
|
2221
|
+
old.replacedBy !== undefined ||
|
|
2222
|
+
(old.phase !== "Accepted" && old.phase !== "Building" && old.phase !== "Ready"))
|
|
2223
|
+
return refuse(invalid("Nothing queued to replace under that key"));
|
|
2224
|
+
const entry = accept({
|
|
2225
|
+
key: next.key,
|
|
2226
|
+
lane: old.lane,
|
|
2227
|
+
request: next.request,
|
|
2228
|
+
fingerprint,
|
|
2229
|
+
notBeforeOffsetMs: undefined,
|
|
2230
|
+
startByOffsetMs: undefined,
|
|
2231
|
+
firm: old.firm,
|
|
2232
|
+
atWallMs: old.atWallMs,
|
|
2233
|
+
late: old.late,
|
|
2234
|
+
...(old.asap === true ? { mode: "asap" } : {}),
|
|
2235
|
+
...(old.held === true ? { mode: "manual" } : {}),
|
|
2236
|
+
cues: next.cues,
|
|
2237
|
+
...(next.continuity === undefined ? {} : { continuity: next.continuity }),
|
|
2238
|
+
}, nowMs, old.startByMs, old.group, {
|
|
2239
|
+
admission: old.admission,
|
|
2240
|
+
replaces: old.key,
|
|
2241
|
+
generation: (old.generation ?? 0) + 1,
|
|
2242
|
+
inserted: old.inserted === true,
|
|
2243
|
+
notBeforeMs: old.notBeforeMs,
|
|
2244
|
+
});
|
|
2245
|
+
admitted(entry);
|
|
2246
|
+
old.replacedBy = entry.key;
|
|
2247
|
+
undo.push(() => delete old.replacedBy);
|
|
2248
|
+
// A pending batch that waits for the replaced item waits for its replacement.
|
|
2249
|
+
for (const batch of batches.values())
|
|
2250
|
+
if (batch.waitFor.includes(old.key)) {
|
|
2251
|
+
batch.waitFor.push(entry.key);
|
|
2252
|
+
undo.push(() => batch.waitFor.pop());
|
|
2253
|
+
}
|
|
2254
|
+
if (old.group !== undefined) {
|
|
2255
|
+
const parts = groups.get(old.group.key)?.parts;
|
|
2256
|
+
parts?.push(entry.key);
|
|
2257
|
+
undo.push(() => parts?.pop());
|
|
2258
|
+
}
|
|
2259
|
+
results.push({ _tag: "Added", handle: entry.handle });
|
|
2260
|
+
break;
|
|
2261
|
+
}
|
|
2262
|
+
case "Withdraw":
|
|
2263
|
+
results.push({ _tag: "Withdrawal", key: edit.key });
|
|
2264
|
+
break;
|
|
2265
|
+
}
|
|
2266
|
+
}
|
|
2267
|
+
return Result.succeed({ results, added, waitFor, replaced });
|
|
2268
|
+
};
|
|
2269
|
+
/**
|
|
2270
|
+
* Makes a staged call's handle. A batch keeps what it withdraws as cover until it commits;
|
|
2271
|
+
* a single call adds one item or group and takes effect at once.
|
|
2272
|
+
*/
|
|
2273
|
+
const openBatch = (staged, batched) => {
|
|
2274
|
+
// A single call adds one item or group and never withdraws; only a replace lane gives
|
|
2275
|
+
// it something to supersede.
|
|
2276
|
+
if (!batched && staged.replaced.length === 0)
|
|
2277
|
+
return {
|
|
2278
|
+
handle: {
|
|
2279
|
+
results: staged.results.filter((result) => result._tag !== "Withdrawal"),
|
|
2280
|
+
committed: Effect.void,
|
|
2281
|
+
},
|
|
2282
|
+
settle: [],
|
|
2283
|
+
};
|
|
2284
|
+
const settle = [];
|
|
2285
|
+
const batch = {
|
|
2286
|
+
id: ++batchCount,
|
|
2287
|
+
waitFor: [...staged.waitFor],
|
|
2288
|
+
targets: new Map(staged.replaced.map((key) => [key, { reason: "replaced", reply: undefined }])),
|
|
2289
|
+
committed: Deferred.makeUnsafe(),
|
|
2290
|
+
};
|
|
2291
|
+
const results = staged.results.map((result) => {
|
|
2292
|
+
if (result._tag !== "Withdrawal")
|
|
2293
|
+
return result;
|
|
2294
|
+
const replies = [];
|
|
2295
|
+
for (const target of withdrawalTargets(result.key)) {
|
|
2296
|
+
const reply = Deferred.makeUnsafe();
|
|
2297
|
+
replies.push(reply);
|
|
2298
|
+
batch.targets.set(target.key, { reason: "withdrawn", reply });
|
|
2299
|
+
}
|
|
2300
|
+
const outcome = Deferred.makeUnsafe();
|
|
2301
|
+
settle.push(settleWithdrawals(replies, outcome, groups.has(result.key)));
|
|
2302
|
+
return {
|
|
2303
|
+
_tag: "Withdrawal",
|
|
2304
|
+
outcome: Deferred.await(outcome).pipe(Effect.raceFirst(closedCall)),
|
|
2305
|
+
};
|
|
2306
|
+
});
|
|
2307
|
+
// A pending batch whose new clip this one withdraws now also waits for this one's, so
|
|
2308
|
+
// its own cover is not withdrawn before anything replaces it.
|
|
2309
|
+
for (const other of batches.values())
|
|
2310
|
+
if (other.waitFor.some((key) => batch.targets.has(key)))
|
|
2311
|
+
other.waitFor.push(...batch.waitFor);
|
|
2312
|
+
batches.set(batch.id, batch);
|
|
2313
|
+
return {
|
|
2314
|
+
handle: {
|
|
2315
|
+
results: batched ? results : results.filter((result) => result._tag !== "Withdrawal"),
|
|
2316
|
+
committed: batched ? Deferred.await(batch.committed) : Effect.void,
|
|
2317
|
+
},
|
|
2318
|
+
settle,
|
|
2319
|
+
};
|
|
2320
|
+
};
|
|
1186
2321
|
const actor = Effect.gen(function* () {
|
|
1187
2322
|
for (;;) {
|
|
1188
|
-
const message = yield*
|
|
1189
|
-
|
|
1190
|
-
|
|
1191
|
-
yield* Deferred.fail(message.reply, PolicyFailure.refuse("SessionClosed", ended.message));
|
|
1192
|
-
else if (message._tag === "Withdraw")
|
|
1193
|
-
yield* Deferred.fail(message.reply, PolicyFailure.refuse("SessionClosed", ended.message));
|
|
1194
|
-
else if (message._tag === "Drain")
|
|
1195
|
-
yield* Deferred.fail(message.reply, PolicyFailure.refuse("SessionClosed", ended.message));
|
|
2323
|
+
const message = yield* takeQueue(inbox);
|
|
2324
|
+
// Queued public calls observe the same terminal result through closedCall.
|
|
2325
|
+
if (ended !== undefined && message._tag !== "CommandDone")
|
|
1196
2326
|
continue;
|
|
1197
|
-
}
|
|
1198
2327
|
switch (message._tag) {
|
|
1199
|
-
case "
|
|
2328
|
+
case "Edit": {
|
|
1200
2329
|
if (!accepting) {
|
|
1201
2330
|
yield* Deferred.fail(message.reply, invalid("Scheduler is draining or closed"));
|
|
1202
2331
|
break;
|
|
1203
2332
|
}
|
|
1204
|
-
const existing = items.get(message.item.key) ?? history.get(message.item.key);
|
|
1205
|
-
if (existing !== undefined) {
|
|
1206
|
-
if (existing.fingerprint === message.item.fingerprint)
|
|
1207
|
-
yield* Deferred.succeed(message.reply, existing.handle);
|
|
1208
|
-
else
|
|
1209
|
-
yield* Deferred.fail(message.reply, KeyMismatch.of(message.item.key));
|
|
1210
|
-
break;
|
|
1211
|
-
}
|
|
1212
2333
|
const nowMs = monotonicMillis(clock);
|
|
1213
2334
|
const state = yield* engine.state;
|
|
1214
|
-
|
|
1215
|
-
|
|
1216
|
-
|
|
1217
|
-
|
|
1218
|
-
|
|
1219
|
-
|
|
2335
|
+
if (ended !== undefined)
|
|
2336
|
+
continue;
|
|
2337
|
+
// No yield from the terminal check above through admission and its
|
|
2338
|
+
// publication below: a claim either precedes the items or settles them
|
|
2339
|
+
// after their Accepted evidence.
|
|
2340
|
+
const staged = stageEdits(message.edits, message.batched, state, nowMs);
|
|
2341
|
+
if (Result.isFailure(staged)) {
|
|
2342
|
+
yield* Deferred.fail(message.reply, staged.failure);
|
|
1220
2343
|
break;
|
|
1221
2344
|
}
|
|
1222
|
-
const
|
|
1223
|
-
|
|
1224
|
-
|
|
1225
|
-
|
|
1226
|
-
|
|
1227
|
-
|
|
1228
|
-
|
|
1229
|
-
|
|
1230
|
-
|
|
1231
|
-
const item = {
|
|
1232
|
-
key: message.item.key,
|
|
1233
|
-
lane: message.item.lane,
|
|
1234
|
-
request: message.item.request,
|
|
1235
|
-
fingerprint: message.item.fingerprint,
|
|
1236
|
-
admission: ++admission,
|
|
1237
|
-
phase: "Accepted",
|
|
1238
|
-
status: { _tag: "Accepted" },
|
|
1239
|
-
...(message.item.notBeforeOffsetMs === undefined
|
|
1240
|
-
? {}
|
|
1241
|
-
: { notBeforeMs: nowMs + message.item.notBeforeOffsetMs }),
|
|
1242
|
-
...(startByMs === undefined ? {} : { startByMs }),
|
|
1243
|
-
firm: message.item.firm,
|
|
1244
|
-
...(message.item.atWallMs === undefined
|
|
1245
|
-
? {}
|
|
1246
|
-
: {
|
|
1247
|
-
atWallMs: message.item.atWallMs,
|
|
1248
|
-
atMs: nowMs + message.item.atWallMs - clock.currentTimeMillisUnsafe(),
|
|
1249
|
-
}),
|
|
1250
|
-
late: message.item.late,
|
|
1251
|
-
sessionId: undefined,
|
|
1252
|
-
unknownSessionId: undefined,
|
|
1253
|
-
unknownCause: undefined,
|
|
1254
|
-
handle,
|
|
1255
|
-
startedWaiter,
|
|
1256
|
-
outcomeWaiter,
|
|
1257
|
-
firstDecisiveWaiter,
|
|
1258
|
-
};
|
|
1259
|
-
items.set(item.key, item);
|
|
1260
|
-
yield* PubSub.publish(events, {
|
|
1261
|
-
key: item.key,
|
|
1262
|
-
at: clock.currentTimeMillisUnsafe(),
|
|
1263
|
-
status: item.status,
|
|
1264
|
-
});
|
|
2345
|
+
for (const entry of staged.success.added)
|
|
2346
|
+
PubSub.publishUnsafe(events, {
|
|
2347
|
+
key: entry.key,
|
|
2348
|
+
at: clock.currentTimeMillisUnsafe(),
|
|
2349
|
+
status: entry.status,
|
|
2350
|
+
});
|
|
2351
|
+
const opened = openBatch(staged.success, message.batched);
|
|
2352
|
+
for (const effect of opened.settle)
|
|
2353
|
+
yield* Effect.forkIn(effect, actorScope);
|
|
1265
2354
|
yield* observed(state);
|
|
1266
|
-
|
|
2355
|
+
// An edit returns its committed view; other messages publish once
|
|
2356
|
+
// through reconciliation after all their evidence is adopted.
|
|
2357
|
+
yield* publishState(state);
|
|
2358
|
+
yield* Deferred.succeed(message.reply, opened.handle);
|
|
1267
2359
|
break;
|
|
1268
2360
|
}
|
|
1269
2361
|
case "Withdraw": {
|
|
2362
|
+
const targets = withdrawalTargets(message.key);
|
|
2363
|
+
if (targets.length <= 1 && !groups.has(message.key)) {
|
|
2364
|
+
if (targets[0] === undefined)
|
|
2365
|
+
yield* Deferred.succeed(message.reply, "not-found");
|
|
2366
|
+
else
|
|
2367
|
+
yield* requestWithdrawal(targets[0], "withdrawn", message.reply);
|
|
2368
|
+
break;
|
|
2369
|
+
}
|
|
2370
|
+
const replies = [];
|
|
2371
|
+
for (const part of targets) {
|
|
2372
|
+
const reply = Deferred.makeUnsafe();
|
|
2373
|
+
replies.push(reply);
|
|
2374
|
+
yield* requestWithdrawal(part, "withdrawn", reply);
|
|
2375
|
+
}
|
|
2376
|
+
yield* Effect.forkIn(settleWithdrawals(replies, message.reply, groups.has(message.key)), actorScope);
|
|
2377
|
+
break;
|
|
2378
|
+
}
|
|
2379
|
+
case "Release": {
|
|
1270
2380
|
const item = items.get(message.key);
|
|
1271
|
-
if (item ===
|
|
1272
|
-
yield* Deferred.
|
|
1273
|
-
|
|
1274
|
-
|
|
2381
|
+
if (item?.held !== true || item.phase === "Started" || item.phase === "Terminal") {
|
|
2382
|
+
yield* Deferred.fail(message.reply, invalid("Nothing is held under that key"));
|
|
2383
|
+
break;
|
|
2384
|
+
}
|
|
2385
|
+
item.held = false;
|
|
2386
|
+
item.asap = true;
|
|
2387
|
+
yield* Deferred.succeed(message.reply, undefined);
|
|
1275
2388
|
break;
|
|
1276
2389
|
}
|
|
1277
2390
|
case "Drain": {
|
|
@@ -1288,9 +2401,12 @@ export const makeScheduler = (options) => Effect.gen(function* () {
|
|
|
1288
2401
|
if (!draining) {
|
|
1289
2402
|
draining = true;
|
|
1290
2403
|
finishAccepted = message.finish === "accepted";
|
|
2404
|
+
updateRecovery();
|
|
1291
2405
|
// Renewal admission must stop even while an earlier enqueue is
|
|
1292
2406
|
// awaiting its reply in the command worker.
|
|
1293
2407
|
const stoppedRenewal = yield* Effect.result(engine.stopRenewal);
|
|
2408
|
+
if (ended !== undefined)
|
|
2409
|
+
break;
|
|
1294
2410
|
if (Result.isFailure(stoppedRenewal)) {
|
|
1295
2411
|
drainFailure = stoppedRenewal.failure;
|
|
1296
2412
|
for (const reply of drainReplies.splice(0))
|
|
@@ -1302,7 +2418,6 @@ export const makeScheduler = (options) => Effect.gen(function* () {
|
|
|
1302
2418
|
yield* sendCommand({ _tag: "PauseAutoplay" });
|
|
1303
2419
|
}
|
|
1304
2420
|
}
|
|
1305
|
-
yield* drainPending(yield* engine.state);
|
|
1306
2421
|
break;
|
|
1307
2422
|
}
|
|
1308
2423
|
case "Snapshot": {
|
|
@@ -1362,22 +2477,12 @@ export const makeScheduler = (options) => Effect.gen(function* () {
|
|
|
1362
2477
|
}
|
|
1363
2478
|
}
|
|
1364
2479
|
});
|
|
1365
|
-
//
|
|
1366
|
-
//
|
|
2480
|
+
// Orderly scope teardown has already claimed and settled Closed before
|
|
2481
|
+
// worker interruption; unexpected interruption retains its original Cause.
|
|
1367
2482
|
const supervise = (effect) => effect.pipe(Effect.onExit((exit) => Exit.isFailure(exit) && ended === undefined
|
|
1368
|
-
?
|
|
1369
|
-
accepting = false;
|
|
1370
|
-
yield* Deferred.failCause(stopped, exit.cause);
|
|
1371
|
-
yield* Deferred.failCause(initialized, exit.cause);
|
|
1372
|
-
for (const item of items.values()) {
|
|
1373
|
-
yield* Deferred.failCause(item.startedWaiter, exit.cause);
|
|
1374
|
-
yield* Deferred.failCause(item.outcomeWaiter, exit.cause);
|
|
1375
|
-
yield* Deferred.failCause(item.firstDecisiveWaiter, exit.cause);
|
|
1376
|
-
}
|
|
1377
|
-
yield* Queue.shutdown(inbox);
|
|
1378
|
-
yield* Queue.shutdown(commandQueue);
|
|
1379
|
-
})
|
|
2483
|
+
? terminate(Exit.failCause(exit.cause))
|
|
1380
2484
|
: Effect.void));
|
|
2485
|
+
yield* Effect.forkScoped(supervise(watchdog));
|
|
1381
2486
|
yield* Effect.forkScoped(supervise(commandWorker));
|
|
1382
2487
|
yield* Effect.forkScoped(supervise(actor));
|
|
1383
2488
|
yield* Effect.forkScoped(supervise(observe));
|
|
@@ -1390,16 +2495,72 @@ export const makeScheduler = (options) => Effect.gen(function* () {
|
|
|
1390
2495
|
yield* Queue.offer(inbox, { _tag: "Tick" });
|
|
1391
2496
|
yield* Effect.addFinalizer(() => closeActor(ReactorError.fromCode("Closed", "The scheduler closed")));
|
|
1392
2497
|
yield* Deferred.await(initialized);
|
|
2498
|
+
const laneSet = new Set(lanes);
|
|
2499
|
+
const send = (edits, batched) => Effect.gen(function* () {
|
|
2500
|
+
const reply = yield* Deferred.make();
|
|
2501
|
+
if (!(yield* Queue.offer(inbox, { _tag: "Edit", edits, batched, reply })))
|
|
2502
|
+
return yield* closedCall;
|
|
2503
|
+
return yield* Deferred.await(reply).pipe(Effect.raceFirst(closedCall));
|
|
2504
|
+
});
|
|
2505
|
+
/** The one result of a single call. */
|
|
2506
|
+
const only = (handle) => handle.results[0];
|
|
1393
2507
|
const submit = (input) => Effect.gen(function* () {
|
|
1394
2508
|
if (yield* Deferred.isDone(stopped))
|
|
1395
2509
|
return yield* closedCall;
|
|
1396
|
-
const item = yield* captureItem(input,
|
|
2510
|
+
const item = yield* captureItem(input, laneSet);
|
|
1397
2511
|
yield* Effect.annotateCurrentSpan("reactor.scheduler.item.key", item.key);
|
|
2512
|
+
const result = only(yield* send([{ _tag: "Submit", item }], false));
|
|
2513
|
+
if (result?._tag !== "Added")
|
|
2514
|
+
return yield* Effect.die("A submission returned no item");
|
|
2515
|
+
return result.handle;
|
|
2516
|
+
}).pipe(Effect.withSpan("Scheduler.submit", {}, { captureStackTrace: false }));
|
|
2517
|
+
const submitGroup = (input) => Effect.gen(function* () {
|
|
2518
|
+
if (yield* Deferred.isDone(stopped))
|
|
2519
|
+
return yield* closedCall;
|
|
2520
|
+
const group = yield* captureGroup(input, laneSet);
|
|
2521
|
+
yield* Effect.annotateCurrentSpan("reactor.scheduler.group.key", group.key);
|
|
2522
|
+
const result = only(yield* send([{ _tag: "SubmitGroup", group }], false));
|
|
2523
|
+
if (result?._tag !== "AddedGroup")
|
|
2524
|
+
return yield* Effect.die("A group returned no handle");
|
|
2525
|
+
return result.handle;
|
|
2526
|
+
}).pipe(Effect.withSpan("Scheduler.submitGroup", {}, { captureStackTrace: false }));
|
|
2527
|
+
const replace = (key, input) => Effect.gen(function* () {
|
|
2528
|
+
if (yield* Deferred.isDone(stopped))
|
|
2529
|
+
return yield* closedCall;
|
|
2530
|
+
const next = yield* captureReplacement(input);
|
|
2531
|
+
const result = only(yield* send([{ _tag: "Replace", key, next }], false).pipe(
|
|
2532
|
+
// A replacement keeps the replaced item's place and window, so neither a deadline
|
|
2533
|
+
// nor its lane's policy is checked for it.
|
|
2534
|
+
Effect.catchTag(["WouldMissDeadline", "LaneBusy"], (error) => Effect.die(error))));
|
|
2535
|
+
if (result?._tag !== "Added")
|
|
2536
|
+
return yield* Effect.die("A replacement returned no item");
|
|
2537
|
+
return result.handle;
|
|
2538
|
+
}).pipe(Effect.withSpan("Scheduler.replace", { attributes: { key } }, { captureStackTrace: false }));
|
|
2539
|
+
const insert = (input) => Effect.gen(function* () {
|
|
2540
|
+
if (yield* Deferred.isDone(stopped))
|
|
2541
|
+
return yield* closedCall;
|
|
2542
|
+
const captured = yield* captureInsert(input);
|
|
2543
|
+
yield* Effect.annotateCurrentSpan("reactor.scheduler.item.key", captured.key);
|
|
2544
|
+
const result = only(yield* send([{ _tag: "Insert", insert: captured }], false).pipe(
|
|
2545
|
+
// An insert places itself, so its lane's policy does not apply.
|
|
2546
|
+
Effect.catchTag("LaneBusy", (error) => Effect.die(error))));
|
|
2547
|
+
if (result?._tag !== "Added")
|
|
2548
|
+
return yield* Effect.die("An insert returned no item");
|
|
2549
|
+
return result.handle;
|
|
2550
|
+
}).pipe(Effect.withSpan("Scheduler.insert", {}, { captureStackTrace: false }));
|
|
2551
|
+
const edit = (input) => Effect.gen(function* () {
|
|
2552
|
+
if (yield* Deferred.isDone(stopped))
|
|
2553
|
+
return yield* closedCall;
|
|
2554
|
+
const edits = yield* captureEdits(input, laneSet);
|
|
2555
|
+
yield* Effect.annotateCurrentSpan("reactor.scheduler.edits", edits.length);
|
|
2556
|
+
return yield* send(edits, true);
|
|
2557
|
+
}).pipe(Effect.withSpan("Scheduler.edit", {}, { captureStackTrace: false }));
|
|
2558
|
+
const release = (key) => Effect.gen(function* () {
|
|
1398
2559
|
const reply = yield* Deferred.make();
|
|
1399
|
-
if (!(yield* Queue.offer(inbox, { _tag: "
|
|
2560
|
+
if (!(yield* Queue.offer(inbox, { _tag: "Release", key, reply })))
|
|
1400
2561
|
return yield* closedCall;
|
|
1401
2562
|
return yield* Deferred.await(reply).pipe(Effect.raceFirst(closedCall));
|
|
1402
|
-
}).pipe(Effect.withSpan("Scheduler.
|
|
2563
|
+
}).pipe(Effect.withSpan("Scheduler.release", { attributes: { key } }, { captureStackTrace: false }));
|
|
1403
2564
|
const withdraw = (key) => Effect.gen(function* () {
|
|
1404
2565
|
const reply = yield* Deferred.make();
|
|
1405
2566
|
if (!(yield* Queue.offer(inbox, { _tag: "Withdraw", key, reply })))
|
|
@@ -1419,12 +2580,18 @@ export const makeScheduler = (options) => Effect.gen(function* () {
|
|
|
1419
2580
|
}).pipe(Effect.withSpan("Scheduler.drain", {}, { captureStackTrace: false }));
|
|
1420
2581
|
return {
|
|
1421
2582
|
submit,
|
|
2583
|
+
submitGroup,
|
|
2584
|
+
replace,
|
|
2585
|
+
insert,
|
|
2586
|
+
edit,
|
|
2587
|
+
release,
|
|
1422
2588
|
withdraw,
|
|
1423
2589
|
drain,
|
|
1424
2590
|
failure: Deferred.await(stopped),
|
|
1425
2591
|
state: SubscriptionRef.get(stateRef),
|
|
1426
2592
|
asRun: Stream.fromPubSub(events),
|
|
2593
|
+
cues: Stream.fromPubSub(cueEvents),
|
|
1427
2594
|
};
|
|
1428
|
-
});
|
|
2595
|
+
}).pipe(Effect.provideServiceEffect(Scope.Scope, Effect.flatMap(Scope.Scope, (parent) => Scope.fork(parent, "sequential"))));
|
|
1429
2596
|
export const layerScheduler = (options) => Layer.effect(Scheduler, makeScheduler(options));
|
|
1430
2597
|
//# sourceMappingURL=scheduler.js.map
|