diff --git a/README.md b/README.md index 98cb77f..9d8f38a 100644 --- a/README.md +++ b/README.md @@ -272,7 +272,7 @@ zu starten. | `SCANNER_ENCRYPTION_KEY` | eigener Schluessel fuer die Portal-Passwoerter; ohne ihn wird ein bekannter Ersatzschluessel benutzt und beim Start gewarnt | | `SCANNER_UPLOAD_DIR` | eigenes CV-Verzeichnis; leer = `/uploads` | | `SCANNER_SCHEDULER` | Hintergrund-Scraper; Standard an, nur `false` schaltet ab | -| `SCANNER_SCHEDULER_OFFSET` | Minuten Versatz fuer den ersten Lauf, Standard `30` | +| `SCANNER_SCHEDULER_OFFSET` | Minuten Versatz fuer den **allerersten** Lauf, Standard `30`. Danach traegt `settings.last_scan_at` den Zeitplan ueber Neustarts hinweg | > **Kosten:** Beide Instanzen bewerten dieselben Projekte unabhaengig > voneinander - die OpenAI-Kosten fallen also doppelt an, solange die alte App @@ -281,11 +281,15 @@ zu starten. > dann weiter. Der **Zeitversatz** ist nicht Kosmetik: beide Instanzen benutzen dieselben -Portal-Sessions (aus derselben Cookie-Quelle) und wuerden nach einem Neustart +Portal-Sessions (aus derselben Cookie-Quelle) und wuerden beim ersten Start gleichzeitig losziehen - das provoziert Rate-Limits und CAPTCHAs bei Freelancermap und Freelance.de. `SCANNER_SCHEDULER_OFFSET=30` verschiebt den ersten Lauf um 30 Minuten, danach laufen beide im eigenen Intervall verschraenkt. -Wird der Wert >= Suchintervall gesetzt, wird er ignoriert. + +Ab dem zweiten Start traegt `settings.last_scan_at` den Zeitplan - die Frist +greift dann nicht mehr. Sonst begann sie bei jedem Deployment von vorn und ein +faelliger Lauf konnte beliebig lange ausfallen (siehe +[docs/03-scanner.md](docs/03-scanner.md#wann-laeuft-er)). Innerhalb dieser Instanz kann **nur ein Lauf** gleichzeitig arbeiten: Scheduler und *Jetzt scannen* teilen sich eine Sperre (`isPipelineRunning`), ein zweiter diff --git a/docs/03-scanner.md b/docs/03-scanner.md index 4d3dbf3..3b3e57e 100644 --- a/docs/03-scanner.md +++ b/docs/03-scanner.md @@ -53,26 +53,43 @@ flowchart TD A -->|nein| STOP1["nichts tun"] A -->|ja| B{"Intervall geaendert?"} B -->|ja| RE["neu planen"] - B -->|nein| C{"Sperrfrist nach Start
SCANNER_SCHEDULER_OFFSET"} + B -->|nein| C{"Sperrfrist
nur beim allerersten Start"} C -->|noch aktiv| STOP2["nichts tun"] C -->|abgelaufen| D{"Mo-Fr?"} D -->|nein| STOP3["nichts tun"] D -->|ja| E{"07:00-21:59?"} E -->|nein| STOP4["nichts tun"] - E -->|ja| F{"Intervall seit
letztem Lauf um?"} + E -->|ja| S["last_scan_at aus der DB lesen
(faengt manuelle Laeufe mit ein)"] + S --> F{"Intervall seit
letztem Lauf um?"} F -->|nein| STOP5["nichts tun"] F -->|ja| G["Pipeline starten"] + G --> W["last_scan_at schreiben"] ``` Die **Sperrfrist** ist die Antwort auf ein konkretes Problem: beide Instanzen -benutzen dieselben Portal-Cookies. Ohne Versatz ziehen nach einem Neustart beide +benutzen dieselben Portal-Cookies. Beim ersten Start ziehen sonst beide gleichzeitig los, was Rate-Limits und CAPTCHAs provoziert - und zwei Chromium gleichzeitig belasten den Speicher doppelt. -> Fruehere Fassung war fehlerhaft: der Versatz wurde ueber einen vorgealterten -> `lastRunTime` gebaut. Ein Zeitstempel kann einen Lauf aber nur **vorziehen**, -> nie verzoegern - bei Intervall 15 und Versatz 30 lief er deshalb sofort. Jetzt -> ist es eine eigene Frist (`notBefore`). +Der Zeitpunkt des letzten Laufs steht in `settings.last_scan_at`. Das ist kein +Luxus, sondern nach zwei beobachteten Fehlern noetig: + +> **Erster Fehler:** Der Versatz war ueber einen vorgealterten `lastRunTime` +> gebaut. Ein Zeitstempel kann einen Lauf aber nur **vorziehen**, nie verzoegern - +> bei Intervall 15 und Versatz 30 lief er sofort. Seither eine eigene Frist +> (`notBefore`). +> +> **Zweiter Fehler:** Diese Frist begann bei **jedem** Neustart von vorn. Zwei +> Deployments im Abstand von zehn Minuten haben den faelligen Lauf zweimal um eine +> halbe Stunde verschoben - er fand nie statt. Wer oefter als alle 30 Minuten +> deployt, haette einen Scanner, der nie automatisch sucht. Deshalb liegt der +> Zeitpunkt jetzt in der Datenbank, und der Versatz greift nur, solange dort noch +> nichts steht. + +Der gespeicherte Zeitpunkt wird auch **im Tick** gelesen: ein manueller Scan aus +der Oberflaeche schreibt ihn, weiss aber nichts von der Variablen im Scheduler. +Ohne den Abgleich koennte der Scheduler direkt hinter einem manuellen Lauf noch +einmal losziehen. ## Parallelbetrieb - alles verdoppelt diff --git a/server/.env.example b/server/.env.example index 19e769f..ce1a954 100644 --- a/server/.env.example +++ b/server/.env.example @@ -52,9 +52,11 @@ SCANNER_UPLOAD_DIR= # dieselben Projekte unabhaengig voneinander - die OpenAI-Kosten fallen doppelt an. SCANNER_SCHEDULER=true -# Minuten Versatz fuer den ersten Lauf nach einem Neustart. Verhindert, dass +# Minuten Versatz fuer den allerersten Lauf dieser Instanz. Verhindert, dass # beide Scanner gleichzeitig mit denselben Portal-Sessions losziehen (CAPTCHA). -# 0 = sofort loslegen. Wird ignoriert, wenn der Wert >= Suchintervall ist. +# Greift nur, solange noch kein Lauf stattgefunden hat - danach traegt der in der +# Datenbank gespeicherte Zeitpunkt (last_scan_at) den Zeitplan ueber Neustarts +# hinweg. 0 = sofort loslegen. SCANNER_SCHEDULER_OFFSET=30 # Fallback fuer den OpenAI-Key; bevorzugt wird der Wert aus den Einstellungen. diff --git a/server/src/scanner/services/pipeline.js b/server/src/scanner/services/pipeline.js index 14fb290..949e956 100644 --- a/server/src/scanner/services/pipeline.js +++ b/server/src/scanner/services/pipeline.js @@ -97,6 +97,15 @@ export const runMatchingPipeline = async () => { return await executePipeline(); } finally { pipelineRunning = false; + /* + * Zeitpunkt festhalten, damit der Zeitplan einen Neustart uebersteht - sonst + * beginnt nach jedem Deployment die Wartezeit von vorn. Auch ein manueller + * Scan zaehlt: er hat die Boersen ja gerade abgefragt. + * + * Im finally, also auch nach einem Abbruch: ein gescheiterter Lauf soll sich + * nicht sofort wiederholen. + */ + await Setting.setVal('last_scan_at', new Date().toISOString()).catch(() => {}); } }; diff --git a/server/src/scanner/services/scheduler.js b/server/src/scanner/services/scheduler.js index ef6947f..1f9414b 100644 --- a/server/src/scanner/services/scheduler.js +++ b/server/src/scanner/services/scheduler.js @@ -27,25 +27,40 @@ export const startScheduler = async () => { } /* - * Zeitversatz zum parallel laufenden alten Scanner. Ohne ihn starten beide - * Instanzen direkt nach einem Neustart gleichzeitig und greifen mit - * denselben Portal-Sessions zeitgleich auf die Boersen zu - das provoziert - * Rate-Limits und CAPTCHAs. Mit Versatz laufen die Laeufe verschraenkt. + * Der Zeitpunkt des letzten Laufs steht in der Datenbank (`last_scan_at`, + * geschrieben von der Pipeline). Damit ueberlebt der Zeitplan einen Neustart: + * ohne diesen Wert begann nach jedem Deployment wieder eine Sperrfrist von + * vorn - zwei Deployments im Abstand von zehn Minuten haben den faelligen + * Lauf zweimal um eine halbe Stunde verschoben, er fand also nie statt. */ - const offsetMinutes = Number(process.env.SCANNER_SCHEDULER_OFFSET ?? 30); - const useOffset = Number.isFinite(offsetMinutes) && offsetMinutes > 0; + const storedLast = await Setting.getVal('last_scan_at', null); + const parsedLast = storedLast ? new Date(storedLast) : null; + let lastRunTime = parsedLast && !Number.isNaN(parsedLast.getTime()) ? parsedLast : null; /* - * Eigene Sperrfrist statt eines vorgealterten lastRunTime: ein Zeitstempel - * kann einen Lauf nur vorziehen, nie verzoegern. Sobald der Versatz groesser - * als das Suchintervall ist - bei 15 Minuten Intervall der Normalfall - - * waere der erste Lauf sonst sofort faellig gewesen. + * Zeitversatz zum parallel laufenden alten Scanner - aber nur beim allerersten + * Start dieser Instanz. Danach traegt der gespeicherte Zeitstempel den + * Zeitplan, und der laeuft von sich aus versetzt zum alten Scanner weiter. + * + * Als eigene Frist umgesetzt und nicht ueber einen vorgealterten + * `lastRunTime`: ein Zeitstempel kann einen Lauf nur vorziehen, nie + * verzoegern - bei Intervall 15 und Versatz 30 waere er sofort faellig. */ + const offsetMinutes = Number(process.env.SCANNER_SCHEDULER_OFFSET ?? 30); + const useOffset = Number.isFinite(offsetMinutes) && offsetMinutes > 0 && lastRunTime === null; const notBefore = useOffset ? Date.now() + offsetMinutes * 60 * 1000 : 0; - let lastRunTime = null; if (useOffset) { - console.log(`[Scheduler] Erster Lauf in ${offsetMinutes} Minuten (Versatz zum alten Scanner).`); + console.log( + `[Scheduler] Erster Lauf in ${offsetMinutes} Minuten (Versatz zum alten Scanner).` + ); + } else if (lastRunTime) { + const dueAt = new Date(lastRunTime.getTime() + intervalMinutes * 60 * 1000); + const minutes = Math.max(0, Math.round((dueAt.getTime() - Date.now()) / 60000)); + console.log( + `[Scheduler] Letzter Lauf ${lastRunTime.toLocaleString('de-DE')}, ` + + `naechster in ${minutes} Minuten.` + ); } // We check every minute if we are due for a run. @@ -88,6 +103,18 @@ export const startScheduler = async () => { return; } + /* + * Auch den gespeicherten Zeitpunkt beruecksichtigen: ein manueller Scan aus + * der Oberflaeche schreibt ihn, weiss aber nichts von der Variablen hier. + * Ohne diesen Abgleich koennte der Scheduler direkt hinter einem manuellen + * Lauf noch einmal losziehen. + */ + const stored = await Setting.getVal('last_scan_at', null); + const storedTime = stored ? new Date(stored).getTime() : 0; + if (Number.isFinite(storedTime) && storedTime > (lastRunTime?.getTime() ?? 0)) { + lastRunTime = new Date(storedTime); + } + // Run if never run before, or if enough time has passed const isDue = !lastRunTime || (now - lastRunTime) >= activeIntervalMinutes * 60 * 1000;