FERS 1.0.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 pendingGranularUpdates.set(getGranularUpdateKey(itemType, itemId), {
200 itemType,
201 itemId,
202 json,
203 });
204 return scheduleGranularFlush();
205}
206
207export function registerGranularSyncFailureHandler(
208 handler: GranularSyncFailureHandler | null
209): void {
210 granularSyncFailureHandler = handler;
211}
212
213export function registerSyncWarningsHandler(
214 handler: SyncWarningsHandler | null
215): void {
216 syncWarningsHandler = handler;
217}
218
219/**
220 * Enqueue a full scenario snapshot. Coalesces with any pending full sync.
221 * `buildJson` MUST read live state at call time.
222 */
223export function enqueueFullSync(buildSnapshot: () => string): Promise<void> {
224 // A pending full snapshot already contains any granular edits that have not
225 // yet been appended to the FIFO queue, so drop those buffered writes.
226 if (pendingFullSync) {
227 discardBufferedGranularSync(pendingFullSync);
228 return pendingFullSync;
229 }
230
231 const task = queue.then(async () => {
232 // Clear before snapshot so any enqueues from this point create a new task.
233 pendingFullSync = null;
234 const json = buildSnapshot();
235 try {
236 const warnings = await invokeBackend<string[]>(
237 'update_scenario_from_json',
238 { json }
239 );
240 await handleSyncWarnings(warnings);
241 } catch (e) {
242 console.error('Full sync failed:', e);
243 }
244 });
245 pendingFullSync = task;
246 discardBufferedGranularSync(task);
247 queue = task.catch(() => undefined);
248 return task;
249}
250
251/** Resolves once every currently-queued sync task has settled. */
252export function waitForSyncIdle(): Promise<void> {
253 return Promise.all([
254 queue,
255 scheduledGranularFlush?.promise ?? Promise.resolve(),
256 ]).then(() => undefined);
257}
258
259export function resetSyncQueueForTests(): void {
260 if (granularFlushTimer) {
261 clearTimeout(granularFlushTimer);
262 granularFlushTimer = null;
263 }
264
265 pendingGranularUpdates.clear();
266 scheduledGranularFlush = null;
267 pendingFullSync = null;
268 queue = Promise.resolve();
269 invokeBackend = invoke;
270 granularSyncEpoch = 0;
271 granularSyncFailureHandler = null;
272 syncWarningsHandler = null;
273}