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);
  }
}