lsa-planer

LSA-Planer Professional – Planungssoftware für Lichtsignalanlagen nach RiLSA 2015 und § 45 StVO. EUPL-1.2.

/ src services autosave.ts

12,0 KB Rohdatei
src/services/autosave.ts — 295 Zeilen
1 import type { ProjectStore } from '@/app/store';
2 import type { Project } from '@/domain/model/project';
3 import { addRecoveryPoint, saveSession, type AppSettings } from './storage';
4
5 /**
6 * Zwischenspeichern des Arbeitsstands.
7 *
8 * Zwei getrennte Takte:
9 * - Nach jeder Aenderung, verzoegert um 1,5 s, wird der Sitzungsstand
10 * gesichert. So ist auch bei einem Absturz hoechstens die letzte Eingabe weg.
11 * - In groesserem Abstand entsteht ein Wiederherstellungspunkt; bis zu fuenf
12 * Staende liegen damit als zweite Sicherung in der Datenbank. Einspielen
13 * laesst sich bisher keiner davon: Die Leseseite der Speicherschicht hat
14 * ausserhalb der Pruefungen keinen Aufrufer, und die Oberflaeche bietet
15 * keinen Bedienweg dorthin an. Hier stand zuvor das Gegenteil.
16 *
17 * Der Altbestand startete bei jedem Projektwechsel ein neues setInterval, ohne
18 * das alte zu beenden. Nach fuenf Projektwechseln liefen fuenf Sicherungen
19 * gleichzeitig, die sich gegenseitig ueberschrieben. Diese Klasse haelt genau
20 * einen Zeitgeber je Aufgabe und raeumt beide in `stop()` ab.
21 *
22 * SEIT DER UMSTELLUNG AUF IndexedDB schreibt der Sitzungsspeicher asynchron.
23 * Daraus folgen drei Dinge, die hier geregelt werden:
24 * - Es laeuft nie mehr als ein Schreibvorgang gleichzeitig; waehrenddessen
25 * eingehende Aenderungen werden zu EINEM Nachzuegler zusammengefasst. Sonst
26 * stapelten sich bei zuegiger Eingabe mehrere 20-MB-Sicherungen, von denen
27 * die aelteste womoeglich als letzte ankaeme.
28 * - Unveraenderte Staende werden nicht erneut geschrieben.
29 * - Beim Verlassen des Fensters wird nicht erst in `beforeunload` gesichert -
30 * dort bleibt fuer eine IndexedDB-Transaktion keine Zeit mehr.
31 */
32 export class AutoSave {
33 private debounceTimer: ReturnType<typeof setTimeout> | null = null;
34 private intervalTimer: ReturnType<typeof setInterval> | null = null;
35 private unsubscribe: (() => void) | null = null;
36 private beimVerlassen: (() => void) | null = null;
37 private beiSichtwechsel: (() => void) | null = null;
38 private lastSavedAt = 0;
39
40 /** Laeuft gerade ein Schreibvorgang? */
41 private schreibtGerade = false;
42 /** Kam waehrend des Schreibens eine neue Aenderung? */
43 private nachholen = false;
44 /**
45 * Zuletzt erfolgreich gesicherter Projektstand.
46 *
47 * Der Vergleich laeuft ueber die Objektgleichheit: Der Zustandsspeicher
48 * ersetzt das Projekt bei jeder Aenderung durch ein neues Objekt, gleiche
49 * Kennung heisst also unveraendert. Ohne diese Pruefung schriebe der
50 * Zeitgeber beim Anlegen der Wiederherstellungspunkte auch dann, wenn nichts
51 * geschehen ist - bei einem Luftbild von 20 MB jedes Mal umsonst.
52 */
53 private zuletztGesichert: Project | null = null;
54 /**
55 * Zuletzt erfolgreich abgelegter Wiederherstellungspunkt.
56 *
57 * Aus demselben Grund wie `zuletztGesichert` und ueber dieselbe
58 * Objektgleichheit, aber getrennt gefuehrt: `lastSavedAt` allein taugt als
59 * Sperre nicht, weil der Programmstart den Stand herstellt, ohne ihn zu
60 * sichern - `lastSavedAt` bleibt dann 0, und im Leerlauf fuellte sich der
61 * Fuenferring alle zwei Minuten mit Kopien desselben Projekts, bis die
62 * Punkte frueherer Sitzungen verdraengt waren.
63 *
64 * Erst NACH dem geglueckten Schreiben gesetzt: Ein gescheiterter Punkt liegt
65 * nirgends und darf den naechsten Versuch nicht sperren.
66 */
67 private letzterPunkt: Project | null = null;
68 /** Zuletzt ausgegebener Meldungstext, gegen Dauerwiederholung. */
69 private letzteMeldung = '';
70 /**
71 * Darf der Sitzungsstand ueberhaupt geschrieben werden?
72 *
73 * Gesperrt wird nach dem Befund 'nicht-lesbar' des Sitzungsspeichers: Dort
74 * liegt ein womoeglich HEILER Stand, der sich nur nicht lesen liess, und der
75 * Programmstart sagt dem Anwender ausdruecklich zu, dass er "nicht verloren"
76 * ist und jetzt nichts gespeichert wird. Ohne diese Sperre schriebe der erste
77 * Sichtwechsel - Fenster minimieren oder schliessen, also genau der
78 * empfohlene Neustart - das leere Startprojekt darueber, und die
79 * anschliessende Grossdatenaufraeumung naehme das Luftbild gleich mit.
80 */
81 private sitzungGesperrt = false;
82
83 constructor(
84 private readonly store: ProjectStore,
85 private settings: AppSettings,
86 /*
87 * 'warnung' ist neu und der eigentliche Punkt: Die einzige Auskunft
88 * darueber, dass der Arbeitsstand nur noch im 5-MB-Ersatzspeicher liegt -
89 * ein Luftbild passt dort nicht hinein -, ging als hoefliche Kurzmeldung
90 * hinaus und verschwand nach fuenf Sekunden. Die Wiederholungssperre unten
91 * sorgt dafuer, dass sie genau einmal je Sitzung erscheint; wer in dem
92 * Moment nicht hinsah, erfuhr es nie. Warnungen bleiben stehen.
93 */
94 private readonly onMessage: (message: string, kind: 'info' | 'warnung' | 'fehler') => void,
95 ) {}
96
97 start(): void {
98 this.stop();
99 if (!this.settings.autoSaveEnabled) return;
100
101 this.unsubscribe = this.store.subscribe((state) => {
102 if (!state.dirty) return;
103 this.scheduleSessionSave();
104 });
105
106 this.intervalTimer = setInterval(
107 () => {
108 this.createRecoveryPoint();
109 },
110 Math.max(15, this.settings.autoSaveIntervalSeconds) * 1000,
111 );
112
113 // Gesichert wird, sobald das Fenster in den Hintergrund geht. Das ist der
114 // letzte Zeitpunkt, zu dem eine IndexedDB-Transaktion zuverlaessig
115 // durchlaeuft: In `beforeunload` wird die Verbindung mit dem Fenster
116 // abgeraeumt, bevor die Transaktion festschreiben kann - der Schreibvorgang
117 // liefe ins Leere, ohne dass es jemand bemerkt.
118 this.beiSichtwechsel = () => {
119 if (document.visibilityState === 'hidden') void this.flush();
120 };
121 document.addEventListener('visibilitychange', this.beiSichtwechsel);
122
123 // Zusaetzlich, nicht stattdessen: Wird das Fenster geschlossen, ohne vorher
124 // verborgen zu werden, ist das der letzte Versuch.
125 this.beimVerlassen = () => {
126 void this.flush();
127 };
128 window.addEventListener('beforeunload', this.beimVerlassen);
129 }
130
131 stop(): void {
132 if (this.debounceTimer !== null) {
133 clearTimeout(this.debounceTimer);
134 this.debounceTimer = null;
135 }
136 if (this.intervalTimer !== null) {
137 clearInterval(this.intervalTimer);
138 this.intervalTimer = null;
139 }
140 this.unsubscribe?.();
141 this.unsubscribe = null;
142 if (this.beimVerlassen !== null) {
143 window.removeEventListener('beforeunload', this.beimVerlassen);
144 this.beimVerlassen = null;
145 }
146 if (this.beiSichtwechsel !== null) {
147 document.removeEventListener('visibilitychange', this.beiSichtwechsel);
148 this.beiSichtwechsel = null;
149 }
150 }
151
152 updateSettings(settings: AppSettings): void {
153 this.settings = settings;
154 this.start();
155 }
156
157 /**
158 * Haelt jedes Schreiben des Sitzungsstands an - siehe `sitzungGesperrt`.
159 *
160 * Wiederherstellungspunkte laufen weiter: Sie liegen unter einem eigenen
161 * Schluessel und ersetzen den Sitzungssatz nicht.
162 */
163 sperreSitzungsspeicherung(): void {
164 this.sitzungGesperrt = true;
165 }
166
167 /**
168 * Gibt das Schreiben wieder frei.
169 *
170 * Nur nach einer ausdruecklichen Entscheidung des Anwenders aufzurufen - er
171 * hat ueber "Neu" oder "Öffnen" ein anderes Projekt gesetzt und damit selbst
172 * bestimmt, was von jetzt an im Sitzungsspeicher stehen soll.
173 */
174 gibSitzungsspeicherungFrei(): void {
175 this.sitzungGesperrt = false;
176 }
177
178 /**
179 * Sichert sofort, ohne auf den Zeitgeber zu warten.
180 *
181 * Das Versprechen ist erst erfuellt, wenn der Stand tatsaechlich geschrieben
182 * ist; aufrufen laesst sich die Methode weiterhin ohne `await`.
183 */
184 flush(): Promise<void> {
185 if (this.debounceTimer !== null) {
186 clearTimeout(this.debounceTimer);
187 this.debounceTimer = null;
188 }
189 return this.writeSession();
190 }
191
192 private scheduleSessionSave(): void {
193 if (this.debounceTimer !== null) clearTimeout(this.debounceTimer);
194 this.debounceTimer = setTimeout(() => {
195 this.debounceTimer = null;
196 void this.writeSession();
197 }, 1500);
198 }
199
200 private async writeSession(): Promise<void> {
201 // Vor allem anderen: Ist gesperrt, wird gar nicht geschrieben - auch nicht
202 // ueber `flush()` aus dem Sichtwechsel oder der Fehlerbehandlung heraus.
203 if (this.sitzungGesperrt) return;
204 if (this.schreibtGerade) {
205 // Nicht ueberholen lassen: Der laufende Vorgang holt den neuesten Stand
206 // selbst nach - gelingt er, gleich in der Schleife unten; scheitert er,
207 // ueber eine erneute entprellte Einplanung.
208 this.nachholen = true;
209 return;
210 }
211 this.schreibtGerade = true;
212 try {
213 for (;;) {
214 /*
215 * Der Linter haelt die beiden Abfragen auf `nachholen` weiter unten
216 * fuer entschieden ("always falsy" bzw. "always truthy"): Die
217 * Flussanalyse sieht die Zuweisung hier und behaelt sie ueber das
218 * `await` hinweg bei. Zwischen beiden liegt aber der Wartepunkt, und
219 * genau dort setzt ein zweiter Aufruf von writeSession das Feld auf
220 * true und kehrt zurueck (oben, Zweig `schreibtGerade`). Beide
221 * Abfragen sind der Grund, warum dieser Nachzuegler ankommt, und jede
222 * hat ihre eigene Wache. Gemessen durch Zuruecknehmen: Ohne die
223 * Abfrage im Fehlerzweig sind zwei Faelle in
224 * tests/services/nachzueglerNachFehlschlag.test.ts und einer
225 * in tests/services/autosave.test.ts rot; ohne die am Schleifenende
226 * zwei Faelle in tests/services/autosave.test.ts.
227 */
228 this.nachholen = false;
229 const projekt = this.store.getProject();
230 if (projekt === this.zuletztGesichert) return;
231
232 const result = await saveSession(projekt);
233 if (!result.ok) {
234 this.melde(result.message, 'fehler');
235 /*
236 * Der Nachzuegler faellt auch hier nicht weg.
237 *
238 * Der Entprellzeitgeber ist an dieser Stelle laengst abgeraeumt -
239 * ohne diese Einplanung stuende gar kein Sitzungsschreibversuch mehr
240 * an, und der zuletzt eingegebene Wert laege nur noch im
241 * Arbeitsspeicher, bis der Anwender das naechste Mal etwas aendert.
242 *
243 * ENTPRELLT eingeplant und nicht in der Schleife sofort wiederholt:
244 * Bei dauerhaft defektem Speicher drehte sie sonst durch. So bleibt
245 * es bei einem weiteren Versuch je vorgemerktem Stand.
246 */
247 // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- das Feld wird ueber den Wartepunkt hinweg gesetzt; siehe darueber
248 if (this.nachholen) this.scheduleSessionSave();
249 return;
250 }
251 this.zuletztGesichert = projekt;
252 this.lastSavedAt = Date.now();
253 // Ein geglueckter Speichervorgang MIT Meldung heisst: Er ist geglueckt,
254 // aber nicht so, wie er sollte - der Ersatzspeicher hat uebernommen.
255 // Das ist eine Warnung, keine Mitteilung.
256 this.melde(result.message, 'warnung');
257
258 // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- das Feld wird ueber den Wartepunkt hinweg gesetzt
259 if (!this.nachholen) return;
260 }
261 } finally {
262 this.schreibtGerade = false;
263 }
264 }
265
266 /**
267 * Gibt eine Meldung nur bei Aenderung aus.
268 *
269 * Gesichert wird 1,5 s nach jeder Eingabe. Ohne diese Sperre wuerde ein
270 * dauerhafter Zustand - etwa ein fehlender Speicher - im Sekundentakt
271 * gemeldet und der Meldungsbereich unbrauchbar.
272 */
273 private melde(text: string, art: 'info' | 'warnung' | 'fehler'): void {
274 if (text === this.letzteMeldung) return;
275 this.letzteMeldung = text;
276 if (text !== '') this.onMessage(text, art);
277 }
278
279 private createRecoveryPoint(): void {
280 const state = this.store.getState();
281 if (!state.dirty && this.lastSavedAt > 0) return;
282 // Derselbe Stand ein zweites Mal ergibt keinen zweiten Punkt - siehe
283 // `letzterPunkt`.
284 if (state.project === this.letzterPunkt) return;
285 const projekt = state.project;
286 void addRecoveryPoint(projekt)
287 .then(() => {
288 this.letzterPunkt = projekt;
289 })
290 .catch(() => {
291 // Ein Wiederherstellungspunkt ist Beiwerk; sein Scheitern darf weder den
292 // Zeitgeber anhalten noch als unbehandelte Ablehnung enden.
293 });
294 }
295 }