File size: 7,065 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
// Where a satellite's passes come from.
//
// The same shape as sampleSource, and for the same reasons: one interface, two
// implementations chosen once, so PassPredictor never handles a transport and the
// tests never need a worker. The differences are all consequences of pass
// prediction being a slow batch job rather than a fast stream.
//
// There is no inline fallback for a dead worker. A failed sample request stops
// satellites moving, which is worth degrading for; a failed pass request costs a
// table that stays empty, and running 40 s of prediction on the main thread to
// avoid an empty table is a worse outcome than the empty table. So a broken
// worker is reported once and every request resolves to nothing.

import type { SwathExtents } from "../../config/satelliteMetadata";
import type { GpRecord } from "./gp";
import { OrbitCache, runPassCommand, type PassCommand, type PassRequest, type PassResponse, type PassStation, type WorkerPass } from "./passWorker";

export type { PassStation, WorkerPass };

export interface PassQuery {
  mode: string;
  stations: PassStation[];
  startEpochMs: number;
  endEpochMs: number;
  swath: SwathExtents;
}

/**
 * One satellite's pass prediction, bound to its satnum and element set — so
 * PassPredictor asks about a window and gets passes.
 */
export interface PassPredictorSource {
  passes(query: PassQuery): Promise<WorkerPass[] | undefined>;
}

export interface PassSource {
  predictorFor(satnum: string, record: GpRecord): PassPredictorSource;
}

/**
 * Commands per message.
 *
 * Small, unlike the sampling source's sixty-four. Each command is milliseconds of
 * propagation rather than microseconds, so a big batch would hold every result
 * back until the slowest one finished. Eight is about 64 ms a reply, which is what
 * makes a station's table fill in progressively instead of arriving whole.
 */
const MAX_COMMANDS_PER_MESSAGE = 8;

/** Predicts on the calling thread. The tests' implementation. */
export class InlinePassSource implements PassSource {
  readonly #cache = new OrbitCache();

  predictorFor(satnum: string, record: GpRecord): PassPredictorSource {
    return {
      passes: (query) => {
        const reply = runPassCommand(this.#cache, { kind: "passes", satnum, record, ...query });
        if (reply.kind !== "passes") {
          return Promise.resolve(undefined);
        }
        return Promise.resolve(reply.passes);
      },
    };
  }
}

interface Pending {
  resolve: (passes: WorkerPass[] | undefined) => void;
  satnum: string;
  query: PassQuery;
  record: GpRecord;
  sendRecord: boolean;
}

export class WorkerPassSource implements PassSource {
  #worker: Worker | undefined;

  /** Set once the worker is given up on; every request resolves to nothing after. */
  #broken = false;

  #nextBatchId = 1;

  #queued: Pending[] = [];

  #flushScheduled = false;

  #inFlight = new Map<number, Pending[]>();

  /** Satnums whose record has been sent at least once. */
  #recordSent = new Set<string>();

  constructor() {
    if (typeof Worker !== "function") {
      this.#giveUp("this environment has no Worker");
      return;
    }
    try {
      this.#worker = new Worker(new URL("./passWorker.ts", import.meta.url), { type: "module" });
      this.#worker.addEventListener("message", (event: MessageEvent<PassResponse>) => this.#accept(event.data));
      this.#worker.addEventListener("error", (event) => this.#giveUp(event.message || "worker error"));
    } catch (error) {
      this.#giveUp(error instanceof Error ? error.message : String(error));
    }
  }

  predictorFor(satnum: string, record: GpRecord): PassPredictorSource {
    return {
      passes: (query) => {
        if (this.#broken) {
          return Promise.resolve(undefined);
        }
        const sendRecord = !this.#recordSent.has(satnum);
        this.#recordSent.add(satnum);
        return new Promise<WorkerPass[] | undefined>((resolve) => {
          this.#queued.push({ resolve, satnum, query, record, sendRecord });
          this.#schedule();
        });
      },
    };
  }

  /**
   * Coalesce onto messages once per turn.
   *
   * No deadline timer, unlike the sampling source. A batch here legitimately
   * takes a second, and there is no answer to "it is taking too long" that beats
   * waiting — the fallback would be to do the same work on the thread this exists
   * to keep free.
   */
  #schedule(): void {
    if (this.#flushScheduled) {
      return;
    }
    this.#flushScheduled = true;
    queueMicrotask(() => {
      this.#flushScheduled = false;
      this.#flush();
    });
  }

  #flush(): void {
    const queued = this.#queued;
    this.#queued = [];
    if (queued.length === 0 || !this.#worker) {
      return;
    }
    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: PassCommand[] = pending.map((item) =>
        item.sendRecord ? { kind: "passes", satnum: item.satnum, record: item.record, ...item.query } : { kind: "passes", satnum: item.satnum, ...item.query },
      );
      this.#inFlight.set(batchId, pending);
      // A transfer list, not a target origin — see sampleSource.
      // eslint-disable-next-line unicorn/require-post-message-target-origin
      this.#worker.postMessage({ batchId, commands } satisfies PassRequest);
    }
  }

  #accept(response: PassResponse): void {
    const pending = this.#inFlight.get(response.batchId);
    if (!pending) {
      return;
    }
    this.#inFlight.delete(response.batchId);
    response.replies.forEach((reply, index) => {
      const item = pending[index];
      if (!item) {
        return;
      }
      if (reply.kind === "passes") {
        item.resolve(reply.passes);
        return;
      }
      if (reply.kind === "unknown") {
        // The orbit was evicted, or a batch raced ahead of the one that would
        // have created it. Re-queue with the record attached this time.
        this.#recordSent.delete(reply.satnum);
        this.#queued.push({ ...item, sendRecord: true });
        this.#schedule();
        return;
      }
      item.resolve(undefined);
    });
  }

  /**
   * Stop using the worker. Loud, and once — a worker that fails to construct or
   * throws on load would otherwise look like a ground station with nothing
   * overhead, which is a plausible enough answer to go unnoticed.
   */
  #giveUp(reason: string): void {
    if (this.#broken) {
      return;
    }
    this.#broken = true;
    console.error(`Pass prediction worker unavailable (${reason}); pass tables will stay empty`);
    this.#worker?.terminate();
    this.#worker = undefined;
    for (const pending of this.#inFlight.values()) {
      for (const item of pending) {
        item.resolve(undefined);
      }
    }
    this.#inFlight.clear();
    const queued = this.#queued;
    this.#queued = [];
    for (const item of queued) {
      item.resolve(undefined);
    }
  }
}