Spaces:
Configuration error
Configuration error
File size: 17,606 Bytes
9f21d0a | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 | // Where a trajectory's samples come from.
//
// One interface, two implementations, chosen once: a worker for the app and an
// inline one for tests and for any environment without workers. That is the whole
// reason this file exists — the alternative was a fallback branch inside
// SampledTrajectory, which meant every sample carrying a question about where it
// came from.
//
// The worker implementation batches: a synchronous burst of requests from the
// build queue is coalesced onto one message by a microtask, so sixty-four
// satellites cost one round trip rather than sixty-four, without costing a frame.
//
// It is also a small pool rather than one worker, because the build drains at the
// speed of SGP4 and one thread of it is the ceiling. Which worker a satellite
// belongs to is a pure function of its satnum and never changes — see `#laneFor`,
// where the reasons that has to be true are the interesting part.
import type { GpRecord } from "./gp";
import { runCommand, SatrecCache, type Sgp4Chunk, type Sgp4Command, type Sgp4Request, type Sgp4Response } from "./sgp4Worker";
/** Samples for one interval. See Sgp4Chunk for what the fields mean. */
export type SampleChunk = Sgp4Chunk;
/**
* A satellite's sampling, bound to its satnum and element set.
*
* Bound so that `SampledTrajectory` never handles a satnum, a record or a
* transport — it asks for an interval and gets samples.
*/
export interface TrajectorySampler {
samples(fromEpochMs: number, toEpochMs: number): Promise<SampleChunk | undefined>;
}
export interface SampleSource {
samplerFor(satnum: string, record: GpRecord): TrajectorySampler;
/** Counters, so a measurement can tell whether this is earning its keep. */
readonly stats: SampleSourceStats;
}
export interface SampleSourceStats {
requests: number;
chunks: number;
samples: number;
refused: number;
unopenable: number;
/** Requests answered by the inline implementation after the worker was given up on. */
inlineFallbacks: number;
}
/**
* How long the worker may go *silent* before it is presumed dead.
*
* Silence, not batch age. A large activation posts every batch in one turn and the
* worker answers them in order, so the last batch of five thousand satellites is
* legitimately unanswered for seconds while the ones ahead of it propagate — timing
* from when a batch was posted made that look like a hung worker on any machine
* slower than the one it was tuned on. Giving up is expensive and permanent: it
* terminates the worker and re-runs every pending window inline, synchronously, on
* the thread the worker exists to protect. So the clock now restarts on every
* reply, and only a worker that has said nothing at all for this long is dead.
*/
const WORKER_SILENCE_MS = 4000;
/**
* Commands per message.
*
* A cap, not a target: coalescing is worth doing but one message is not. A single
* `postMessage` carrying every request in an activation means structured-cloning
* five thousand element sets in one go on this thread — measured at a 133 ms frame
* — and the worker cannot start on any of it until all of it has been
* deserialised. Splitting pipelines the two sides against each other: the worker
* is propagating batch one while this thread is still posting batch ten, and the
* build starts consuming before the tail has been asked for.
*/
const MAX_COMMANDS_PER_MESSAGE = 64;
const emptyStats = (): SampleSourceStats => ({ requests: 0, chunks: 0, samples: 0, refused: 0, unopenable: 0, inlineFallbacks: 0 });
/**
* Propagates on the calling thread. The tests' implementation, and what the
* worker-backed one degrades into.
*/
export class InlineSampleSource implements SampleSource {
readonly #cache = new SatrecCache();
readonly stats = emptyStats();
samplerFor(satnum: string, record: GpRecord): TrajectorySampler {
return {
samples: (fromEpochMs, toEpochMs) => {
this.stats.requests += 1;
const reply = runCommand(this.#cache, { kind: "sample", satnum, fromEpochMs, toEpochMs, record });
if (reply.kind !== "chunk") {
if (reply.kind === "unopenable") this.stats.unopenable += 1;
return Promise.resolve(undefined);
}
this.stats.chunks += 1;
this.stats.samples += reply.chunk.positionsFixed.length / 3;
this.stats.refused += reply.chunk.refusedIndices.length;
return Promise.resolve(reply.chunk);
},
};
}
}
/**
* One outstanding request. The record is kept whether or not it goes on the wire,
* so an `unknown` reply can be retried with it.
*/
interface Pending {
resolve: (chunk: SampleChunk | undefined) => void;
satnum: string;
fromEpochMs: number;
toEpochMs: number;
record: GpRecord;
sendRecord: boolean;
}
/** One worker and the traffic bound for it. See `#laneFor`. */
interface WorkerLane {
worker: Worker;
queued: Pending[];
/** In-flight batches, so replies can be correlated. */
inFlight: Map<number, Pending[]>;
/** Restarted by every reply from this worker. See WORKER_SILENCE_MS. */
silenceTimer: ReturnType<typeof setTimeout> | undefined;
}
/**
* Ceiling on the pool.
*
* The build is worker-bound, measured rather than assumed: at 5,000 satellites a
* 4.8x cheaper main-thread ingest moved `buildMs` by 19%, and then moving 48 ms of
* rotation *into* the worker moved it back by 47. The queue drains at the speed of
* SGP4, which is what a pool splits.
*
* Bounded rather than greedy, because two things already want the cores this would
* take: the main thread, which is building entities and rendering throughout, and
* the pass predictor's own worker.
*
* **This stops paying somewhere between 5,000 satellites and 10,000.** `buildMs`
* medians against no pool: 27 → 19 at 100, 131 → 60 at 1,000, 562 → 321 at 5,000,
* and 1,508 → 993 at 10,000 — where the propagation phases alone reach 934. Past
* about five thousand the build stops waiting on SGP4 and starts waiting on entity
* creation against the per-frame budget, and no amount of propagation throughput
* moves a main-thread bound. Don't read the 5,000 figure as a slope.
*
* **It is also worth nothing without the worker-side rotation, and the reverse.**
* That rotation (see sgp4Worker) measured as an 11% *regression* on its own, by
* lengthening the worker's critical path. But with the rotation left on the main
* thread, a pool is worse than no pool at all at 10,000 — 1,295 ms against 988 —
* because four workers then feed the main thread faster than it can ingest. The
* pair is the unit; neither half is worth judging alone.
*
* Four rather than two, measured over two interleaved passes: 4 is better on every
* frame-time column at 5,000 and 10,000 and 17% better on `buildMs` at 1,000, and
* 2 wins only `buildMs` at 10,000 by 9%. An earlier reading that 4 hurt tail
* latency did not survive replication.
*/
const MAX_WORKERS = 4;
/**
* Which lane a satnum belongs to. Exported for its test rather than for callers:
* the property worth pinning is that it is a pure function, and that is not
* observable from outside a pool whose workers the test environment has no way to
* start.
*
* FNV-1a rather than `Number(satnum) % laneCount`: satnums are usually numeric but
* nothing guarantees it, and a hash that only works on digits would quietly pile
* every non-numeric satellite onto one worker.
*/
export function laneIndexFor(satnum: string, laneCount: number): number {
if (laneCount <= 1) {
return 0;
}
let hash = 0x811c9dc5;
for (let index = 0; index < satnum.length; index += 1) {
hash ^= satnum.charCodeAt(index);
hash = Math.imul(hash, 0x01000193);
}
return (hash >>> 0) % laneCount;
}
function poolSize(): number {
const cores = typeof navigator === "object" && navigator ? (navigator.hardwareConcurrency ?? 0) : 0;
if (!Number.isFinite(cores) || cores <= 0) {
// A single worker rather than a guess, which is also what every environment
// without `navigator` gets — the tests, and any worker-less host.
return 1;
}
return Math.max(1, Math.min(MAX_WORKERS, cores - 2));
}
export class WorkerSampleSource implements SampleSource {
#lanes: WorkerLane[] = [];
/** Set once the pool is given up on; every request goes inline from then on. */
#inline: InlineSampleSource | undefined;
/** Unique across lanes, so a reply is found in its own lane's map and nowhere else. */
#nextBatchId = 1;
#flushScheduled = false;
/** Satnums whose record has been sent at least once. See `record` in Sgp4SampleCommand. */
#recordSent = new Set<string>();
readonly stats = emptyStats();
constructor() {
if (typeof Worker !== "function") {
this.#giveUp("this environment has no Worker");
return;
}
try {
for (let index = 0; index < poolSize(); index += 1) {
const lane: WorkerLane = {
worker: new Worker(new URL("./sgp4Worker.ts", import.meta.url), { type: "module" }),
queued: [],
inFlight: new Map(),
silenceTimer: undefined,
};
lane.worker.addEventListener("message", (event: MessageEvent<Sgp4Response>) => this.#accept(lane, event.data));
lane.worker.addEventListener("error", (event) => this.#giveUp(event.message || "worker error"));
this.#lanes.push(lane);
}
} catch (error) {
this.#giveUp(error instanceof Error ? error.message : String(error));
}
}
/**
* Which worker owns a satellite. A pure function of the satnum, and that is
* load-bearing in three separate places rather than a tidiness preference:
*
* - **The satrec cache is per worker.** Scattering one satellite's requests
* across the pool would build a satrec for it in every worker it touched —
* `sgp4init` and its memory multiplied by the pool size, for one satellite.
* - **`#recordSent` is one set for the whole pool.** It records that *a* worker
* has the element set, which is only the same statement as *the* worker having
* it while the mapping holds. Round-robin would answer the first request to
* each new worker with `unknown` and pay a round trip to learn it.
* - **Eviction stays meaningful.** MAX_CACHED_SATRECS bounds a worker's own
* satellites rather than a shifting fraction of all of them.
*/
#laneFor(satnum: string): WorkerLane | undefined {
return this.#lanes[laneIndexFor(satnum, this.#lanes.length)];
}
samplerFor(satnum: string, record: GpRecord): TrajectorySampler {
return {
samples: (fromEpochMs, toEpochMs) => {
this.stats.requests += 1;
if (this.#inline) {
return this.#inlineSamples(satnum, record, fromEpochMs, toEpochMs);
}
const lane = this.#laneFor(satnum);
if (!lane) {
// Not reachable while the pool is up, since every satnum maps to a lane.
// A pool that emptied without giving up would otherwise drop the request
// on the floor and leave the caller waiting on a promise nothing settles.
this.#giveUp("no propagation worker");
return this.#inlineSamples(satnum, record, fromEpochMs, toEpochMs);
}
// The record only goes on the wire when this satellite's worker cannot be
// assumed to hold a satrec for it yet.
const sendRecord = !this.#recordSent.has(satnum);
this.#recordSent.add(satnum);
return new Promise<SampleChunk | undefined>((resolve) => {
lane.queued.push({ resolve, satnum, fromEpochMs, toEpochMs, record, sendRecord });
this.#schedule();
});
},
};
}
/** Every path that has stopped using the pool funnels through here. */
#inlineSamples(satnum: string, record: GpRecord, fromEpochMs: number, toEpochMs: number): Promise<SampleChunk | undefined> {
const inline = this.#inline;
if (!inline) {
return Promise.resolve(undefined);
}
this.stats.inlineFallbacks += 1;
return inline.samplerFor(satnum, record).samples(fromEpochMs, toEpochMs);
}
/**
* Coalesce onto one message per turn. A microtask rather than a frame: the build
* queue asks for a whole chunk of satellites synchronously, and making it wait
* for a frame would put latency back that the batching is meant to remove.
*/
#schedule(): void {
if (this.#flushScheduled) {
return;
}
this.#flushScheduled = true;
queueMicrotask(() => {
this.#flushScheduled = false;
this.#flush();
});
}
#flush(): void {
for (const lane of this.#lanes) {
const queued = lane.queued;
if (queued.length === 0) {
continue;
}
lane.queued = [];
for (let offset = 0; offset < queued.length; offset += MAX_COMMANDS_PER_MESSAGE) {
const pending = queued.slice(offset, offset + MAX_COMMANDS_PER_MESSAGE);
const batchId = this.#nextBatchId++;
const commands: Sgp4Command[] = pending.map((item) =>
item.sendRecord
? { kind: "sample", satnum: item.satnum, fromEpochMs: item.fromEpochMs, toEpochMs: item.toEpochMs, record: item.record }
: { kind: "sample", satnum: item.satnum, fromEpochMs: item.fromEpochMs, toEpochMs: item.toEpochMs },
);
const request: Sgp4Request = { batchId, commands };
lane.inFlight.set(batchId, pending);
// Not `Window.postMessage`, which is the one that takes a target origin. A
// worker's second parameter is a transfer list, and the fix this rule
// suggests throws: `postMessage(msg, self.location.origin)` fails overload
// resolution in Chrome. Verified rather than assumed.
// eslint-disable-next-line unicorn/require-post-message-target-origin
lane.worker.postMessage(request);
}
this.#armSilenceTimer(lane);
}
}
/**
* Restart one lane's silence timer while it has anything outstanding.
*
* Per lane, because an idle worker is legitimately silent: with one timer for the
* pool, a lane holding no work would be indistinguishable from a lane that had
* died, and the whole pool would be torn down on the first quiet stretch.
*/
#armSilenceTimer(lane: WorkerLane): void {
if (lane.silenceTimer !== undefined) {
clearTimeout(lane.silenceTimer);
lane.silenceTimer = undefined;
}
if (lane.inFlight.size === 0 || this.#inline) {
return;
}
lane.silenceTimer = setTimeout(() => this.#giveUp(`a worker said nothing for ${WORKER_SILENCE_MS} ms`), WORKER_SILENCE_MS);
}
#accept(lane: WorkerLane, response: Sgp4Response): void {
const batch = lane.inFlight.get(response.batchId);
if (!batch) {
return;
}
lane.inFlight.delete(response.batchId);
// Proof of life, so the timer measures silence rather than the depth of the
// queue this reply just came off.
this.#armSilenceTimer(lane);
response.replies.forEach((reply, index) => {
const pending = batch[index];
if (!pending) {
return;
}
if (reply.kind === "chunk") {
this.stats.chunks += 1;
this.stats.samples += reply.chunk.positionsFixed.length / 3;
this.stats.refused += reply.chunk.refusedIndices.length;
pending.resolve(reply.chunk);
return;
}
if (reply.kind === "unknown") {
// The satrec was evicted, or a batch raced ahead of the one that would
// have created it. Re-queue with the record attached this time — onto this
// same lane, which is where the mapping sends it anyway.
this.#recordSent.delete(reply.satnum);
lane.queued.push({ ...pending, sendRecord: true });
this.#schedule();
return;
}
this.stats.unopenable += 1;
pending.resolve(undefined);
});
}
/**
* Stop using the pool and answer everything inline from here on.
*
* Loud, and once: a worker that fails to construct, throws on load or simply
* stops answering would otherwise look like nothing more than a slow build, and
* the app would keep working while quietly propagating everything on the thread
* this exists to protect.
*
* All of them rather than the one that failed. A lane owns its satellites
* outright, so keeping the survivors would leave part of the catalog propagating
* off-thread and part of it inline, at different speeds, with the failure
* reported once and then invisible. One rule is easier to reason about than a
* pool that is partly alive, and the fallback is correct either way.
*/
#giveUp(reason: string): void {
if (this.#inline) {
return;
}
console.error(`SGP4 worker unavailable (${reason}); propagating on the main thread instead`);
this.#inline = new InlineSampleSource();
const lanes = this.#lanes;
this.#lanes = [];
for (const lane of lanes) {
lane.worker.terminate();
if (lane.silenceTimer !== undefined) {
clearTimeout(lane.silenceTimer);
lane.silenceTimer = undefined;
}
for (const pending of lane.inFlight.values()) {
for (const item of pending) {
this.#retryInline(item);
}
}
lane.inFlight.clear();
const queued = lane.queued;
lane.queued = [];
for (const item of queued) {
this.#retryInline(item);
}
}
}
/** The same, for a request that was already promised an answer. */
#retryInline(item: Pending): void {
void this.#inlineSamples(item.satnum, item.record, item.fromEpochMs, item.toEpochMs).then(item.resolve);
}
}
|