diff --git a/docs/03-scanner.md b/docs/03-scanner.md index 1b79400..0598795 100644 --- a/docs/03-scanner.md +++ b/docs/03-scanner.md @@ -96,6 +96,65 @@ der Oberfläche schreibt ihn, weiß aber nichts von der Variablen im Scheduler. Ohne den Abgleich könnte der Scheduler direkt hinter einem manuellen Lauf noch einmal losziehen. +## Ein Lauf zur Zeit + +Zwei Aufrufer können die Pipeline starten: der Cron-Tick und der Knopf "Jetzt +scannen" (`POST /api/scanner/scan`). Keiner von beiden weiß vom anderen, und der +Tick prüft nur die verstrichene Zeit - nicht, ob der vorige Lauf noch arbeitet. +Ein voller Lauf dauert länger als das Intervall (43 Suchbegriffe über mehrere +Börsen), also überholen sich die Läufe und stapeln sich auf. + +Deshalb hält `pipeline.js` ein Modul-Flag. Beide Aufrufer teilen es, weil beide +dasselbe Modul importieren: + +```js +let pipelineRunning = false; +let pipelineStartedAtMs = null; +const PIPELINE_MAX_RUNTIME_MS = 60 * 60 * 1000; + +export const runMatchingPipeline = async () => { + if (pipelineRunning) { /* ... überspringen ... */ } + pipelineRunning = true; + pipelineStartedAtMs = Date.now(); + try { + return await executePipeline(); + } finally { + pipelineRunning = false; // zwingend im finally + pipelineStartedAtMs = null; + } +}; +``` + +Das `finally` ist der wichtigste Teil. Würde das Flag am Ende des `try`-Blocks +zurückgesetzt, bliebe es nach einer Ausnahme für immer `true` - der Scanner +würde bis zum nächsten Neustart des Dienstes nie wieder suchen. Das wäre +schlimmer als die Überlappung, gegen die die Sperre da ist. + +Die Route fragt zusätzlich `isPipelineRunning()` ab und antwortet mit **409**, +statt einen Start zu melden, den die Sperre gleich darauf verwirft. + +> **Warum das nötig ist - der Vorfall vom 26.08.2026.** Die alte App +> (`scanner.perinco.de`) hatte diese Sperre nicht. Gemessen an diesem Tag: fünf +> Pipeline-Starts in 16 Minuten, zwei davon zehn Sekunden auseinander. Jeder Lauf +> startet eigene Chromium-Instanzen, jede mit rund fünf Unterprozessen - in +> Summe **31 `chrome-headless-shell`-Prozesse** und **1,62 GB RSS** im Dienst, +> nach 2 Monaten 23 Tagen ohne Neustart. Auf einer Box mit 4 Kernen und 7,7 GiB +> RAM ergab das **Load Average 126**, 168 MB freien Speicher und einen zu 100 % +> gefüllten Swap. Die Maschine war im Thrashing und nahe am OOM-Killer; selbst +> `systemctl` und SSH brauchten Minuten. Ein Neustart brachte Load 0,87 und +> 277 MB RSS zurück. +> +> Wichtig für die Fehlersuche: die Chromium-Prozesse waren **kein Leck**. Alle +> Scraper schließen ihren Browser in einem `finally`. Die 31 Prozesse waren +> schlicht fünf gleichzeitige Läufe mal mehrere Börsen - die Sperre allein +> behebt das. + +Die Sperre schützt gegen Überholen, nicht gegen einen Lauf, der gar nicht +zurückkommt. Hängt ein Browser oder steht eine Verbindung, läuft das `finally` +nie. Dagegen das **Notventil** `PIPELINE_MAX_RUNTIME_MS`: läuft die Sperre +länger als eine Stunde, gibt der nächste Aufruf sie frei und startet neu. So +erholt sich der Scanner selbst, statt auf ein Deployment zu warten. + ## Parallelbetrieb - alles verdoppelt ```mermaid diff --git a/server/src/scanner/services/pipeline.js b/server/src/scanner/services/pipeline.js index f008f2b..949fed1 100644 --- a/server/src/scanner/services/pipeline.js +++ b/server/src/scanner/services/pipeline.js @@ -80,6 +80,16 @@ const sendEmailNotification = async (agent, project, matchPercentage, pros, cons * Bewertung zweimal bei OpenAI. */ let pipelineRunning = false; +let pipelineStartedAtMs = null; + +/* + * Notventil. Die Sperre schuetzt nur gegen Ueberholen, nicht gegen einen Lauf, + * der gar nicht zurueckkommt: haengt ein Browser oder steht eine Verbindung, + * laeuft das finally nie und der Scanner bleibt bis zum naechsten Neustart + * stumm - schlimmer als die Ueberlappung, gegen die die Sperre da ist. Nach + * dieser Zeit gibt der naechste Aufruf die Sperre frei und startet neu. + */ +const PIPELINE_MAX_RUNTIME_MS = 60 * 60 * 1000; export const isPipelineRunning = () => pipelineRunning; @@ -89,10 +99,17 @@ export const isPipelineRunning = () => pipelineRunning; */ export const runMatchingPipeline = async () => { if (pipelineRunning) { - console.log('[Pipeline] Lauf laeuft schon - neuer Aufruf uebersprungen.'); - return false; + const runningForMin = Math.round((Date.now() - pipelineStartedAtMs) / 60000); + + if (Date.now() - pipelineStartedAtMs < PIPELINE_MAX_RUNTIME_MS) { + console.log(`[Pipeline] Lauf laeuft schon (${runningForMin} min) - neuer Aufruf uebersprungen.`); + return false; + } + + console.warn(`[Pipeline] Lauf haengt seit ${runningForMin} min - Sperre wird freigegeben, neuer Lauf startet.`); } pipelineRunning = true; + pipelineStartedAtMs = Date.now(); /* * Der BEGINN des Laufs, nicht sein Ende. Das Suchintervall ist ein Takt und @@ -107,6 +124,7 @@ export const runMatchingPipeline = async () => { return await executePipeline(); } finally { pipelineRunning = false; + pipelineStartedAtMs = null; /* * Zeitpunkt festhalten, damit der Zeitplan einen Neustart uebersteht - sonst * beginnt nach jedem Deployment die Wartezeit von vorn. Auch ein manueller