FERS 0.1.0
The Flexible Extensible Radar Simulator
Loading...
Searching...
No Matches
syncQueue.ts
Go to the documentation of this file.
1// SPDX-License-Identifier: GPL-2.0-only
2// Copyright (c) 2026-present FERS Contributors (see AUTHORS.md).
3
4import { invoke } from '@tauri-apps/api/core';
5
6// Single FIFO queue for every backend write, granular or full.
7// Serializing all writes through one promise chain prevents the race where
8// a granular edit on a freshly-added item would reach the backend before
9// the full sync that created it.
10let queue: Promise<void> = Promise.resolve();
11let invokeBackend: typeof invoke = invoke;
12
13const GRANULAR_FLUSH_INTERVAL_MS = 16;
14
15interface GranularUpdate {
16 itemType: string;
17 itemId: string;
18 json: string;
19}
20
21export interface GranularSyncFailure {
22 itemType: string;
23 itemId: string;
24 error: unknown;
25}
26
27interface Deferred {
28 promise: Promise<void>;
29 resolve: () => void;
30 reject: (reason?: unknown) => void;
31}
32
33type GranularSyncFailureHandler = (
34 failure: GranularSyncFailure
35) => Promise<void> | void;
36type SyncWarningsHandler = (warnings: string[]) => Promise<void> | void;
37
38// Latest pending granular payload per backend object. Older snapshots for the
39// same item are obsolete because granular syncs send full item state, not diffs.
40const pendingGranularUpdates = new Map<string, GranularUpdate>();
41let granularFlushTimer: ReturnType<typeof setTimeout> | null = null;
42let scheduledGranularFlush: Deferred | null = null;
43
44// Reference to a full sync that has been enqueued but has not yet started
45// executing. While this is set, any further enqueueFullSync() calls are
46// coalesced into it — the snapshot is captured when the task runs, so
47// later changes are automatically included. It is cleared at the start of
48// the task body so subsequent enqueues create a fresh task.
49let pendingFullSync: Promise<void> | null = null;
50let granularSyncEpoch = 0;
51let granularSyncFailureHandler: GranularSyncFailureHandler | null = null;
52let syncWarningsHandler: SyncWarningsHandler | null = null;
53
54function createDeferred(): Deferred {
55 let resolve!: () => void;
56 let reject!: (reason?: unknown) => void;
57 const promise = new Promise<void>((res, rej) => {
58 resolve = res;
59 reject = rej;
60 });
61 return { promise, resolve, reject };
62}
63
64function getGranularUpdateKey(itemType: string, itemId: string): string {
65 return `${itemType}:${itemId}`;
66}
67
68function discardBufferedGranularSync(replacement?: Promise<void>): void {
69 if (granularFlushTimer) {
70 clearTimeout(granularFlushTimer);
71 granularFlushTimer = null;
72 }
73
74 if (pendingGranularUpdates.size > 0) {
75 pendingGranularUpdates.clear();
76 }
77
78 if (scheduledGranularFlush) {
79 const deferred = scheduledGranularFlush;
80 if (replacement) {
81 void replacement.then(
82 () => deferred.resolve(),
83 (error) => deferred.reject(error)
84 );
85 } else {
86 deferred.resolve();
87 }
88 scheduledGranularFlush = null;
89 }
90}
91
92function invalidatePendingGranularSyncs(): void {
93 granularSyncEpoch += 1;
94 discardBufferedGranularSync();
95}
96
97async function handleGranularSyncFailure(
98 update: GranularUpdate,
99 error: unknown
100): Promise<void> {
101 console.error(
102 `Granular sync failed (${update.itemType} ${update.itemId}):`,
103 error
104 );
105
106 invalidatePendingGranularSyncs();
107
108 if (granularSyncFailureHandler) {
109 await granularSyncFailureHandler({
110 itemType: update.itemType,
111 itemId: update.itemId,
112 error,
113 });
114 }
115}
116
117async function handleSyncWarnings(warnings: unknown): Promise<void> {
118 if (
119 !Array.isArray(warnings) ||
120 warnings.length === 0 ||
121 !syncWarningsHandler
122 ) {
123 return;
124 }
125 await syncWarningsHandler(
126 warnings.filter(
127 (warning): warning is string => typeof warning === 'string'
128 )
129 );
130}
131
132function scheduleGranularFlush(): Promise<void> {
133 if (!scheduledGranularFlush) {
134 scheduledGranularFlush = createDeferred();
135 }
136
137 if (granularFlushTimer) {
138 return scheduledGranularFlush.promise;
139 }
140
141 granularFlushTimer = setTimeout(() => {
142 granularFlushTimer = null;
143
144 const updates = [...pendingGranularUpdates.values()];
145 pendingGranularUpdates.clear();
146
147 const flushDeferred = scheduledGranularFlush;
148 scheduledGranularFlush = null;
149
150 if (updates.length === 0) {
151 flushDeferred?.resolve();
152 return;
153 }
154
155 const taskEpoch = granularSyncEpoch;
156 const task = queue.then(async () => {
157 if (taskEpoch !== granularSyncEpoch) {
158 return;
159 }
160
161 for (const update of updates) {
162 try {
163 const warnings = await invokeBackend<string[]>(
164 'update_item_from_json',
165 {
166 itemType: update.itemType,
167 itemId: update.itemId,
168 json: update.json,
169 }
170 );
171 await handleSyncWarnings(warnings);
172 } catch (e) {
173 await handleGranularSyncFailure(update, e);
174 throw e;
175 }
176 }
177 });
178
179 queue = task.catch(() => undefined);
180 void task.then(
181 () => flushDeferred?.resolve(),
182 (error) => flushDeferred?.reject(error)
183 );
184 }, GRANULAR_FLUSH_INTERVAL_MS);
185
186 return scheduledGranularFlush.promise;
187}
188
189export function setSyncQueueInvokerForTests(testInvoke?: typeof invoke): void {
190 invokeBackend = testInvoke ?? invoke;
191}
192
193/** Enqueue a granular item update behind any in-flight work. */
194export function enqueueGranularSync(
195 itemType: string,
196 itemId: string,
197 json: string
198): Promise<void> {
199 if (pendingFullSync) {
200 return pendingFullSync;
201 }
202
203 pendingGranularUpdates.set(getGranularUpdateKey(itemType, itemId), {
204 itemType,
205 itemId,
206 json,
207 });
208 return scheduleGranularFlush();
209}
210
211export function enqueueGranularSyncDetached(
212 itemType: string,
213 itemId: string,
214 json: string
215): void {
216 void enqueueGranularSync(itemType, itemId, json).catch(() => undefined);
217}
218
219export function registerGranularSyncFailureHandler(
220 handler: GranularSyncFailureHandler | null
221): void {
222 granularSyncFailureHandler = handler;
223}
224
225export function registerSyncWarningsHandler(
226 handler: SyncWarningsHandler | null
227): void {
228 syncWarningsHandler = handler;
229}
230
231/**
232 * Enqueue a full scenario snapshot. Coalesces with any pending full sync.
233 * `buildJson` MUST read live state at call time.
234 */
235export function enqueueFullSync(buildSnapshot: () => string): Promise<void> {
236 // A pending full snapshot already contains any granular edits that have not
237 // yet been appended to the FIFO queue, so drop those buffered writes.
238 if (pendingFullSync) {
239 discardBufferedGranularSync(pendingFullSync);
240 return pendingFullSync;
241 }
242
243 const task = queue.then(async () => {
244 // Clear before snapshot so any enqueues from this point create a new task.
245 pendingFullSync = null;
246 const json = buildSnapshot();
247 try {
248 const warnings = await invokeBackend<string[]>(
249 'update_scenario_from_json',
250 { json }
251 );
252 await handleSyncWarnings(warnings);
253 } catch (e) {
254 console.error('Full sync failed:', e);
255 throw e;
256 }
257 });
258 pendingFullSync = task;
259 discardBufferedGranularSync(task);
260 queue = task.catch(() => undefined);
261 return task;
262}
263
264export function enqueueFullSyncDetached(buildSnapshot: () => string): void {
265 void enqueueFullSync(buildSnapshot).catch(() => undefined);
266}
267
268/** Resolves once every currently-queued sync task has settled. */
269export function waitForSyncIdle(): Promise<void> {
270 return Promise.all([
271 queue,
272 scheduledGranularFlush?.promise ?? Promise.resolve(),
273 ]).then(() => undefined);
274}
275
276export function resetSyncQueueForTests(): void {
277 if (granularFlushTimer) {
278 clearTimeout(granularFlushTimer);
279 granularFlushTimer = null;
280 }
281
282 pendingGranularUpdates.clear();
283 scheduledGranularFlush = null;
284 pendingFullSync = null;
285 queue = Promise.resolve();
286 invokeBackend = invoke;
287 granularSyncEpoch = 0;
288 granularSyncFailureHandler = null;
289 syncWarningsHandler = null;
290}