reactor-effect-client 0.4.0 → 0.6.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 +20 -2
- 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/duration.d.ts +2 -0
- package/dist/duration.d.ts.map +1 -1
- package/dist/duration.js +1 -1
- package/dist/duration.js.map +1 -1
- 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/h3-source.d.ts.map +1 -1
- package/dist/orchestration/h3-source.js +28 -9
- package/dist/orchestration/h3-source.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/queries.d.ts.map +1 -1
- package/dist/orchestration/queries.js +3 -0
- package/dist/orchestration/queries.js.map +1 -1
- package/dist/orchestration/renewal-state.d.ts +28 -4
- package/dist/orchestration/renewal-state.d.ts.map +1 -1
- package/dist/orchestration/renewal-state.js +42 -7
- package/dist/orchestration/renewal-state.js.map +1 -1
- package/dist/orchestration/renewal.d.ts +39 -3
- package/dist/orchestration/renewal.d.ts.map +1 -1
- package/dist/orchestration/renewal.js +490 -103
- 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 +13 -0
- package/dist/orchestration/scheduler-key.d.ts.map +1 -0
- package/dist/orchestration/scheduler-key.js +62 -0
- package/dist/orchestration/scheduler-key.js.map +1 -0
- package/dist/orchestration/scheduler-policy.d.ts +81 -0
- package/dist/orchestration/scheduler-policy.d.ts.map +1 -0
- package/dist/orchestration/scheduler-policy.js +200 -0
- package/dist/orchestration/scheduler-policy.js.map +1 -0
- package/dist/orchestration/scheduler.d.ts +184 -0
- package/dist/orchestration/scheduler.d.ts.map +1 -0
- package/dist/orchestration/scheduler.js +1680 -0
- package/dist/orchestration/scheduler.js.map +1 -0
- package/dist/orchestration/source-slot.d.ts +20 -5
- package/dist/orchestration/source-slot.d.ts.map +1 -1
- package/dist/orchestration/source-slot.js +151 -29
- package/dist/orchestration/source-slot.js.map +1 -1
- package/dist/orchestration/types.d.ts +545 -6
- 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 +43 -31
- package/dist/simulation/_internal/source.js.map +1 -1
- package/package.json +2 -2
- package/dist/orchestration/lineup.d.ts +0 -109
- package/dist/orchestration/lineup.d.ts.map +0 -1
- package/dist/orchestration/lineup.js +0 -249
- package/dist/orchestration/lineup.js.map +0 -1
|
@@ -0,0 +1,1680 @@
|
|
|
1
|
+
import * as Clock from "effect/Clock";
|
|
2
|
+
import * as Context from "effect/Context";
|
|
3
|
+
import * as Deferred from "effect/Deferred";
|
|
4
|
+
import * as Duration from "effect/Duration";
|
|
5
|
+
import * as Effect from "effect/Effect";
|
|
6
|
+
import * as Exit from "effect/Exit";
|
|
7
|
+
import * as Layer from "effect/Layer";
|
|
8
|
+
import * as Option from "effect/Option";
|
|
9
|
+
import * as PubSub from "effect/PubSub";
|
|
10
|
+
import * as Queue from "effect/Queue";
|
|
11
|
+
import * as Result from "effect/Result";
|
|
12
|
+
import * as Schema from "effect/Schema";
|
|
13
|
+
import * as Scope from "effect/Scope";
|
|
14
|
+
import * as Stream from "effect/Stream";
|
|
15
|
+
import * as SubscriptionRef from "effect/SubscriptionRef";
|
|
16
|
+
import { take as takeQueue } from "../_internal/queue.js";
|
|
17
|
+
import { duration } from "../duration.js";
|
|
18
|
+
import { parsedInput, ReactorError } from "../errors.js";
|
|
19
|
+
import { monotonicMillis } from "./elapsed.js";
|
|
20
|
+
import { captureRequest, PolicyFailure } from "./request.js";
|
|
21
|
+
import { activeIds } from "./routing.js";
|
|
22
|
+
import { fillerIndexFromKey, fillerKey, isReservedSchedulerKey, keyedRequest, keyFromProviderMetadata, } from "./scheduler-key.js";
|
|
23
|
+
import { plan, runwaySeconds } from "./scheduler-policy.js";
|
|
24
|
+
import { Engine } from "./types.js";
|
|
25
|
+
/** Stable caller identity for a scheduled item. */
|
|
26
|
+
export const ItemKey = Schema.NonEmptyString.pipe(Schema.brand("ItemKey"));
|
|
27
|
+
const isFirstDecisive = (status) => status._tag === "Started" ||
|
|
28
|
+
status._tag === "Ended" ||
|
|
29
|
+
status._tag === "Dropped" ||
|
|
30
|
+
status._tag === "Failed" ||
|
|
31
|
+
status._tag === "Unobserved" ||
|
|
32
|
+
(status._tag === "Unknown" && status.terminal === true);
|
|
33
|
+
export class KeyMismatch extends Schema.TaggedError()("KeyMismatch", {
|
|
34
|
+
key: ItemKey,
|
|
35
|
+
}) {
|
|
36
|
+
static of(key) {
|
|
37
|
+
return new KeyMismatch({ key });
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
export class WouldMissDeadline extends Schema.TaggedError()("WouldMissDeadline", { key: ItemKey }) {
|
|
41
|
+
static of(key) {
|
|
42
|
+
return new WouldMissDeadline({ key });
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
const maxEarlyClipIds = 4096;
|
|
46
|
+
const invalid = (message) => PolicyFailure.refuse("InvalidRequest", message, "submit");
|
|
47
|
+
const ownedData = (input, fields, name) => {
|
|
48
|
+
if (input === null || typeof input !== "object" || Array.isArray(input))
|
|
49
|
+
return Result.fail(invalid(`${name} must be an object`));
|
|
50
|
+
const descriptors = Object.getOwnPropertyDescriptors(input);
|
|
51
|
+
if (Object.getOwnPropertySymbols(input).length > 0 ||
|
|
52
|
+
Object.keys(descriptors).some((field) => !fields.includes(field) || !("value" in descriptors[field])))
|
|
53
|
+
return Result.fail(invalid(`${name} has unsupported fields or accessors`));
|
|
54
|
+
return Result.succeed(Object.fromEntries(Object.entries(descriptors).map(([field, descriptor]) => [field, descriptor.value])));
|
|
55
|
+
};
|
|
56
|
+
/** A Duration tuple or object read from own data properties; no accessor runs. */
|
|
57
|
+
const durationData = (input) => {
|
|
58
|
+
if (Array.isArray(input)) {
|
|
59
|
+
const descriptors = Object.getOwnPropertyDescriptors(input);
|
|
60
|
+
if (Object.getOwnPropertySymbols(input).length > 0 ||
|
|
61
|
+
Object.keys(descriptors).some((field) => !["0", "1", "length"].includes(field) || !("value" in descriptors[field])) ||
|
|
62
|
+
descriptors.length?.value !== 2 ||
|
|
63
|
+
descriptors["0"] === undefined ||
|
|
64
|
+
descriptors["1"] === undefined)
|
|
65
|
+
return Result.fail(invalid("Duration tuple has unsupported fields or accessors"));
|
|
66
|
+
const seconds = descriptors["0"].value;
|
|
67
|
+
const nanos = descriptors["1"].value;
|
|
68
|
+
return Result.succeed([seconds, nanos]);
|
|
69
|
+
}
|
|
70
|
+
const fields = ownedData(input, ["weeks", "days", "hours", "minutes", "seconds", "milliseconds", "microseconds", "nanoseconds"], "Duration");
|
|
71
|
+
return Result.isFailure(fields) ? Result.fail(fields.failure) : Result.succeed(fields.success);
|
|
72
|
+
};
|
|
73
|
+
const captureDuration = (input) => Effect.gen(function* () {
|
|
74
|
+
if (input === null || typeof input !== "object" || Duration.isDuration(input))
|
|
75
|
+
return input;
|
|
76
|
+
return yield* Effect.fromResult(durationData(input));
|
|
77
|
+
});
|
|
78
|
+
/**
|
|
79
|
+
* A Duration option read as caller data: a data property, own or inherited,
|
|
80
|
+
* captured without running any accessor. Rejections throw for `parsedInput`.
|
|
81
|
+
*/
|
|
82
|
+
const optionDuration = (options, field, name) => {
|
|
83
|
+
let descriptor;
|
|
84
|
+
for (let owner = options; descriptor === undefined && owner !== null; owner = Object.getPrototypeOf(owner))
|
|
85
|
+
descriptor = Object.getOwnPropertyDescriptor(owner, field);
|
|
86
|
+
if (descriptor === undefined)
|
|
87
|
+
return undefined;
|
|
88
|
+
const reject = () => ReactorError.fromCode("InvalidInput", `${name} must be a Duration of data properties`);
|
|
89
|
+
if (!("value" in descriptor))
|
|
90
|
+
throw reject();
|
|
91
|
+
const value = descriptor.value;
|
|
92
|
+
if (value === undefined)
|
|
93
|
+
return undefined;
|
|
94
|
+
if (value === null)
|
|
95
|
+
throw reject();
|
|
96
|
+
if (typeof value !== "object" || Duration.isDuration(value))
|
|
97
|
+
return value;
|
|
98
|
+
const captured = durationData(value);
|
|
99
|
+
if (Result.isFailure(captured))
|
|
100
|
+
throw reject();
|
|
101
|
+
return captured.success;
|
|
102
|
+
};
|
|
103
|
+
const requestDuration = (input, name, allowZero = false) => Effect.gen(function* () {
|
|
104
|
+
const captured = yield* captureDuration(input);
|
|
105
|
+
return yield* parsedInput(() => Duration.toMillis(duration(captured, name, { allowZero, allowNegative: name === "startBy" })), "submit").pipe(Effect.mapError((error) => invalid(error.message)));
|
|
106
|
+
});
|
|
107
|
+
/** Capture scheduling fields before reading the request's placement fields. */
|
|
108
|
+
const captureItem = (input, lanes) => Effect.gen(function* () {
|
|
109
|
+
if (input === null || typeof input !== "object" || Array.isArray(input))
|
|
110
|
+
return yield* invalid("Scheduled item must be an object");
|
|
111
|
+
const descriptors = Object.getOwnPropertyDescriptors(input);
|
|
112
|
+
if (Object.getOwnPropertySymbols(input).length > 0 ||
|
|
113
|
+
Object.keys(descriptors).some((field) => !["key", "lane", "request", "window", "start"].includes(field) ||
|
|
114
|
+
!("value" in descriptors[field])))
|
|
115
|
+
return yield* invalid("Scheduled item has unsupported fields or accessors");
|
|
116
|
+
const key = yield* Schema.decodeUnknownEffect(ItemKey)(descriptors.key?.value).pipe(Effect.mapError(() => invalid("Scheduled item key must be nonempty")));
|
|
117
|
+
if (isReservedSchedulerKey(key))
|
|
118
|
+
return yield* invalid("Scheduled item key is reserved");
|
|
119
|
+
const lane = descriptors.lane?.value;
|
|
120
|
+
if (typeof lane !== "string" || !lanes.has(lane))
|
|
121
|
+
return yield* invalid("Scheduled item lane is not configured");
|
|
122
|
+
const rawRequest = descriptors.request?.value;
|
|
123
|
+
const request = yield* captureRequest(rawRequest);
|
|
124
|
+
if (request.position !== undefined ||
|
|
125
|
+
request.before !== undefined ||
|
|
126
|
+
request.sameSessionAs !== undefined ||
|
|
127
|
+
request.continueFrom !== undefined ||
|
|
128
|
+
request.sequence !== undefined)
|
|
129
|
+
return yield* invalid("Scheduler owns placement and source selection");
|
|
130
|
+
const rawWindow = descriptors.window?.value;
|
|
131
|
+
const window = rawWindow === undefined
|
|
132
|
+
? undefined
|
|
133
|
+
: yield* Effect.fromResult(ownedData(rawWindow, ["notBefore", "startBy", "firm"], "Scheduled window"));
|
|
134
|
+
const notBeforeOffsetMs = window?.notBefore === undefined
|
|
135
|
+
? undefined
|
|
136
|
+
: yield* requestDuration(window.notBefore, "notBefore", true);
|
|
137
|
+
const startByOffsetMs = window?.startBy === undefined
|
|
138
|
+
? undefined
|
|
139
|
+
: yield* requestDuration(window.startBy, "startBy", true);
|
|
140
|
+
if (window !== undefined &&
|
|
141
|
+
(typeof window.firm !== "boolean" ||
|
|
142
|
+
(notBeforeOffsetMs !== undefined &&
|
|
143
|
+
startByOffsetMs !== undefined &&
|
|
144
|
+
startByOffsetMs >= 0 &&
|
|
145
|
+
notBeforeOffsetMs > startByOffsetMs)))
|
|
146
|
+
return yield* invalid("Scheduled window is inconsistent");
|
|
147
|
+
const rawStart = descriptors.start?.value;
|
|
148
|
+
const start = rawStart === undefined
|
|
149
|
+
? undefined
|
|
150
|
+
: yield* Effect.fromResult(ownedData(rawStart, ["_tag", "time", "late"], "Scheduled start mode"));
|
|
151
|
+
let atWallMs;
|
|
152
|
+
let late = "nextBoundary";
|
|
153
|
+
if (start?._tag === "Follow" && (start.time !== undefined || start.late !== undefined))
|
|
154
|
+
return yield* invalid("Follow cannot contain an anchor or lateness policy");
|
|
155
|
+
if (start !== undefined && start._tag !== "Follow") {
|
|
156
|
+
if (start._tag !== "At" || !Number.isFinite(start.time))
|
|
157
|
+
return yield* invalid("Scheduled start mode is invalid");
|
|
158
|
+
atWallMs = start.time;
|
|
159
|
+
const policy = yield* Effect.fromResult(ownedData(start.late, ["_tag", "by"], "Scheduled lateness policy"));
|
|
160
|
+
if (policy._tag === "drop" && policy.by === undefined)
|
|
161
|
+
late = "drop";
|
|
162
|
+
else if (policy._tag === "skipIfLaterThan")
|
|
163
|
+
late = {
|
|
164
|
+
skipIfLaterThanMs: yield* requestDuration(policy.by, "skipIfLaterThan", true),
|
|
165
|
+
};
|
|
166
|
+
else if (policy._tag !== "nextBoundary" || policy.by !== undefined)
|
|
167
|
+
return yield* invalid("Scheduled lateness policy is invalid");
|
|
168
|
+
}
|
|
169
|
+
return {
|
|
170
|
+
key,
|
|
171
|
+
lane,
|
|
172
|
+
request,
|
|
173
|
+
notBeforeOffsetMs,
|
|
174
|
+
startByOffsetMs,
|
|
175
|
+
firm: window?.firm === true,
|
|
176
|
+
atWallMs,
|
|
177
|
+
late,
|
|
178
|
+
fingerprint: JSON.stringify({
|
|
179
|
+
lane,
|
|
180
|
+
request,
|
|
181
|
+
notBeforeOffsetMs,
|
|
182
|
+
startByOffsetMs,
|
|
183
|
+
firm: window?.firm === true,
|
|
184
|
+
atWallMs,
|
|
185
|
+
late,
|
|
186
|
+
}),
|
|
187
|
+
};
|
|
188
|
+
});
|
|
189
|
+
export const lineup = (filler) => ({
|
|
190
|
+
lanes: [{ name: "line" }],
|
|
191
|
+
filler,
|
|
192
|
+
});
|
|
193
|
+
export class Scheduler extends Context.Service()("reactor-effect-client/Orchestration/Scheduler") {
|
|
194
|
+
static lineup = lineup;
|
|
195
|
+
}
|
|
196
|
+
export const makeScheduler = (options) => Effect.gen(function* () {
|
|
197
|
+
const engine = yield* Engine;
|
|
198
|
+
const clock = yield* Clock.Clock;
|
|
199
|
+
const lanes = yield* parsedInput(() => {
|
|
200
|
+
if (options.lanes.length === 0 ||
|
|
201
|
+
options.lanes.some((lane) => typeof lane.name !== "string" || lane.name.length === 0) ||
|
|
202
|
+
new Set(options.lanes.map((lane) => lane.name)).size !== options.lanes.length)
|
|
203
|
+
throw ReactorError.fromCode("InvalidInput", "Scheduler lanes must be unique and nonempty");
|
|
204
|
+
return options.lanes.map((lane) => lane.name);
|
|
205
|
+
}, "makeScheduler");
|
|
206
|
+
const { floorSeconds, targetSeconds, maxBuildsInFlight, maxHistory, unknownRecoveryMs } = yield* parsedInput(() => {
|
|
207
|
+
const floorSeconds = Duration.toMillis(duration(options.filler.runway.floor, "runway floor", { allowZero: true })) / 1000;
|
|
208
|
+
const targetSeconds = Duration.toMillis(duration(options.filler.runway.target, "runway target")) / 1000;
|
|
209
|
+
const maxBuildsInFlight = options.maxBuildsInFlight ?? 1;
|
|
210
|
+
const maxHistory = options.maxHistory ?? 4096;
|
|
211
|
+
const unknownRecoveryMs = Duration.toMillis(duration(optionDuration(options, "unknownRecoveryTimeout", "Unknown recovery timeout") ??
|
|
212
|
+
"60 seconds", "unknown recovery timeout", { maximum: "10 minutes" }));
|
|
213
|
+
if (floorSeconds > targetSeconds ||
|
|
214
|
+
!Number.isSafeInteger(maxBuildsInFlight) ||
|
|
215
|
+
maxBuildsInFlight < 1 ||
|
|
216
|
+
maxBuildsInFlight > 1024 ||
|
|
217
|
+
!Number.isSafeInteger(maxHistory) ||
|
|
218
|
+
maxHistory < 0 ||
|
|
219
|
+
maxHistory > 65_536)
|
|
220
|
+
throw ReactorError.fromCode("InvalidInput", "Scheduler runway or build cap is invalid");
|
|
221
|
+
return { floorSeconds, targetSeconds, maxBuildsInFlight, maxHistory, unknownRecoveryMs };
|
|
222
|
+
}, "makeScheduler");
|
|
223
|
+
const captureFiller = (index, runway, targetSeconds) => Effect.gen(function* () {
|
|
224
|
+
const request = yield* captureRequest(options.filler.clip({
|
|
225
|
+
index,
|
|
226
|
+
runwaySeconds: runway,
|
|
227
|
+
...(targetSeconds === undefined ? {} : { targetSeconds }),
|
|
228
|
+
}));
|
|
229
|
+
if (request.position !== undefined ||
|
|
230
|
+
request.before !== undefined ||
|
|
231
|
+
request.sameSessionAs !== undefined ||
|
|
232
|
+
request.continueFrom !== undefined ||
|
|
233
|
+
request.sequence !== undefined)
|
|
234
|
+
return yield* PolicyFailure.refuse("InvalidRequest", "Scheduler filler cannot choose placement or source");
|
|
235
|
+
return request;
|
|
236
|
+
});
|
|
237
|
+
let fillerIndex = 0;
|
|
238
|
+
let fillerRetryAtMs = 0;
|
|
239
|
+
const unknownFillers = new Map();
|
|
240
|
+
const maxUnknownFillers = 4096;
|
|
241
|
+
let upcomingFiller = yield* captureFiller(0, 0);
|
|
242
|
+
const inbox = yield* Queue.unbounded();
|
|
243
|
+
const commandQueue = yield* Queue.unbounded();
|
|
244
|
+
const events = yield* PubSub.unbounded();
|
|
245
|
+
yield* Effect.addFinalizer(() => Effect.gen(function* () {
|
|
246
|
+
yield* Queue.shutdown(commandQueue);
|
|
247
|
+
yield* Queue.shutdown(inbox);
|
|
248
|
+
yield* PubSub.shutdown(events);
|
|
249
|
+
}));
|
|
250
|
+
const stateRef = yield* SubscriptionRef.make({
|
|
251
|
+
accepting: true,
|
|
252
|
+
runwaySeconds: 0,
|
|
253
|
+
playing: null,
|
|
254
|
+
lanes: lanes.map((name) => ({ name, keys: [] })),
|
|
255
|
+
sessions: [],
|
|
256
|
+
starved: 0,
|
|
257
|
+
});
|
|
258
|
+
const initialized = yield* Deferred.make();
|
|
259
|
+
const stopped = yield* Deferred.make();
|
|
260
|
+
const settled = yield* Deferred.make();
|
|
261
|
+
const closedCall = Deferred.await(stopped).pipe(Effect.flatMap((cause) => PolicyFailure.refuse("SessionClosed", cause.message)));
|
|
262
|
+
const items = new Map();
|
|
263
|
+
const history = new Map();
|
|
264
|
+
const owned = new Map();
|
|
265
|
+
// An event can overtake its enqueue reply. Keep only the evidence needed to
|
|
266
|
+
// decide that clip's fate until the one in-flight command returns its ID.
|
|
267
|
+
const earlyClipEvents = new Map();
|
|
268
|
+
const removedIds = new Set();
|
|
269
|
+
const filler = new Map();
|
|
270
|
+
const playingStartedMs = new Map();
|
|
271
|
+
let admission = 0;
|
|
272
|
+
let accepting = true;
|
|
273
|
+
let starved = 0;
|
|
274
|
+
let refillActive = false;
|
|
275
|
+
let ended;
|
|
276
|
+
let drainFailure;
|
|
277
|
+
const drainReplies = [];
|
|
278
|
+
const pendingWithdrawals = new Map();
|
|
279
|
+
const pendingBuilds = new Set();
|
|
280
|
+
// A Build whose enqueue has started and whose result the worker has not
|
|
281
|
+
// yet seen. The provider may still accept it after the scheduler ends.
|
|
282
|
+
let dispatching;
|
|
283
|
+
const pendingAtDeferrals = new Set();
|
|
284
|
+
const pendingItemRemovals = new Set();
|
|
285
|
+
const pendingFillerRemovals = new Set();
|
|
286
|
+
const removalRetryAtMs = new Map();
|
|
287
|
+
const removalUnknownAtSerial = new Map();
|
|
288
|
+
let commandCount = 0;
|
|
289
|
+
let snapshotAcquiredSerial = 0;
|
|
290
|
+
let lastProcessedSnapshotSerial = 0;
|
|
291
|
+
let draining = false;
|
|
292
|
+
let finishAccepted = false;
|
|
293
|
+
let autoplayPaused = false;
|
|
294
|
+
let drained = false;
|
|
295
|
+
let observationReady = false;
|
|
296
|
+
let blockedMove;
|
|
297
|
+
const emit = (entry, status, terminal = false) => Effect.gen(function* () {
|
|
298
|
+
if (ended !== undefined && !terminal)
|
|
299
|
+
return;
|
|
300
|
+
if (JSON.stringify(entry.status) === JSON.stringify(status))
|
|
301
|
+
return;
|
|
302
|
+
entry.status = status;
|
|
303
|
+
entry.phase =
|
|
304
|
+
status._tag === "Accepted" ||
|
|
305
|
+
status._tag === "Building" ||
|
|
306
|
+
status._tag === "Ready" ||
|
|
307
|
+
status._tag === "Started"
|
|
308
|
+
? status._tag
|
|
309
|
+
: status._tag === "Unknown" && status.terminal !== true
|
|
310
|
+
? "Unknown"
|
|
311
|
+
: "Terminal";
|
|
312
|
+
// Publish in the step that records it (the PubSub is unbounded): a claim
|
|
313
|
+
// from another fiber cannot land between the two, so asRun never carries
|
|
314
|
+
// a settlement ahead of a recorded status, nor omits one.
|
|
315
|
+
PubSub.publishUnsafe(events, {
|
|
316
|
+
key: entry.key,
|
|
317
|
+
at: clock.currentTimeMillisUnsafe(),
|
|
318
|
+
status,
|
|
319
|
+
});
|
|
320
|
+
if (isFirstDecisive(status))
|
|
321
|
+
yield* Deferred.succeed(entry.firstDecisiveWaiter, status);
|
|
322
|
+
if (status._tag === "Started")
|
|
323
|
+
yield* Deferred.succeed(entry.startedWaiter, status);
|
|
324
|
+
if (status._tag === "Ended" ||
|
|
325
|
+
status._tag === "Dropped" ||
|
|
326
|
+
status._tag === "Failed" ||
|
|
327
|
+
status._tag === "Unobserved" ||
|
|
328
|
+
(status._tag === "Unknown" && status.terminal === true)) {
|
|
329
|
+
yield* Deferred.succeed(entry.startedWaiter, status);
|
|
330
|
+
yield* Deferred.succeed(entry.outcomeWaiter, status);
|
|
331
|
+
}
|
|
332
|
+
});
|
|
333
|
+
// The claim is synchronous; interruption masking alone would still allow
|
|
334
|
+
// two closers to publish conflicting results. Join only local bookkeeping,
|
|
335
|
+
// never the worker scope (a worker can itself own terminal settlement).
|
|
336
|
+
const terminate = (terminal) => Effect.suspend(() => {
|
|
337
|
+
if (ended !== undefined)
|
|
338
|
+
return Deferred.await(settled);
|
|
339
|
+
ended = terminal;
|
|
340
|
+
accepting = false;
|
|
341
|
+
// Wake the watchdog so an episode left armed by another owner cannot
|
|
342
|
+
// keep re-arming its timer.
|
|
343
|
+
Deferred.doneUnsafe(recoveryChanged, Exit.void);
|
|
344
|
+
return Effect.gen(function* () {
|
|
345
|
+
for (const item of items.values()) {
|
|
346
|
+
if (Exit.isFailure(terminal)) {
|
|
347
|
+
yield* Deferred.failCause(item.startedWaiter, terminal.cause);
|
|
348
|
+
yield* Deferred.failCause(item.outcomeWaiter, terminal.cause);
|
|
349
|
+
yield* Deferred.failCause(item.firstDecisiveWaiter, terminal.cause);
|
|
350
|
+
continue;
|
|
351
|
+
}
|
|
352
|
+
// emit records and publishes a status before completing its waits,
|
|
353
|
+
// and a claim can land in between: a completion can resume an
|
|
354
|
+
// observer that closes the owner, and another fiber can claim while
|
|
355
|
+
// the actor is preempted. Finish those waits from the record.
|
|
356
|
+
const recorded = item.status;
|
|
357
|
+
if (item.phase === "Started" || item.phase === "Terminal") {
|
|
358
|
+
if (isFirstDecisive(recorded))
|
|
359
|
+
yield* Deferred.succeed(item.firstDecisiveWaiter, recorded);
|
|
360
|
+
yield* Deferred.succeed(item.startedWaiter, recorded);
|
|
361
|
+
}
|
|
362
|
+
if (item.phase === "Terminal")
|
|
363
|
+
yield* Deferred.succeed(item.outcomeWaiter, recorded);
|
|
364
|
+
else
|
|
365
|
+
yield* emit(item, item.phase === "Unknown" ||
|
|
366
|
+
(item.clipId === undefined &&
|
|
367
|
+
(item.unknownAtMs !== undefined || item.key === dispatching))
|
|
368
|
+
? { _tag: "Unknown", terminal: true }
|
|
369
|
+
: { _tag: "Failed", reason: { _tag: "Scheduler", cause: terminal.value } }, true);
|
|
370
|
+
}
|
|
371
|
+
const closed = Exit.isSuccess(terminal)
|
|
372
|
+
? Exit.fail(PolicyFailure.refuse("SessionClosed", terminal.value.message))
|
|
373
|
+
: Exit.failCause(terminal.cause);
|
|
374
|
+
for (const [key, pending] of pendingWithdrawals) {
|
|
375
|
+
// A drop recorded before the claim is the withdrawal's actual result.
|
|
376
|
+
const reply = Exit.isSuccess(terminal) && items.get(key)?.status._tag === "Dropped"
|
|
377
|
+
? Exit.succeed("withdrawn")
|
|
378
|
+
: closed;
|
|
379
|
+
for (const waiter of pending.replies)
|
|
380
|
+
yield* Deferred.done(waiter, reply);
|
|
381
|
+
}
|
|
382
|
+
pendingWithdrawals.clear();
|
|
383
|
+
for (const reply of drainReplies.splice(0))
|
|
384
|
+
yield* Deferred.done(reply, closed);
|
|
385
|
+
yield* SubscriptionRef.update(stateRef, (state) => ({ ...state, accepting: false }));
|
|
386
|
+
yield* Deferred.done(initialized, Exit.isFailure(terminal)
|
|
387
|
+
? Exit.failCause(terminal.cause)
|
|
388
|
+
: Exit.fail(ReactorError.fromCode("Closed", terminal.value.message)));
|
|
389
|
+
// Supervisors may close the owner immediately when this wakes them.
|
|
390
|
+
yield* Deferred.done(stopped, terminal);
|
|
391
|
+
}).pipe(Effect.onExit((exit) => Effect.gen(function* () {
|
|
392
|
+
// A bookkeeping defect must release joiners with that Cause, never
|
|
393
|
+
// leave an unfinished latch or report a partial settlement as success.
|
|
394
|
+
yield* Deferred.done(settled, exit);
|
|
395
|
+
if (Exit.isFailure(exit)) {
|
|
396
|
+
yield* Deferred.failCause(initialized, exit.cause);
|
|
397
|
+
yield* Deferred.failCause(stopped, exit.cause);
|
|
398
|
+
}
|
|
399
|
+
})));
|
|
400
|
+
}).pipe(Effect.uninterruptible);
|
|
401
|
+
const closeActor = (cause) => terminate(Exit.succeed(cause));
|
|
402
|
+
let recoveryState;
|
|
403
|
+
let capacityRecovery;
|
|
404
|
+
let recoveryDeadline;
|
|
405
|
+
let recoveryChanged = Deferred.makeUnsafe();
|
|
406
|
+
// Retain the original age while Closing hides the old source and acquisition
|
|
407
|
+
// is still pending. Only accepted evidence or a Ready replacement clears it.
|
|
408
|
+
const updateRecovery = (state = recoveryState, fresh) => {
|
|
409
|
+
if (ended !== undefined)
|
|
410
|
+
return;
|
|
411
|
+
recoveryState = state;
|
|
412
|
+
const preferred = state === undefined ? undefined : Option.getOrUndefined(state.preferredSessionId);
|
|
413
|
+
const ready = preferred !== undefined &&
|
|
414
|
+
state?.sessions.some((source) => source.sessionId === preferred && source.availability === "Ready") === true;
|
|
415
|
+
let earliest;
|
|
416
|
+
let drainDeadline;
|
|
417
|
+
const consider = (atMs, sessionId, cause, newlyObserved = false) => {
|
|
418
|
+
const deadlineMs = atMs + unknownRecoveryMs;
|
|
419
|
+
if (((!ready && newlyObserved) || sessionId === undefined || sessionId === preferred) &&
|
|
420
|
+
(earliest === undefined || deadlineMs < earliest.deadlineMs))
|
|
421
|
+
earliest = { deadlineMs, sessionId, cause };
|
|
422
|
+
if (draining && (drainDeadline === undefined || deadlineMs < drainDeadline.deadlineMs))
|
|
423
|
+
drainDeadline = { deadlineMs, sessionId, cause };
|
|
424
|
+
};
|
|
425
|
+
for (const entry of unknownFillers.values())
|
|
426
|
+
consider(entry.atMs, entry.sessionId, entry.cause);
|
|
427
|
+
for (const item of items.values())
|
|
428
|
+
if (item.phase !== "Terminal" &&
|
|
429
|
+
item.clipId === undefined &&
|
|
430
|
+
item.unknownAtMs !== undefined &&
|
|
431
|
+
item.unknownCause !== undefined)
|
|
432
|
+
consider(item.unknownAtMs, item.unknownSessionId, item.unknownCause);
|
|
433
|
+
if (fresh !== undefined)
|
|
434
|
+
consider(fresh.atMs, fresh.sessionId, fresh.cause, true);
|
|
435
|
+
if (ready &&
|
|
436
|
+
(earliest === undefined ||
|
|
437
|
+
(capacityRecovery?.sessionId !== undefined && capacityRecovery.sessionId !== preferred)))
|
|
438
|
+
capacityRecovery = undefined;
|
|
439
|
+
if (earliest !== undefined &&
|
|
440
|
+
(capacityRecovery === undefined || earliest.deadlineMs < capacityRecovery.deadlineMs))
|
|
441
|
+
capacityRecovery = earliest;
|
|
442
|
+
const next = drainDeadline !== undefined &&
|
|
443
|
+
(capacityRecovery === undefined || drainDeadline.deadlineMs < capacityRecovery.deadlineMs)
|
|
444
|
+
? drainDeadline
|
|
445
|
+
: capacityRecovery;
|
|
446
|
+
if (next?.deadlineMs !== recoveryDeadline?.deadlineMs ||
|
|
447
|
+
next?.cause !== recoveryDeadline?.cause) {
|
|
448
|
+
recoveryDeadline = next;
|
|
449
|
+
Deferred.doneUnsafe(recoveryChanged, Exit.void);
|
|
450
|
+
}
|
|
451
|
+
};
|
|
452
|
+
const provedUnknown = (atMs) => {
|
|
453
|
+
if (atMs !== undefined && capacityRecovery?.deadlineMs === atMs + unknownRecoveryMs)
|
|
454
|
+
capacityRecovery = undefined;
|
|
455
|
+
};
|
|
456
|
+
const watchdog = Effect.gen(function* () {
|
|
457
|
+
for (;;) {
|
|
458
|
+
// A committed result and its queued actor evidence can arrive in one
|
|
459
|
+
// turn. Coalesce those revisions before allocating a replacement timer;
|
|
460
|
+
// the original monotonic deadline remains unchanged.
|
|
461
|
+
yield* Effect.yieldNow;
|
|
462
|
+
if (ended !== undefined)
|
|
463
|
+
return;
|
|
464
|
+
recoveryChanged = Deferred.makeUnsafe();
|
|
465
|
+
const episode = recoveryDeadline;
|
|
466
|
+
if (episode === undefined) {
|
|
467
|
+
yield* Deferred.await(recoveryChanged);
|
|
468
|
+
continue;
|
|
469
|
+
}
|
|
470
|
+
yield* Deferred.await(recoveryChanged).pipe(Effect.timeoutOrElse({
|
|
471
|
+
duration: Math.max(0, episode.deadlineMs - monotonicMillis(clock)),
|
|
472
|
+
orElse: () => Effect.void,
|
|
473
|
+
}));
|
|
474
|
+
// A canceled timer may already have resumed. Recheck the retained
|
|
475
|
+
// identity without awaiting the actor, Engine state, or its permits.
|
|
476
|
+
if (ended === undefined &&
|
|
477
|
+
recoveryDeadline === episode &&
|
|
478
|
+
monotonicMillis(clock) >= episode.deadlineMs) {
|
|
479
|
+
const causes = new Set([episode.cause]);
|
|
480
|
+
for (const item of items.values())
|
|
481
|
+
if (item.unknownCause !== undefined)
|
|
482
|
+
causes.add(item.unknownCause);
|
|
483
|
+
for (const entry of unknownFillers.values())
|
|
484
|
+
causes.add(entry.cause);
|
|
485
|
+
yield* closeActor(ReactorError.fromCode("Timeout", "Scheduler unknown recovery deadline elapsed", {
|
|
486
|
+
operation: "scheduler.unknownRecovery",
|
|
487
|
+
detail: [...causes],
|
|
488
|
+
}));
|
|
489
|
+
return;
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
});
|
|
493
|
+
const prune = (state) => {
|
|
494
|
+
const active = new Set(activeIds(state));
|
|
495
|
+
if (state.availability === "Ready") {
|
|
496
|
+
for (const id of removedIds)
|
|
497
|
+
if (!active.has(id))
|
|
498
|
+
removedIds.delete(id);
|
|
499
|
+
for (const id of removalRetryAtMs.keys())
|
|
500
|
+
if (!active.has(id))
|
|
501
|
+
removalRetryAtMs.delete(id);
|
|
502
|
+
for (const id of removalUnknownAtSerial.keys())
|
|
503
|
+
if (!active.has(id))
|
|
504
|
+
removalUnknownAtSerial.delete(id);
|
|
505
|
+
}
|
|
506
|
+
for (const [key, item] of items) {
|
|
507
|
+
if (item.phase !== "Terminal" ||
|
|
508
|
+
pendingBuilds.has(key) ||
|
|
509
|
+
pendingWithdrawals.has(key) ||
|
|
510
|
+
pendingAtDeferrals.has(key) ||
|
|
511
|
+
pendingItemRemovals.has(key) ||
|
|
512
|
+
(item.clipId !== undefined && active.has(item.clipId)))
|
|
513
|
+
continue;
|
|
514
|
+
if (item.clipId !== undefined) {
|
|
515
|
+
owned.delete(item.clipId);
|
|
516
|
+
playingStartedMs.delete(item.clipId);
|
|
517
|
+
}
|
|
518
|
+
history.set(key, { fingerprint: item.fingerprint, handle: item.handle });
|
|
519
|
+
items.delete(key);
|
|
520
|
+
}
|
|
521
|
+
while (history.size > maxHistory)
|
|
522
|
+
history.delete(history.keys().next().value);
|
|
523
|
+
};
|
|
524
|
+
// Capture membership and public rows in one pass. A fresh token reads every
|
|
525
|
+
// source on every observation, including in-place changes to Engine arrays;
|
|
526
|
+
// frozen empty rows can be shared by every published state without treating
|
|
527
|
+
// array identity as proof.
|
|
528
|
+
const noReady = Object.freeze([]);
|
|
529
|
+
const sourceRows = new Map();
|
|
530
|
+
const projectSources = (state) => {
|
|
531
|
+
const seen = Symbol();
|
|
532
|
+
const sessions = state.sessions.map(({ sessionId }) => {
|
|
533
|
+
let cached = sourceRows.get(sessionId);
|
|
534
|
+
if (cached === undefined) {
|
|
535
|
+
cached = { empty: Object.freeze({ sessionId, ready: noReady }), seen };
|
|
536
|
+
sourceRows.set(sessionId, cached);
|
|
537
|
+
}
|
|
538
|
+
else
|
|
539
|
+
cached.seen = seen;
|
|
540
|
+
if (state.ready.length === 0)
|
|
541
|
+
return cached.empty;
|
|
542
|
+
return {
|
|
543
|
+
sessionId,
|
|
544
|
+
ready: state.ready
|
|
545
|
+
.filter((clip) => clip.sessionId === sessionId)
|
|
546
|
+
.map((clip) => {
|
|
547
|
+
const owner = owned.get(clip.clipId);
|
|
548
|
+
return owner === undefined ? "other" : owner._tag === "Filler" ? "filler" : owner.key;
|
|
549
|
+
}),
|
|
550
|
+
};
|
|
551
|
+
});
|
|
552
|
+
// Bound cached rows by snapshot size without scanning unchanged overlap.
|
|
553
|
+
// Membership still requires this observation's token, even before pruning.
|
|
554
|
+
if (sourceRows.size > state.sessions.length)
|
|
555
|
+
for (const [id, cached] of sourceRows)
|
|
556
|
+
if (cached.seen !== seen)
|
|
557
|
+
sourceRows.delete(id);
|
|
558
|
+
// Only the serialized actor uses this membership view, before its next projection.
|
|
559
|
+
return { sessions, has: (id) => sourceRows.get(id)?.seen === seen };
|
|
560
|
+
};
|
|
561
|
+
const publishState = (state, sessions = projectSources(state).sessions) => {
|
|
562
|
+
const playing = Option.getOrUndefined(state.playing);
|
|
563
|
+
const playingOwner = playing === undefined ? undefined : owned.get(playing.clipId);
|
|
564
|
+
const playingKey = playing === undefined
|
|
565
|
+
? null
|
|
566
|
+
: playingOwner === undefined
|
|
567
|
+
? "other"
|
|
568
|
+
: playingOwner._tag === "Filler"
|
|
569
|
+
? "filler"
|
|
570
|
+
: playingOwner.key;
|
|
571
|
+
return SubscriptionRef.set(stateRef, {
|
|
572
|
+
accepting,
|
|
573
|
+
runwaySeconds: runwaySeconds(state, monotonicMillis(clock), playingStartedMs, owned, [
|
|
574
|
+
...items.values(),
|
|
575
|
+
]),
|
|
576
|
+
playing: playingKey,
|
|
577
|
+
lanes: lanes.map((name) => ({
|
|
578
|
+
name,
|
|
579
|
+
keys: [...items.values()]
|
|
580
|
+
.filter((item) => item.lane === name && item.phase !== "Terminal")
|
|
581
|
+
.map((item) => item.key),
|
|
582
|
+
})),
|
|
583
|
+
sessions,
|
|
584
|
+
starved,
|
|
585
|
+
});
|
|
586
|
+
};
|
|
587
|
+
const activeRecords = (state) => [
|
|
588
|
+
...state.queued,
|
|
589
|
+
...state.ready,
|
|
590
|
+
...Option.match(state.building, {
|
|
591
|
+
onNone: () => [],
|
|
592
|
+
onSome: (build) => [build.record],
|
|
593
|
+
}),
|
|
594
|
+
...Option.match(state.playing, {
|
|
595
|
+
onNone: () => [],
|
|
596
|
+
onSome: (playing) => Option.match(playing.record, {
|
|
597
|
+
onNone: () => [],
|
|
598
|
+
onSome: (record) => [record],
|
|
599
|
+
}),
|
|
600
|
+
}),
|
|
601
|
+
];
|
|
602
|
+
const knownRecord = (state, clipId) => activeRecords(state).find((record) => record.clipId === clipId);
|
|
603
|
+
const refreshAnchors = () => {
|
|
604
|
+
const nowMs = monotonicMillis(clock);
|
|
605
|
+
const wallMs = clock.currentTimeMillisUnsafe();
|
|
606
|
+
for (const item of items.values())
|
|
607
|
+
if (item.atWallMs !== undefined)
|
|
608
|
+
item.atMs = nowMs + item.atWallMs - wallMs;
|
|
609
|
+
};
|
|
610
|
+
const observed = (state, recoverMissing = false) => Effect.gen(function* () {
|
|
611
|
+
const active = new Set(activeIds(state));
|
|
612
|
+
if (recoverMissing) {
|
|
613
|
+
for (const [clipId, owner] of owned) {
|
|
614
|
+
if (owner._tag !== "Filler" || active.has(clipId))
|
|
615
|
+
continue;
|
|
616
|
+
const tracked = filler.get(owner.index);
|
|
617
|
+
if (tracked?.clipId === clipId &&
|
|
618
|
+
tracked?.acknowledgedAtSnapshotSerial !== undefined &&
|
|
619
|
+
lastProcessedSnapshotSerial <= tracked.acknowledgedAtSnapshotSerial)
|
|
620
|
+
continue;
|
|
621
|
+
const sessionId = owner.sessionId;
|
|
622
|
+
const source = state.sessions.find((entry) => entry.sessionId === sessionId);
|
|
623
|
+
if (sessionId === undefined
|
|
624
|
+
? state.availability !== "Ready"
|
|
625
|
+
: source !== undefined && source.availability !== "Ready")
|
|
626
|
+
continue;
|
|
627
|
+
owned.delete(clipId);
|
|
628
|
+
if (tracked?.clipId === clipId)
|
|
629
|
+
filler.delete(owner.index);
|
|
630
|
+
playingStartedMs.delete(clipId);
|
|
631
|
+
pendingFillerRemovals.delete(clipId);
|
|
632
|
+
}
|
|
633
|
+
}
|
|
634
|
+
const keyed = new Map();
|
|
635
|
+
for (const record of activeRecords(state)) {
|
|
636
|
+
const key = keyFromProviderMetadata(record.provider.metadata);
|
|
637
|
+
if (key !== undefined && !removedIds.has(record.clipId))
|
|
638
|
+
keyed.set(key, record);
|
|
639
|
+
const index = fillerIndexFromKey(key);
|
|
640
|
+
if (index === undefined || removedIds.has(record.clipId))
|
|
641
|
+
continue;
|
|
642
|
+
const existing = owned.get(record.clipId);
|
|
643
|
+
if (existing?._tag === "Filler") {
|
|
644
|
+
if (existing.sessionId !== record.sessionId)
|
|
645
|
+
owned.set(record.clipId, { ...existing, sessionId: record.sessionId });
|
|
646
|
+
const tracked = filler.get(index);
|
|
647
|
+
if (tracked?.clipId === record.clipId)
|
|
648
|
+
tracked.sessionId = record.sessionId;
|
|
649
|
+
continue;
|
|
650
|
+
}
|
|
651
|
+
if (existing !== undefined)
|
|
652
|
+
continue;
|
|
653
|
+
owned.set(record.clipId, { _tag: "Filler", index, sessionId: record.sessionId });
|
|
654
|
+
filler.set(index, { index, clipId: record.clipId, sessionId: record.sessionId });
|
|
655
|
+
const priorIndex = fillerIndex;
|
|
656
|
+
fillerIndex = Math.max(fillerIndex, index + 1);
|
|
657
|
+
provedUnknown(unknownFillers.get(index)?.atMs);
|
|
658
|
+
unknownFillers.delete(index);
|
|
659
|
+
if (fillerIndex !== priorIndex)
|
|
660
|
+
upcomingFiller = undefined;
|
|
661
|
+
}
|
|
662
|
+
for (const item of items.values()) {
|
|
663
|
+
if (item.phase === "Terminal")
|
|
664
|
+
continue;
|
|
665
|
+
if (item.clipId === undefined) {
|
|
666
|
+
const resumed = keyed.get(item.key);
|
|
667
|
+
if (resumed !== undefined) {
|
|
668
|
+
item.clipId = resumed.clipId;
|
|
669
|
+
item.sessionId = resumed.sessionId;
|
|
670
|
+
provedUnknown(item.unknownAtMs);
|
|
671
|
+
item.unknownAtMs = undefined;
|
|
672
|
+
item.unknownCause = undefined;
|
|
673
|
+
item.unknownSessionId = undefined;
|
|
674
|
+
owned.set(resumed.clipId, { _tag: "Item", key: item.key });
|
|
675
|
+
}
|
|
676
|
+
}
|
|
677
|
+
const clipId = item.clipId;
|
|
678
|
+
if (clipId === undefined)
|
|
679
|
+
continue;
|
|
680
|
+
const playing = Option.getOrUndefined(state.playing);
|
|
681
|
+
if (playing?.clipId === clipId) {
|
|
682
|
+
const record = Option.getOrUndefined(playing.record);
|
|
683
|
+
const at = Option.getOrUndefined(playing.startedAt);
|
|
684
|
+
const startedAtMonotonicMillis = playing.startedAtMonotonicMillis;
|
|
685
|
+
if (record !== undefined &&
|
|
686
|
+
at !== undefined &&
|
|
687
|
+
startedAtMonotonicMillis !== undefined &&
|
|
688
|
+
item.phase !== "Started") {
|
|
689
|
+
item.sessionId = record.sessionId;
|
|
690
|
+
item.startedAtEpochMs = at;
|
|
691
|
+
item.startedAtMonoMs = startedAtMonotonicMillis;
|
|
692
|
+
playingStartedMs.set(clipId, startedAtMonotonicMillis);
|
|
693
|
+
yield* emit(item, {
|
|
694
|
+
_tag: "Started",
|
|
695
|
+
at,
|
|
696
|
+
sessionId: record.sessionId,
|
|
697
|
+
durationSeconds: record.durationSeconds,
|
|
698
|
+
...((item.atMs ?? item.startByMs) === undefined ||
|
|
699
|
+
startedAtMonotonicMillis <= (item.atMs ?? item.startByMs)
|
|
700
|
+
? {}
|
|
701
|
+
: { lateByMillis: startedAtMonotonicMillis - (item.atMs ?? item.startByMs) }),
|
|
702
|
+
});
|
|
703
|
+
}
|
|
704
|
+
else if (recoverMissing &&
|
|
705
|
+
(record === undefined || at === undefined) &&
|
|
706
|
+
item.phase !== "Started")
|
|
707
|
+
yield* emit(item, { _tag: "Unobserved" });
|
|
708
|
+
}
|
|
709
|
+
else if (state.ready.some((record) => record.clipId === clipId)) {
|
|
710
|
+
const record = knownRecord(state, clipId);
|
|
711
|
+
item.sessionId = record.sessionId;
|
|
712
|
+
if (item.phase !== "Started")
|
|
713
|
+
yield* emit(item, { _tag: "Ready", sessionId: record.sessionId });
|
|
714
|
+
}
|
|
715
|
+
else if (state.queued.some((record) => record.clipId === clipId) ||
|
|
716
|
+
state.generationOrder.includes(clipId) ||
|
|
717
|
+
Option.getOrUndefined(state.building)?.record.clipId === clipId) {
|
|
718
|
+
item.sessionId = knownRecord(state, clipId)?.sessionId ?? item.sessionId;
|
|
719
|
+
if (item.phase === "Accepted" || item.phase === "Unknown")
|
|
720
|
+
yield* emit(item, { _tag: "Building" });
|
|
721
|
+
}
|
|
722
|
+
else if (state.failed.includes(clipId))
|
|
723
|
+
yield* emit(item, {
|
|
724
|
+
_tag: "Failed",
|
|
725
|
+
reason: { _tag: "Clip", message: "The clip failed unobserved" },
|
|
726
|
+
});
|
|
727
|
+
else if (recoverMissing &&
|
|
728
|
+
item.sessionId !== undefined &&
|
|
729
|
+
(state.sessions.find((source) => source.sessionId === item.sessionId)?.availability ??
|
|
730
|
+
"Ready") === "Ready" &&
|
|
731
|
+
!active.has(clipId) &&
|
|
732
|
+
(item.acknowledgedAtSnapshotSerial === undefined ||
|
|
733
|
+
lastProcessedSnapshotSerial > item.acknowledgedAtSnapshotSerial))
|
|
734
|
+
yield* emit(item, { _tag: "Unobserved" });
|
|
735
|
+
}
|
|
736
|
+
updateRecovery(state);
|
|
737
|
+
});
|
|
738
|
+
const onEvent = (event) => Effect.gen(function* () {
|
|
739
|
+
if (event._tag === "Starved")
|
|
740
|
+
starved++;
|
|
741
|
+
if (event._tag === "SessionFailed") {
|
|
742
|
+
yield* closeActor(event.failure);
|
|
743
|
+
return;
|
|
744
|
+
}
|
|
745
|
+
if (!("clipId" in event))
|
|
746
|
+
return;
|
|
747
|
+
if (commandCount > 0 &&
|
|
748
|
+
(event._tag === "Started" || event._tag === "Ended" || event._tag === "Failed")) {
|
|
749
|
+
const prior = earlyClipEvents.get(event.clipId);
|
|
750
|
+
if (prior === undefined) {
|
|
751
|
+
if (earlyClipEvents.size >= maxEarlyClipIds) {
|
|
752
|
+
// A lost pre-ack start cannot be recast as a definite failure.
|
|
753
|
+
for (const key of pendingBuilds) {
|
|
754
|
+
const item = items.get(key);
|
|
755
|
+
if (item !== undefined && item.phase !== "Terminal" && item.phase !== "Started") {
|
|
756
|
+
yield* emit(item, { _tag: "Unknown", terminal: true });
|
|
757
|
+
item.phase = "Terminal";
|
|
758
|
+
}
|
|
759
|
+
}
|
|
760
|
+
yield* closeActor(ReactorError.fromCode("Overflow", "Unattributed clip evidence exceeded its bound"));
|
|
761
|
+
return;
|
|
762
|
+
}
|
|
763
|
+
earlyClipEvents.set(event.clipId, [event]);
|
|
764
|
+
}
|
|
765
|
+
else if (!prior.some((seen) => event._tag === "Started"
|
|
766
|
+
? seen._tag === "Started"
|
|
767
|
+
: seen._tag === "Ended" || seen._tag === "Failed"))
|
|
768
|
+
prior.push(event);
|
|
769
|
+
}
|
|
770
|
+
const owner = owned.get(event.clipId);
|
|
771
|
+
if (owner === undefined)
|
|
772
|
+
return;
|
|
773
|
+
if (owner._tag === "Filler") {
|
|
774
|
+
if (event._tag === "Started")
|
|
775
|
+
playingStartedMs.set(event.clipId, event.atMonotonicMillis ?? monotonicMillis(clock));
|
|
776
|
+
if (event._tag === "Ended" || event._tag === "Failed") {
|
|
777
|
+
owned.delete(event.clipId);
|
|
778
|
+
if (filler.get(owner.index)?.clipId === event.clipId)
|
|
779
|
+
filler.delete(owner.index);
|
|
780
|
+
playingStartedMs.delete(event.clipId);
|
|
781
|
+
}
|
|
782
|
+
return;
|
|
783
|
+
}
|
|
784
|
+
const item = items.get(owner.key);
|
|
785
|
+
if (item === undefined || item.phase === "Terminal")
|
|
786
|
+
return;
|
|
787
|
+
switch (event._tag) {
|
|
788
|
+
case "Queued":
|
|
789
|
+
case "Building":
|
|
790
|
+
if (item.phase === "Accepted" || item.phase === "Unknown")
|
|
791
|
+
yield* emit(item, { _tag: "Building" });
|
|
792
|
+
break;
|
|
793
|
+
case "Ready": {
|
|
794
|
+
const record = knownRecord(yield* engine.state, event.clipId);
|
|
795
|
+
if (record !== undefined) {
|
|
796
|
+
item.sessionId = record.sessionId;
|
|
797
|
+
if (item.phase !== "Started")
|
|
798
|
+
yield* emit(item, { _tag: "Ready", sessionId: record.sessionId });
|
|
799
|
+
}
|
|
800
|
+
break;
|
|
801
|
+
}
|
|
802
|
+
case "Started": {
|
|
803
|
+
if (item.phase === "Started")
|
|
804
|
+
break;
|
|
805
|
+
const state = yield* engine.state;
|
|
806
|
+
const record = knownRecord(state, event.clipId);
|
|
807
|
+
const sessionId = record?.sessionId ?? item.sessionId;
|
|
808
|
+
if (sessionId === undefined)
|
|
809
|
+
yield* emit(item, { _tag: "Unobserved" });
|
|
810
|
+
else {
|
|
811
|
+
item.sessionId = sessionId;
|
|
812
|
+
const playing = Option.getOrUndefined(state.playing);
|
|
813
|
+
item.startedAtMonoMs =
|
|
814
|
+
event.atMonotonicMillis ??
|
|
815
|
+
(playing?.clipId === event.clipId
|
|
816
|
+
? (playing.startedAtMonotonicMillis ?? monotonicMillis(clock))
|
|
817
|
+
: monotonicMillis(clock));
|
|
818
|
+
item.startedAtEpochMs = event.at;
|
|
819
|
+
playingStartedMs.set(event.clipId, item.startedAtMonoMs);
|
|
820
|
+
yield* emit(item, {
|
|
821
|
+
_tag: "Started",
|
|
822
|
+
at: event.at,
|
|
823
|
+
sessionId,
|
|
824
|
+
durationSeconds: event.durationSeconds,
|
|
825
|
+
...((item.atMs ?? item.startByMs) === undefined ||
|
|
826
|
+
item.startedAtMonoMs <= (item.atMs ?? item.startByMs)
|
|
827
|
+
? {}
|
|
828
|
+
: { lateByMillis: item.startedAtMonoMs - (item.atMs ?? item.startByMs) }),
|
|
829
|
+
});
|
|
830
|
+
}
|
|
831
|
+
break;
|
|
832
|
+
}
|
|
833
|
+
case "Ended":
|
|
834
|
+
yield* emit(item, item.startedAtMonoMs === undefined
|
|
835
|
+
? { _tag: "Unobserved" }
|
|
836
|
+
: {
|
|
837
|
+
_tag: "Ended",
|
|
838
|
+
at: event.at ?? clock.currentTimeMillisUnsafe(),
|
|
839
|
+
termination: event.termination,
|
|
840
|
+
airedSeconds: Math.max(0, ((event.atMonotonicMillis ?? monotonicMillis(clock)) - item.startedAtMonoMs) /
|
|
841
|
+
1000),
|
|
842
|
+
});
|
|
843
|
+
playingStartedMs.delete(event.clipId);
|
|
844
|
+
break;
|
|
845
|
+
case "Failed":
|
|
846
|
+
yield* emit(item, { _tag: "Failed", reason: { _tag: "Clip", message: event.reason } });
|
|
847
|
+
break;
|
|
848
|
+
default:
|
|
849
|
+
break;
|
|
850
|
+
}
|
|
851
|
+
});
|
|
852
|
+
const replayEarlyClipEvents = (clipId) => Effect.gen(function* () {
|
|
853
|
+
const saved = earlyClipEvents.get(clipId);
|
|
854
|
+
earlyClipEvents.delete(clipId);
|
|
855
|
+
if (saved === undefined)
|
|
856
|
+
return;
|
|
857
|
+
for (const event of saved)
|
|
858
|
+
yield* onEvent(event);
|
|
859
|
+
});
|
|
860
|
+
const executeCommand = (command) => {
|
|
861
|
+
switch (command._tag) {
|
|
862
|
+
case "Build":
|
|
863
|
+
case "BuildFiller":
|
|
864
|
+
return Effect.gen(function* () {
|
|
865
|
+
if (command.sessionId === undefined ||
|
|
866
|
+
Option.getOrUndefined((yield* engine.state).preferredSessionId) !== command.sessionId)
|
|
867
|
+
return yield* PolicyFailure.refuse("RouteChanged", "The preferred source changed before dispatch");
|
|
868
|
+
// Termination can be claimed while the route is read; its settlement
|
|
869
|
+
// must not be followed by a new dispatch.
|
|
870
|
+
if (ended !== undefined)
|
|
871
|
+
return yield* PolicyFailure.refuse("SessionClosed", "The scheduler closed");
|
|
872
|
+
if (command._tag === "Build")
|
|
873
|
+
dispatching = command.key;
|
|
874
|
+
return yield* engine.enqueueOnSource === undefined
|
|
875
|
+
? engine.enqueue(command.request)
|
|
876
|
+
: engine.enqueueOnSource(command.request, command.sessionId);
|
|
877
|
+
});
|
|
878
|
+
case "RemoveItem":
|
|
879
|
+
case "RemoveFiller":
|
|
880
|
+
case "RemoveDuplicate":
|
|
881
|
+
case "DeferAt":
|
|
882
|
+
return engine.remove(command.clipId);
|
|
883
|
+
case "Order":
|
|
884
|
+
return engine.move(command.clipId, command.position, "playout");
|
|
885
|
+
case "PauseAutoplay":
|
|
886
|
+
return engine.setAutoplay(false);
|
|
887
|
+
}
|
|
888
|
+
};
|
|
889
|
+
const commandWorker = Effect.gen(function* () {
|
|
890
|
+
for (;;) {
|
|
891
|
+
const command = yield* takeQueue(commandQueue);
|
|
892
|
+
if (ended !== undefined)
|
|
893
|
+
continue;
|
|
894
|
+
const result = yield* Effect.result(executeCommand(command));
|
|
895
|
+
dispatching = undefined;
|
|
896
|
+
// Capture uncertainty before actor delivery: drain may be awaiting a
|
|
897
|
+
// permit in stopRenewal while this committed command finishes.
|
|
898
|
+
if (ended === undefined &&
|
|
899
|
+
Result.isFailure(result) &&
|
|
900
|
+
result.failure.context.outcome === "unknown") {
|
|
901
|
+
let fresh;
|
|
902
|
+
const sessionId = engine.enqueueOnSource === undefined ||
|
|
903
|
+
(command._tag !== "Build" && command._tag !== "BuildFiller")
|
|
904
|
+
? undefined
|
|
905
|
+
: command.sessionId;
|
|
906
|
+
if (command._tag === "Build") {
|
|
907
|
+
const item = items.get(command.key);
|
|
908
|
+
if (item !== undefined && item.phase !== "Terminal" && item.clipId === undefined) {
|
|
909
|
+
item.unknownAtMs ??= monotonicMillis(clock);
|
|
910
|
+
item.unknownCause = result.failure;
|
|
911
|
+
item.unknownSessionId = sessionId;
|
|
912
|
+
fresh = { sessionId, atMs: item.unknownAtMs, cause: result.failure };
|
|
913
|
+
}
|
|
914
|
+
}
|
|
915
|
+
else if (command._tag === "BuildFiller" &&
|
|
916
|
+
!filler.has(command.index) &&
|
|
917
|
+
!unknownFillers.has(command.index)) {
|
|
918
|
+
fresh = { sessionId, atMs: monotonicMillis(clock), cause: result.failure };
|
|
919
|
+
unknownFillers.set(command.index, { ...fresh, index: command.index });
|
|
920
|
+
}
|
|
921
|
+
updateRecovery(recoveryState, fresh);
|
|
922
|
+
}
|
|
923
|
+
yield* Queue.offer(inbox, { _tag: "CommandDone", command, result });
|
|
924
|
+
}
|
|
925
|
+
});
|
|
926
|
+
const sendCommand = (command) => Effect.gen(function* () {
|
|
927
|
+
if (ended !== undefined)
|
|
928
|
+
return;
|
|
929
|
+
if (command._tag === "Build")
|
|
930
|
+
pendingBuilds.add(command.key);
|
|
931
|
+
if (command._tag === "DeferAt")
|
|
932
|
+
pendingAtDeferrals.add(command.key);
|
|
933
|
+
commandCount++;
|
|
934
|
+
yield* Queue.offer(commandQueue, command);
|
|
935
|
+
});
|
|
936
|
+
const canRetryRemoval = (clipId) => monotonicMillis(clock) >= (removalRetryAtMs.get(clipId) ?? 0) &&
|
|
937
|
+
(removalUnknownAtSerial.get(clipId) === undefined ||
|
|
938
|
+
lastProcessedSnapshotSerial > removalUnknownAtSerial.get(clipId));
|
|
939
|
+
const finishWithdrawal = (key, result) => Effect.gen(function* () {
|
|
940
|
+
const pending = pendingWithdrawals.get(key);
|
|
941
|
+
pendingWithdrawals.delete(key);
|
|
942
|
+
if (pending === undefined)
|
|
943
|
+
return;
|
|
944
|
+
for (const reply of pending.replies)
|
|
945
|
+
if (Result.isSuccess(result))
|
|
946
|
+
yield* Deferred.succeed(reply, result.success);
|
|
947
|
+
else
|
|
948
|
+
yield* Deferred.fail(reply, result.failure);
|
|
949
|
+
});
|
|
950
|
+
const sendItemRemoval = (item, reason) => Effect.gen(function* () {
|
|
951
|
+
if (item.clipId === undefined ||
|
|
952
|
+
pendingItemRemovals.has(item.key) ||
|
|
953
|
+
!canRetryRemoval(item.clipId))
|
|
954
|
+
return;
|
|
955
|
+
pendingItemRemovals.add(item.key);
|
|
956
|
+
yield* sendCommand({ _tag: "RemoveItem", key: item.key, clipId: item.clipId, reason });
|
|
957
|
+
});
|
|
958
|
+
const requestWithdrawal = (item, reason, reply) => Effect.gen(function* () {
|
|
959
|
+
if (item.phase === "Started" || item.phase === "Terminal") {
|
|
960
|
+
if (reply !== undefined)
|
|
961
|
+
yield* Deferred.succeed(reply, item.phase === "Started" ? "already-started" : "not-found");
|
|
962
|
+
return;
|
|
963
|
+
}
|
|
964
|
+
const pending = pendingWithdrawals.get(item.key);
|
|
965
|
+
if (pending !== undefined) {
|
|
966
|
+
if (reply !== undefined)
|
|
967
|
+
pending.replies.push(reply);
|
|
968
|
+
return;
|
|
969
|
+
}
|
|
970
|
+
pendingWithdrawals.set(item.key, { reason, replies: reply === undefined ? [] : [reply] });
|
|
971
|
+
if (pendingAtDeferrals.has(item.key))
|
|
972
|
+
return;
|
|
973
|
+
if (item.clipId === undefined) {
|
|
974
|
+
if (item.phase === "Unknown" || pendingBuilds.has(item.key))
|
|
975
|
+
return;
|
|
976
|
+
yield* emit(item, { _tag: "Dropped", reason });
|
|
977
|
+
yield* finishWithdrawal(item.key, Result.succeed("withdrawn"));
|
|
978
|
+
return;
|
|
979
|
+
}
|
|
980
|
+
yield* sendItemRemoval(item, reason);
|
|
981
|
+
});
|
|
982
|
+
const applyPolicy = (action, state) => Effect.gen(function* () {
|
|
983
|
+
switch (action._tag) {
|
|
984
|
+
case "Withdraw": {
|
|
985
|
+
const item = items.get(action.key);
|
|
986
|
+
if (item !== undefined)
|
|
987
|
+
yield* requestWithdrawal(item, action.reason);
|
|
988
|
+
break;
|
|
989
|
+
}
|
|
990
|
+
case "DeferAt":
|
|
991
|
+
if (canRetryRemoval(action.clipId))
|
|
992
|
+
yield* sendCommand({ _tag: "DeferAt", key: action.key, clipId: action.clipId });
|
|
993
|
+
break;
|
|
994
|
+
case "WithdrawFiller":
|
|
995
|
+
for (const clipId of action.clipIds)
|
|
996
|
+
if (!pendingFillerRemovals.has(clipId) && canRetryRemoval(clipId)) {
|
|
997
|
+
pendingFillerRemovals.add(clipId);
|
|
998
|
+
yield* sendCommand({ _tag: "RemoveFiller", clipId });
|
|
999
|
+
}
|
|
1000
|
+
break;
|
|
1001
|
+
case "Order": {
|
|
1002
|
+
const signature = state.ready.map((record) => record.clipId).join(",") +
|
|
1003
|
+
":" +
|
|
1004
|
+
action.clipId +
|
|
1005
|
+
":" +
|
|
1006
|
+
action.position;
|
|
1007
|
+
if (blockedMove !== signature)
|
|
1008
|
+
yield* sendCommand({
|
|
1009
|
+
_tag: "Order",
|
|
1010
|
+
clipId: action.clipId,
|
|
1011
|
+
position: action.position,
|
|
1012
|
+
signature,
|
|
1013
|
+
});
|
|
1014
|
+
break;
|
|
1015
|
+
}
|
|
1016
|
+
case "Build": {
|
|
1017
|
+
const item = items.get(action.key);
|
|
1018
|
+
if (item?.phase !== "Accepted")
|
|
1019
|
+
break;
|
|
1020
|
+
const prepared = yield* Effect.result(keyedRequest(item.request, item.key));
|
|
1021
|
+
if (Result.isFailure(prepared)) {
|
|
1022
|
+
yield* emit(item, {
|
|
1023
|
+
_tag: "Failed",
|
|
1024
|
+
reason: { _tag: "Command", cause: prepared.failure },
|
|
1025
|
+
});
|
|
1026
|
+
break;
|
|
1027
|
+
}
|
|
1028
|
+
yield* sendCommand({
|
|
1029
|
+
_tag: "Build",
|
|
1030
|
+
key: item.key,
|
|
1031
|
+
request: prepared.success,
|
|
1032
|
+
sessionId: Option.getOrUndefined(state.preferredSessionId),
|
|
1033
|
+
});
|
|
1034
|
+
break;
|
|
1035
|
+
}
|
|
1036
|
+
case "BuildFiller": {
|
|
1037
|
+
if (unknownFillers.size >= maxUnknownFillers) {
|
|
1038
|
+
yield* closeActor(ReactorError.fromCode("Overflow", "Scheduler uncertain filler ledger is full", {
|
|
1039
|
+
operation: "scheduler.unknownRecovery",
|
|
1040
|
+
}));
|
|
1041
|
+
break;
|
|
1042
|
+
}
|
|
1043
|
+
const captured = upcomingFiller === undefined
|
|
1044
|
+
? yield* Effect.result(captureFiller(fillerIndex, runwaySeconds(state, monotonicMillis(clock), playingStartedMs, owned, [
|
|
1045
|
+
...items.values(),
|
|
1046
|
+
]), action.targetSeconds))
|
|
1047
|
+
: Result.succeed(upcomingFiller);
|
|
1048
|
+
if (Result.isFailure(captured)) {
|
|
1049
|
+
yield* closeActor(captured.failure);
|
|
1050
|
+
break;
|
|
1051
|
+
}
|
|
1052
|
+
upcomingFiller = captured.success;
|
|
1053
|
+
const prepared = yield* Effect.result(keyedRequest(captured.success, fillerKey(fillerIndex)));
|
|
1054
|
+
if (Result.isFailure(prepared)) {
|
|
1055
|
+
yield* closeActor(prepared.failure);
|
|
1056
|
+
break;
|
|
1057
|
+
}
|
|
1058
|
+
yield* sendCommand({
|
|
1059
|
+
_tag: "BuildFiller",
|
|
1060
|
+
index: fillerIndex,
|
|
1061
|
+
request: prepared.success,
|
|
1062
|
+
sessionId: Option.getOrUndefined(state.preferredSessionId),
|
|
1063
|
+
});
|
|
1064
|
+
break;
|
|
1065
|
+
}
|
|
1066
|
+
}
|
|
1067
|
+
});
|
|
1068
|
+
const resolveRetiredUnknown = (item) => Effect.gen(function* () {
|
|
1069
|
+
if (item.phase !== "Unknown" || item.clipId !== undefined)
|
|
1070
|
+
return;
|
|
1071
|
+
item.unknownSessionId = undefined;
|
|
1072
|
+
// A retired source cannot play this key again, but it may have played
|
|
1073
|
+
// during a missed observation. Preserve Unknown instead of replaying it.
|
|
1074
|
+
yield* emit(item, { _tag: "Unknown", terminal: true });
|
|
1075
|
+
item.phase = "Terminal";
|
|
1076
|
+
const pending = pendingWithdrawals.get(item.key);
|
|
1077
|
+
if (pending !== undefined)
|
|
1078
|
+
yield* finishWithdrawal(item.key, Result.fail(item.unknownCause ??
|
|
1079
|
+
PolicyFailure.refuse("SessionRecovering", "The original source retired without an enqueue result")));
|
|
1080
|
+
});
|
|
1081
|
+
const handleCommandDone = (command, result) => Effect.gen(function* () {
|
|
1082
|
+
commandCount--;
|
|
1083
|
+
if (ended !== undefined) {
|
|
1084
|
+
if (command._tag === "Build")
|
|
1085
|
+
pendingBuilds.delete(command.key);
|
|
1086
|
+
if (command._tag === "DeferAt")
|
|
1087
|
+
pendingAtDeferrals.delete(command.key);
|
|
1088
|
+
if (command._tag === "RemoveItem")
|
|
1089
|
+
pendingItemRemovals.delete(command.key);
|
|
1090
|
+
if (command._tag === "RemoveFiller")
|
|
1091
|
+
pendingFillerRemovals.delete(command.clipId);
|
|
1092
|
+
return;
|
|
1093
|
+
}
|
|
1094
|
+
switch (command._tag) {
|
|
1095
|
+
case "Build": {
|
|
1096
|
+
const item = items.get(command.key);
|
|
1097
|
+
pendingBuilds.delete(command.key);
|
|
1098
|
+
if (item === undefined)
|
|
1099
|
+
break;
|
|
1100
|
+
if (Result.isSuccess(result)) {
|
|
1101
|
+
const clipId = result.success;
|
|
1102
|
+
if (item.clipId !== undefined && item.clipId !== clipId) {
|
|
1103
|
+
yield* sendCommand({ _tag: "RemoveDuplicate", clipId });
|
|
1104
|
+
break;
|
|
1105
|
+
}
|
|
1106
|
+
item.clipId = clipId;
|
|
1107
|
+
item.acknowledgedAtSnapshotSerial = snapshotAcquiredSerial;
|
|
1108
|
+
owned.set(clipId, { _tag: "Item", key: item.key });
|
|
1109
|
+
item.sessionId =
|
|
1110
|
+
knownRecord(yield* engine.state, clipId)?.sessionId ??
|
|
1111
|
+
(engine.enqueueOnSource === undefined ? undefined : command.sessionId);
|
|
1112
|
+
provedUnknown(item.unknownAtMs);
|
|
1113
|
+
item.unknownAtMs = undefined;
|
|
1114
|
+
item.unknownSessionId = undefined;
|
|
1115
|
+
item.unknownCause = undefined;
|
|
1116
|
+
yield* replayEarlyClipEvents(clipId);
|
|
1117
|
+
const pending = pendingWithdrawals.get(item.key);
|
|
1118
|
+
if (pending !== undefined) {
|
|
1119
|
+
if (item.phase === "Started" ||
|
|
1120
|
+
item.status._tag === "Ended" ||
|
|
1121
|
+
item.status._tag === "Unobserved")
|
|
1122
|
+
yield* finishWithdrawal(item.key, Result.succeed("already-started"));
|
|
1123
|
+
else if (item.phase === "Terminal")
|
|
1124
|
+
yield* finishWithdrawal(item.key, Result.succeed("not-found"));
|
|
1125
|
+
else
|
|
1126
|
+
yield* sendItemRemoval(item, pending.reason);
|
|
1127
|
+
}
|
|
1128
|
+
else if (item.phase === "Accepted" || item.phase === "Unknown")
|
|
1129
|
+
yield* emit(item, { _tag: "Building" });
|
|
1130
|
+
yield* observed(yield* engine.state);
|
|
1131
|
+
}
|
|
1132
|
+
else if (result.failure.context.outcome === "unknown") {
|
|
1133
|
+
if (item.clipId === undefined) {
|
|
1134
|
+
// A generic engine may re-route between the state read and dispatch.
|
|
1135
|
+
// Only an owner-fenced enqueue can attribute its unknown result.
|
|
1136
|
+
item.unknownSessionId =
|
|
1137
|
+
engine.enqueueOnSource === undefined ? undefined : command.sessionId;
|
|
1138
|
+
item.unknownCause = result.failure;
|
|
1139
|
+
yield* emit(item, { _tag: "Unknown" });
|
|
1140
|
+
}
|
|
1141
|
+
}
|
|
1142
|
+
else {
|
|
1143
|
+
const pending = pendingWithdrawals.get(item.key);
|
|
1144
|
+
if (pending !== undefined) {
|
|
1145
|
+
yield* emit(item, { _tag: "Dropped", reason: pending.reason });
|
|
1146
|
+
yield* finishWithdrawal(item.key, Result.succeed("withdrawn"));
|
|
1147
|
+
}
|
|
1148
|
+
else if (result.failure.context.outcome !== "not-submitted" ||
|
|
1149
|
+
!["QueueFull", "SessionRecovering", "RouteChanged"].includes(result.failure.reason._tag))
|
|
1150
|
+
yield* emit(item, {
|
|
1151
|
+
_tag: "Failed",
|
|
1152
|
+
reason: { _tag: "Command", cause: result.failure },
|
|
1153
|
+
});
|
|
1154
|
+
else
|
|
1155
|
+
item.retryAtMs = monotonicMillis(clock) + 1_000;
|
|
1156
|
+
}
|
|
1157
|
+
break;
|
|
1158
|
+
}
|
|
1159
|
+
case "BuildFiller": {
|
|
1160
|
+
if (Result.isSuccess(result)) {
|
|
1161
|
+
const clipId = result.success;
|
|
1162
|
+
const actualSessionId = knownRecord(yield* engine.state, clipId)?.sessionId ??
|
|
1163
|
+
(engine.enqueueOnSource === undefined ? undefined : command.sessionId);
|
|
1164
|
+
owned.set(clipId, {
|
|
1165
|
+
_tag: "Filler",
|
|
1166
|
+
index: command.index,
|
|
1167
|
+
sessionId: actualSessionId,
|
|
1168
|
+
});
|
|
1169
|
+
filler.set(command.index, {
|
|
1170
|
+
index: command.index,
|
|
1171
|
+
clipId,
|
|
1172
|
+
sessionId: actualSessionId,
|
|
1173
|
+
acknowledgedAtSnapshotSerial: snapshotAcquiredSerial,
|
|
1174
|
+
});
|
|
1175
|
+
fillerIndex = Math.max(fillerIndex, command.index + 1);
|
|
1176
|
+
upcomingFiller = undefined;
|
|
1177
|
+
yield* replayEarlyClipEvents(clipId);
|
|
1178
|
+
}
|
|
1179
|
+
else if (result.failure.context.outcome === "unknown") {
|
|
1180
|
+
// The worker already retained this identity and its original age.
|
|
1181
|
+
}
|
|
1182
|
+
else {
|
|
1183
|
+
fillerRetryAtMs = monotonicMillis(clock) + 1_000;
|
|
1184
|
+
if (result.failure.context.outcome === "replied") {
|
|
1185
|
+
fillerIndex = Math.max(fillerIndex, command.index + 1);
|
|
1186
|
+
upcomingFiller = undefined;
|
|
1187
|
+
}
|
|
1188
|
+
}
|
|
1189
|
+
yield* observed(yield* engine.state);
|
|
1190
|
+
break;
|
|
1191
|
+
}
|
|
1192
|
+
case "RemoveItem": {
|
|
1193
|
+
pendingItemRemovals.delete(command.key);
|
|
1194
|
+
const item = items.get(command.key);
|
|
1195
|
+
if (Result.isSuccess(result)) {
|
|
1196
|
+
removalRetryAtMs.delete(command.clipId);
|
|
1197
|
+
removalUnknownAtSerial.delete(command.clipId);
|
|
1198
|
+
removedIds.add(command.clipId);
|
|
1199
|
+
owned.delete(command.clipId);
|
|
1200
|
+
playingStartedMs.delete(command.clipId);
|
|
1201
|
+
if (item?.phase === "Started")
|
|
1202
|
+
yield* finishWithdrawal(command.key, Result.succeed("already-started"));
|
|
1203
|
+
else {
|
|
1204
|
+
if (item !== undefined && item.phase !== "Terminal")
|
|
1205
|
+
yield* emit(item, { _tag: "Dropped", reason: command.reason });
|
|
1206
|
+
yield* finishWithdrawal(command.key, Result.succeed("withdrawn"));
|
|
1207
|
+
}
|
|
1208
|
+
}
|
|
1209
|
+
else {
|
|
1210
|
+
removalRetryAtMs.set(command.clipId, monotonicMillis(clock) + 1_000);
|
|
1211
|
+
if (result.failure.context.outcome === "unknown")
|
|
1212
|
+
removalUnknownAtSerial.set(command.clipId, snapshotAcquiredSerial);
|
|
1213
|
+
if (item?.phase === "Started")
|
|
1214
|
+
yield* finishWithdrawal(command.key, Result.succeed("already-started"));
|
|
1215
|
+
else
|
|
1216
|
+
yield* finishWithdrawal(command.key, Result.fail(result.failure));
|
|
1217
|
+
}
|
|
1218
|
+
break;
|
|
1219
|
+
}
|
|
1220
|
+
case "RemoveFiller": {
|
|
1221
|
+
pendingFillerRemovals.delete(command.clipId);
|
|
1222
|
+
if (Result.isSuccess(result)) {
|
|
1223
|
+
removalRetryAtMs.delete(command.clipId);
|
|
1224
|
+
removalUnknownAtSerial.delete(command.clipId);
|
|
1225
|
+
removedIds.add(command.clipId);
|
|
1226
|
+
const owner = owned.get(command.clipId);
|
|
1227
|
+
if (owner?._tag === "Filler" && filler.get(owner.index)?.clipId === command.clipId)
|
|
1228
|
+
filler.delete(owner.index);
|
|
1229
|
+
owned.delete(command.clipId);
|
|
1230
|
+
playingStartedMs.delete(command.clipId);
|
|
1231
|
+
}
|
|
1232
|
+
else {
|
|
1233
|
+
removalRetryAtMs.set(command.clipId, monotonicMillis(clock) + 1_000);
|
|
1234
|
+
if (result.failure.context.outcome === "unknown")
|
|
1235
|
+
removalUnknownAtSerial.set(command.clipId, snapshotAcquiredSerial);
|
|
1236
|
+
}
|
|
1237
|
+
break;
|
|
1238
|
+
}
|
|
1239
|
+
case "RemoveDuplicate":
|
|
1240
|
+
if (Result.isFailure(result))
|
|
1241
|
+
yield* closeActor(result.failure);
|
|
1242
|
+
break;
|
|
1243
|
+
case "DeferAt": {
|
|
1244
|
+
pendingAtDeferrals.delete(command.key);
|
|
1245
|
+
const item = items.get(command.key);
|
|
1246
|
+
if (Result.isFailure(result)) {
|
|
1247
|
+
removalRetryAtMs.set(command.clipId, monotonicMillis(clock) + 1_000);
|
|
1248
|
+
if (result.failure.context.outcome === "unknown")
|
|
1249
|
+
removalUnknownAtSerial.set(command.clipId, snapshotAcquiredSerial);
|
|
1250
|
+
break;
|
|
1251
|
+
}
|
|
1252
|
+
removalRetryAtMs.delete(command.clipId);
|
|
1253
|
+
removalUnknownAtSerial.delete(command.clipId);
|
|
1254
|
+
owned.delete(command.clipId);
|
|
1255
|
+
removedIds.add(command.clipId);
|
|
1256
|
+
playingStartedMs.delete(command.clipId);
|
|
1257
|
+
if (item === undefined || item.phase === "Started" || item.phase === "Terminal")
|
|
1258
|
+
break;
|
|
1259
|
+
delete item.clipId;
|
|
1260
|
+
item.sessionId = undefined;
|
|
1261
|
+
const pending = pendingWithdrawals.get(command.key);
|
|
1262
|
+
if (pending !== undefined) {
|
|
1263
|
+
yield* emit(item, { _tag: "Dropped", reason: pending.reason });
|
|
1264
|
+
yield* finishWithdrawal(command.key, Result.succeed("withdrawn"));
|
|
1265
|
+
}
|
|
1266
|
+
else
|
|
1267
|
+
yield* emit(item, { _tag: "Accepted" });
|
|
1268
|
+
break;
|
|
1269
|
+
}
|
|
1270
|
+
case "Order":
|
|
1271
|
+
blockedMove = Result.isFailure(result) ? command.signature : undefined;
|
|
1272
|
+
break;
|
|
1273
|
+
case "PauseAutoplay":
|
|
1274
|
+
if (Result.isFailure(result)) {
|
|
1275
|
+
drainFailure = result.failure;
|
|
1276
|
+
const replies = drainReplies.splice(0);
|
|
1277
|
+
for (const reply of replies)
|
|
1278
|
+
yield* Deferred.fail(reply, result.failure);
|
|
1279
|
+
}
|
|
1280
|
+
break;
|
|
1281
|
+
}
|
|
1282
|
+
});
|
|
1283
|
+
// An accepted drain still owes air to every line not yet Ready, and to a
|
|
1284
|
+
// Ready line held for a future At anchor, which runway must reach. Filler
|
|
1285
|
+
// is the only material that can cover either, so it keeps playing and
|
|
1286
|
+
// refilling until nothing accepted can still leave the host frozen. An
|
|
1287
|
+
// Unknown item does not count: without a fenced source it can stay open
|
|
1288
|
+
// until the scheduler closes, and filler for it would never stop.
|
|
1289
|
+
const fillerHeld = (nowMs) => draining &&
|
|
1290
|
+
finishAccepted &&
|
|
1291
|
+
[...items.values()].some((item) => item.phase === "Accepted" ||
|
|
1292
|
+
item.phase === "Building" ||
|
|
1293
|
+
(item.phase === "Ready" && item.atMs !== undefined && item.atMs > nowMs));
|
|
1294
|
+
const drainPending = (state) => Effect.gen(function* () {
|
|
1295
|
+
if (!draining)
|
|
1296
|
+
return;
|
|
1297
|
+
const playingId = Option.getOrUndefined(state.playing)?.clipId;
|
|
1298
|
+
for (const item of items.values()) {
|
|
1299
|
+
if (finishAccepted)
|
|
1300
|
+
break;
|
|
1301
|
+
if (item.phase === "Terminal" || item.phase === "Started")
|
|
1302
|
+
continue;
|
|
1303
|
+
if (item.clipId === playingId)
|
|
1304
|
+
continue;
|
|
1305
|
+
yield* requestWithdrawal(item, "withdrawn");
|
|
1306
|
+
}
|
|
1307
|
+
if (fillerHeld(monotonicMillis(clock)))
|
|
1308
|
+
return;
|
|
1309
|
+
for (const entry of filler.values()) {
|
|
1310
|
+
if (entry.clipId !== undefined &&
|
|
1311
|
+
entry.clipId !== playingId &&
|
|
1312
|
+
!pendingFillerRemovals.has(entry.clipId) &&
|
|
1313
|
+
canRetryRemoval(entry.clipId)) {
|
|
1314
|
+
pendingFillerRemovals.add(entry.clipId);
|
|
1315
|
+
yield* sendCommand({ _tag: "RemoveFiller", clipId: entry.clipId });
|
|
1316
|
+
}
|
|
1317
|
+
}
|
|
1318
|
+
});
|
|
1319
|
+
const projectedStartMs = (state, lane, nowMs) => {
|
|
1320
|
+
const playing = Option.getOrUndefined(state.playing);
|
|
1321
|
+
const playingRecord = playing === undefined ? undefined : Option.getOrUndefined(playing.record);
|
|
1322
|
+
const started = playing === undefined ? undefined : playingStartedMs.get(playing.clipId);
|
|
1323
|
+
const restMs = playingRecord === undefined || started === undefined
|
|
1324
|
+
? 0
|
|
1325
|
+
: Math.max(0, playingRecord.durationSeconds * 1000 - (nowMs - started));
|
|
1326
|
+
const laneRank = lanes.indexOf(lane);
|
|
1327
|
+
const preferred = Option.getOrUndefined(state.preferredSessionId);
|
|
1328
|
+
const aheadMs = state.ready.reduce((total, record) => {
|
|
1329
|
+
if (record.sessionId !== preferred)
|
|
1330
|
+
return total;
|
|
1331
|
+
const owner = owned.get(record.clipId);
|
|
1332
|
+
if (owner?._tag === "Filler")
|
|
1333
|
+
return total;
|
|
1334
|
+
if (owner?._tag === "Item") {
|
|
1335
|
+
const prior = items.get(owner.key);
|
|
1336
|
+
if (prior !== undefined && lanes.indexOf(prior.lane) > laneRank)
|
|
1337
|
+
return total;
|
|
1338
|
+
}
|
|
1339
|
+
return total + record.durationSeconds * 1000;
|
|
1340
|
+
}, 0);
|
|
1341
|
+
return nowMs + restMs + aheadMs;
|
|
1342
|
+
};
|
|
1343
|
+
const reconcile = Effect.gen(function* () {
|
|
1344
|
+
const state = yield* engine.state;
|
|
1345
|
+
if (ended !== undefined)
|
|
1346
|
+
return;
|
|
1347
|
+
prune(state);
|
|
1348
|
+
refreshAnchors();
|
|
1349
|
+
const sources = projectSources(state);
|
|
1350
|
+
if (!observationReady) {
|
|
1351
|
+
yield* publishState(state, sources.sessions);
|
|
1352
|
+
return;
|
|
1353
|
+
}
|
|
1354
|
+
for (const uncertain of unknownFillers.values()) {
|
|
1355
|
+
const sourceRetired = uncertain.sessionId !== undefined && !sources.has(uncertain.sessionId);
|
|
1356
|
+
if (sourceRetired)
|
|
1357
|
+
unknownFillers.delete(uncertain.index);
|
|
1358
|
+
if (sourceRetired ||
|
|
1359
|
+
(uncertain.sessionId !== undefined &&
|
|
1360
|
+
Option.getOrUndefined(state.preferredSessionId) !== uncertain.sessionId)) {
|
|
1361
|
+
if (fillerIndex <= uncertain.index) {
|
|
1362
|
+
fillerIndex = uncertain.index + 1;
|
|
1363
|
+
upcomingFiller = undefined;
|
|
1364
|
+
}
|
|
1365
|
+
}
|
|
1366
|
+
}
|
|
1367
|
+
for (const [key, pending] of pendingWithdrawals) {
|
|
1368
|
+
const item = items.get(key);
|
|
1369
|
+
if (item === undefined)
|
|
1370
|
+
continue;
|
|
1371
|
+
if (item.phase === "Started")
|
|
1372
|
+
yield* finishWithdrawal(key, Result.succeed("already-started"));
|
|
1373
|
+
else if (item.clipId !== undefined)
|
|
1374
|
+
yield* sendItemRemoval(item, pending.reason);
|
|
1375
|
+
}
|
|
1376
|
+
for (const item of items.values())
|
|
1377
|
+
if (item.phase === "Unknown" &&
|
|
1378
|
+
item.clipId === undefined &&
|
|
1379
|
+
item.unknownSessionId !== undefined &&
|
|
1380
|
+
!sources.has(item.unknownSessionId))
|
|
1381
|
+
yield* resolveRetiredUnknown(item);
|
|
1382
|
+
// After retirement, so a deadline no longer outlives the uncertainty that
|
|
1383
|
+
// set it until the next message.
|
|
1384
|
+
updateRecovery(state);
|
|
1385
|
+
if (draining)
|
|
1386
|
+
yield* drainPending(state);
|
|
1387
|
+
const preferred = Option.getOrUndefined(state.preferredSessionId);
|
|
1388
|
+
let unknownFillerCount = 0;
|
|
1389
|
+
for (const entry of unknownFillers.values())
|
|
1390
|
+
if (entry.sessionId === undefined || entry.sessionId === preferred)
|
|
1391
|
+
unknownFillerCount++;
|
|
1392
|
+
const decision = plan({
|
|
1393
|
+
engine: state,
|
|
1394
|
+
items: [...items.values()],
|
|
1395
|
+
owned,
|
|
1396
|
+
lanes,
|
|
1397
|
+
nowMs: monotonicMillis(clock),
|
|
1398
|
+
playingStartedMs,
|
|
1399
|
+
floorSeconds,
|
|
1400
|
+
targetSeconds,
|
|
1401
|
+
refillActive,
|
|
1402
|
+
maxBuildsInFlight,
|
|
1403
|
+
accepting: accepting || (draining && finishAccepted),
|
|
1404
|
+
fillerEnabled: !draining || fillerHeld(monotonicMillis(clock)),
|
|
1405
|
+
fillerRetryAtMs,
|
|
1406
|
+
unknownFillerCount,
|
|
1407
|
+
blockedMove,
|
|
1408
|
+
});
|
|
1409
|
+
refillActive = decision.refillActive;
|
|
1410
|
+
yield* publishState(state, sources.sessions);
|
|
1411
|
+
if ((!draining || finishAccepted) && commandCount === 0 && decision.action !== undefined)
|
|
1412
|
+
yield* applyPolicy(decision.action, state);
|
|
1413
|
+
if (draining &&
|
|
1414
|
+
finishAccepted &&
|
|
1415
|
+
!autoplayPaused &&
|
|
1416
|
+
commandCount === 0 &&
|
|
1417
|
+
[...items.values()].every((item) => item.phase === "Terminal")) {
|
|
1418
|
+
autoplayPaused = true;
|
|
1419
|
+
yield* sendCommand({ _tag: "PauseAutoplay" });
|
|
1420
|
+
}
|
|
1421
|
+
if (drainFailure === undefined &&
|
|
1422
|
+
drainReplies.length > 0 &&
|
|
1423
|
+
commandCount === 0 &&
|
|
1424
|
+
[...items.values()].every((item) => item.phase === "Terminal") &&
|
|
1425
|
+
pendingWithdrawals.size === 0 &&
|
|
1426
|
+
unknownFillers.size === 0) {
|
|
1427
|
+
const after = yield* engine.state;
|
|
1428
|
+
if (Option.isNone(after.playing) && !activeIds(after).some((clipId) => owned.has(clipId))) {
|
|
1429
|
+
drained = true;
|
|
1430
|
+
// A truthful completed drain no longer needs a replacement to resume
|
|
1431
|
+
// service, even when retirement left a capacity episode behind.
|
|
1432
|
+
capacityRecovery = undefined;
|
|
1433
|
+
updateRecovery(after);
|
|
1434
|
+
const replies = drainReplies.splice(0);
|
|
1435
|
+
for (const reply of replies)
|
|
1436
|
+
yield* Deferred.succeed(reply, undefined);
|
|
1437
|
+
}
|
|
1438
|
+
}
|
|
1439
|
+
});
|
|
1440
|
+
const actor = Effect.gen(function* () {
|
|
1441
|
+
for (;;) {
|
|
1442
|
+
const message = yield* takeQueue(inbox);
|
|
1443
|
+
// Queued public calls observe the same terminal result through closedCall.
|
|
1444
|
+
if (ended !== undefined && message._tag !== "CommandDone")
|
|
1445
|
+
continue;
|
|
1446
|
+
switch (message._tag) {
|
|
1447
|
+
case "Submit": {
|
|
1448
|
+
if (!accepting) {
|
|
1449
|
+
yield* Deferred.fail(message.reply, invalid("Scheduler is draining or closed"));
|
|
1450
|
+
break;
|
|
1451
|
+
}
|
|
1452
|
+
const existing = items.get(message.item.key) ?? history.get(message.item.key);
|
|
1453
|
+
if (existing !== undefined) {
|
|
1454
|
+
if (existing.fingerprint === message.item.fingerprint)
|
|
1455
|
+
yield* Deferred.succeed(message.reply, existing.handle);
|
|
1456
|
+
else
|
|
1457
|
+
yield* Deferred.fail(message.reply, KeyMismatch.of(message.item.key));
|
|
1458
|
+
break;
|
|
1459
|
+
}
|
|
1460
|
+
const nowMs = monotonicMillis(clock);
|
|
1461
|
+
const state = yield* engine.state;
|
|
1462
|
+
if (ended !== undefined)
|
|
1463
|
+
continue;
|
|
1464
|
+
const startByMs = message.item.startByOffsetMs === undefined
|
|
1465
|
+
? undefined
|
|
1466
|
+
: nowMs + message.item.startByOffsetMs;
|
|
1467
|
+
const projectedMs = projectedStartMs(state, message.item.lane, nowMs);
|
|
1468
|
+
if (startByMs !== undefined && projectedMs > startByMs) {
|
|
1469
|
+
yield* Deferred.fail(message.reply, WouldMissDeadline.of(message.item.key));
|
|
1470
|
+
break;
|
|
1471
|
+
}
|
|
1472
|
+
// No yield from the terminal check above through admission and its
|
|
1473
|
+
// publication below: a claim either precedes the item or settles it
|
|
1474
|
+
// after its Accepted evidence.
|
|
1475
|
+
const startedWaiter = Deferred.makeUnsafe();
|
|
1476
|
+
const outcomeWaiter = Deferred.makeUnsafe();
|
|
1477
|
+
const firstDecisiveWaiter = Deferred.makeUnsafe();
|
|
1478
|
+
const handle = {
|
|
1479
|
+
key: message.item.key,
|
|
1480
|
+
started: Deferred.await(startedWaiter),
|
|
1481
|
+
outcome: Deferred.await(outcomeWaiter),
|
|
1482
|
+
firstDecisive: Deferred.await(firstDecisiveWaiter),
|
|
1483
|
+
};
|
|
1484
|
+
const item = {
|
|
1485
|
+
key: message.item.key,
|
|
1486
|
+
lane: message.item.lane,
|
|
1487
|
+
request: message.item.request,
|
|
1488
|
+
fingerprint: message.item.fingerprint,
|
|
1489
|
+
admission: ++admission,
|
|
1490
|
+
phase: "Accepted",
|
|
1491
|
+
status: { _tag: "Accepted" },
|
|
1492
|
+
...(message.item.notBeforeOffsetMs === undefined
|
|
1493
|
+
? {}
|
|
1494
|
+
: { notBeforeMs: nowMs + message.item.notBeforeOffsetMs }),
|
|
1495
|
+
...(startByMs === undefined ? {} : { startByMs }),
|
|
1496
|
+
firm: message.item.firm,
|
|
1497
|
+
...(message.item.atWallMs === undefined
|
|
1498
|
+
? {}
|
|
1499
|
+
: {
|
|
1500
|
+
atWallMs: message.item.atWallMs,
|
|
1501
|
+
atMs: nowMs + message.item.atWallMs - clock.currentTimeMillisUnsafe(),
|
|
1502
|
+
}),
|
|
1503
|
+
late: message.item.late,
|
|
1504
|
+
sessionId: undefined,
|
|
1505
|
+
unknownSessionId: undefined,
|
|
1506
|
+
unknownCause: undefined,
|
|
1507
|
+
unknownAtMs: undefined,
|
|
1508
|
+
handle,
|
|
1509
|
+
startedWaiter,
|
|
1510
|
+
outcomeWaiter,
|
|
1511
|
+
firstDecisiveWaiter,
|
|
1512
|
+
};
|
|
1513
|
+
items.set(item.key, item);
|
|
1514
|
+
PubSub.publishUnsafe(events, {
|
|
1515
|
+
key: item.key,
|
|
1516
|
+
at: clock.currentTimeMillisUnsafe(),
|
|
1517
|
+
status: item.status,
|
|
1518
|
+
});
|
|
1519
|
+
yield* observed(state);
|
|
1520
|
+
// Submit returns its committed view; other messages publish once
|
|
1521
|
+
// through reconciliation after all their evidence is adopted.
|
|
1522
|
+
yield* publishState(state);
|
|
1523
|
+
yield* Deferred.succeed(message.reply, handle);
|
|
1524
|
+
break;
|
|
1525
|
+
}
|
|
1526
|
+
case "Withdraw": {
|
|
1527
|
+
const item = items.get(message.key);
|
|
1528
|
+
if (item === undefined)
|
|
1529
|
+
yield* Deferred.succeed(message.reply, "not-found");
|
|
1530
|
+
else
|
|
1531
|
+
yield* requestWithdrawal(item, "withdrawn", message.reply);
|
|
1532
|
+
break;
|
|
1533
|
+
}
|
|
1534
|
+
case "Drain": {
|
|
1535
|
+
if (drainFailure !== undefined) {
|
|
1536
|
+
yield* Deferred.fail(message.reply, drainFailure);
|
|
1537
|
+
break;
|
|
1538
|
+
}
|
|
1539
|
+
accepting = false;
|
|
1540
|
+
if (drained) {
|
|
1541
|
+
yield* Deferred.succeed(message.reply, undefined);
|
|
1542
|
+
break;
|
|
1543
|
+
}
|
|
1544
|
+
drainReplies.push(message.reply);
|
|
1545
|
+
if (!draining) {
|
|
1546
|
+
draining = true;
|
|
1547
|
+
finishAccepted = message.finish === "accepted";
|
|
1548
|
+
updateRecovery();
|
|
1549
|
+
// Renewal admission must stop even while an earlier enqueue is
|
|
1550
|
+
// awaiting its reply in the command worker.
|
|
1551
|
+
const stoppedRenewal = yield* Effect.result(engine.stopRenewal);
|
|
1552
|
+
if (ended !== undefined)
|
|
1553
|
+
break;
|
|
1554
|
+
if (Result.isFailure(stoppedRenewal)) {
|
|
1555
|
+
drainFailure = stoppedRenewal.failure;
|
|
1556
|
+
for (const reply of drainReplies.splice(0))
|
|
1557
|
+
yield* Deferred.fail(reply, stoppedRenewal.failure);
|
|
1558
|
+
break;
|
|
1559
|
+
}
|
|
1560
|
+
if (!finishAccepted) {
|
|
1561
|
+
autoplayPaused = true;
|
|
1562
|
+
yield* sendCommand({ _tag: "PauseAutoplay" });
|
|
1563
|
+
}
|
|
1564
|
+
}
|
|
1565
|
+
yield* drainPending(yield* engine.state);
|
|
1566
|
+
break;
|
|
1567
|
+
}
|
|
1568
|
+
case "Snapshot": {
|
|
1569
|
+
lastProcessedSnapshotSerial = message.acquiredSerial;
|
|
1570
|
+
observationReady = true;
|
|
1571
|
+
yield* observed(message.state, true);
|
|
1572
|
+
blockedMove = undefined;
|
|
1573
|
+
break;
|
|
1574
|
+
}
|
|
1575
|
+
case "Event":
|
|
1576
|
+
yield* onEvent(message.event);
|
|
1577
|
+
yield* observed(yield* engine.state);
|
|
1578
|
+
blockedMove = undefined;
|
|
1579
|
+
break;
|
|
1580
|
+
case "Tick":
|
|
1581
|
+
break;
|
|
1582
|
+
case "CommandDone":
|
|
1583
|
+
yield* handleCommandDone(message.command, message.result);
|
|
1584
|
+
if (commandCount === 0)
|
|
1585
|
+
earlyClipEvents.clear();
|
|
1586
|
+
break;
|
|
1587
|
+
case "Closed":
|
|
1588
|
+
yield* closeActor(message.cause);
|
|
1589
|
+
yield* Deferred.fail(initialized, ReactorError.fromCode("Closed", message.cause.message));
|
|
1590
|
+
break;
|
|
1591
|
+
}
|
|
1592
|
+
if (ended === undefined)
|
|
1593
|
+
yield* reconcile;
|
|
1594
|
+
if (message._tag === "Snapshot")
|
|
1595
|
+
yield* Deferred.succeed(initialized, undefined);
|
|
1596
|
+
}
|
|
1597
|
+
});
|
|
1598
|
+
const observe = Effect.gen(function* () {
|
|
1599
|
+
for (;;) {
|
|
1600
|
+
const result = yield* Effect.result(Effect.scoped(Effect.gen(function* () {
|
|
1601
|
+
// Claim the serial before acquiring the initial state. A snapshot
|
|
1602
|
+
// begun before an enqueue result must not disprove its clip.
|
|
1603
|
+
const acquiredSerial = ++snapshotAcquiredSerial;
|
|
1604
|
+
const observation = yield* engine.observe({ capacity: 4096 });
|
|
1605
|
+
yield* Queue.offer(inbox, {
|
|
1606
|
+
_tag: "Snapshot",
|
|
1607
|
+
state: observation.initial,
|
|
1608
|
+
acquiredSerial,
|
|
1609
|
+
});
|
|
1610
|
+
yield* Stream.runForEach(observation.events, (event) => Queue.offer(inbox, { _tag: "Event", event }));
|
|
1611
|
+
})));
|
|
1612
|
+
if (Result.isSuccess(result)) {
|
|
1613
|
+
yield* Queue.offer(inbox, {
|
|
1614
|
+
_tag: "Closed",
|
|
1615
|
+
cause: ReactorError.fromCode("Closed", "The orchestration closed"),
|
|
1616
|
+
});
|
|
1617
|
+
return;
|
|
1618
|
+
}
|
|
1619
|
+
if (result.failure.reason._tag !== "Overflow") {
|
|
1620
|
+
yield* Queue.offer(inbox, { _tag: "Closed", cause: result.failure });
|
|
1621
|
+
return;
|
|
1622
|
+
}
|
|
1623
|
+
}
|
|
1624
|
+
});
|
|
1625
|
+
// Orderly scope teardown has already claimed and settled Closed before
|
|
1626
|
+
// worker interruption; unexpected interruption retains its original Cause.
|
|
1627
|
+
const supervise = (effect) => effect.pipe(Effect.onExit((exit) => Exit.isFailure(exit) && ended === undefined
|
|
1628
|
+
? terminate(Exit.failCause(exit.cause))
|
|
1629
|
+
: Effect.void));
|
|
1630
|
+
yield* Effect.forkScoped(supervise(watchdog));
|
|
1631
|
+
yield* Effect.forkScoped(supervise(commandWorker));
|
|
1632
|
+
yield* Effect.forkScoped(supervise(actor));
|
|
1633
|
+
yield* Effect.forkScoped(supervise(observe));
|
|
1634
|
+
yield* Effect.forkScoped(Effect.gen(function* () {
|
|
1635
|
+
for (;;) {
|
|
1636
|
+
yield* Effect.sleep("100 millis");
|
|
1637
|
+
yield* Queue.offer(inbox, { _tag: "Tick" });
|
|
1638
|
+
}
|
|
1639
|
+
}));
|
|
1640
|
+
yield* Queue.offer(inbox, { _tag: "Tick" });
|
|
1641
|
+
yield* Effect.addFinalizer(() => closeActor(ReactorError.fromCode("Closed", "The scheduler closed")));
|
|
1642
|
+
yield* Deferred.await(initialized);
|
|
1643
|
+
const submit = (input) => Effect.gen(function* () {
|
|
1644
|
+
if (yield* Deferred.isDone(stopped))
|
|
1645
|
+
return yield* closedCall;
|
|
1646
|
+
const item = yield* captureItem(input, new Set(lanes));
|
|
1647
|
+
yield* Effect.annotateCurrentSpan("reactor.scheduler.item.key", item.key);
|
|
1648
|
+
const reply = yield* Deferred.make();
|
|
1649
|
+
if (!(yield* Queue.offer(inbox, { _tag: "Submit", item, reply })))
|
|
1650
|
+
return yield* closedCall;
|
|
1651
|
+
return yield* Deferred.await(reply).pipe(Effect.raceFirst(closedCall));
|
|
1652
|
+
}).pipe(Effect.withSpan("Scheduler.submit", {}, { captureStackTrace: false }));
|
|
1653
|
+
const withdraw = (key) => Effect.gen(function* () {
|
|
1654
|
+
const reply = yield* Deferred.make();
|
|
1655
|
+
if (!(yield* Queue.offer(inbox, { _tag: "Withdraw", key, reply })))
|
|
1656
|
+
return yield* closedCall;
|
|
1657
|
+
return yield* Deferred.await(reply).pipe(Effect.raceFirst(closedCall));
|
|
1658
|
+
}).pipe(Effect.withSpan("Scheduler.withdraw", { attributes: { key } }, {
|
|
1659
|
+
captureStackTrace: false,
|
|
1660
|
+
}));
|
|
1661
|
+
const drain = (options = {}) => Effect.gen(function* () {
|
|
1662
|
+
const reply = yield* Deferred.make();
|
|
1663
|
+
const finish = options.finish ?? "playing";
|
|
1664
|
+
if (finish !== "playing" && finish !== "accepted")
|
|
1665
|
+
return yield* invalid("Drain finish must be playing or accepted");
|
|
1666
|
+
if (!(yield* Queue.offer(inbox, { _tag: "Drain", finish, reply })))
|
|
1667
|
+
return yield* closedCall;
|
|
1668
|
+
yield* Deferred.await(reply).pipe(Effect.raceFirst(closedCall));
|
|
1669
|
+
}).pipe(Effect.withSpan("Scheduler.drain", {}, { captureStackTrace: false }));
|
|
1670
|
+
return {
|
|
1671
|
+
submit,
|
|
1672
|
+
withdraw,
|
|
1673
|
+
drain,
|
|
1674
|
+
failure: Deferred.await(stopped),
|
|
1675
|
+
state: SubscriptionRef.get(stateRef),
|
|
1676
|
+
asRun: Stream.fromPubSub(events),
|
|
1677
|
+
};
|
|
1678
|
+
}).pipe(Effect.provideServiceEffect(Scope.Scope, Effect.flatMap(Scope.Scope, (parent) => Scope.fork(parent, "sequential"))));
|
|
1679
|
+
export const layerScheduler = (options) => Layer.effect(Scheduler, makeScheduler(options));
|
|
1680
|
+
//# sourceMappingURL=scheduler.js.map
|