[P1][BUG] Scheduler ist vollständig sequentiell — ein laufendes Backup blockiert alle anderen Jobs und Ticks (Blocker für Mehr-Node-Betrieb) #226

Open
opened 2026-07-27 10:44:21 +00:00 by chinux · 1 comment
Owner

Blockiert das Anbinden weiterer Produktiv-Nodes.

Befund

server/scheduler.py:370run_scheduler() ist eine einzige sequentielle Schleife, und _tick() (Zeile 114) arbeitet fällige Jobs nacheinander mit await ab:

for job in due:
    ...
    status, output = await _run_job(job)   # blockiert bis fertig

_run_job() setzt für Backups timeout = 3660 (61 Minuten), für command.run bis zu 3600 s (command_router.py, min(3600, timeout)).

Auswirkung

Solange ein Backup läuft, läuft im gesamten Scheduler nichts anderes:

  • keine Backups anderer Nodes
  • kein _cve_scan_tick()
  • kein _docker_check_tick()
  • kein maybe_run_docgen()
  • kein _auto_update_tick()

Alle diese Ticks stehen in run_scheduler() hinter await _tick().

Rechnung: 10 Produktiv-Nodes, nächtliches Backup à 40 min → 400 min = 6,7 Stunden serialisiert. Ein Backup-Fenster von 4 Stunden reicht nicht. Job 1 läuft um 2:00, Job 10 um 8:00 während der Arbeitszeit.

Mit einem Node ist das unauffällig. Genau deshalb fällt es jetzt nicht auf und beim Hochskalieren sofort.

Sekundäreffekt

Ein hängender Agent (siehe #214) hält den vollen Timeout. 61 Minuten Scheduler-Stillstand durch einen einzigen unerreichbaren Node.


Prompt für Claude Code

Mache den Scheduler in theProx nebenläufig, ohne die Semantik zu ändern.

--- 1. TICKS ENTKOPPELN ---
run_scheduler(): die fünf Ticks (_tick, _cve_scan_tick, _docker_check_tick,
maybe_run_docgen, _auto_update_tick) laufen heute streng nacheinander in EINER
Schleife. Jeder bekommt seine EIGENE Endlosschleife als separater Task:

    async def _loop(name, fn, interval):
        while True:
            try: await fn()
            except Exception as e: log.error("%s tick error: %s", name, e)
            await asyncio.sleep(interval)

    async def run_scheduler():
        await asyncio.gather(
            _loop("jobs",   _tick,               60),
            _loop("cve",    _cve_scan_tick,      60),
            _loop("docker", _docker_check_tick,  60),
            _loop("docgen", maybe_run_docgen,    60),
            _loop("auto",   _auto_update_tick,   60),
        )

Die bestehenden Minuten-Gates (_LAST_CVE_TICK etc.) bleiben unverändert — sie
sind weiterhin nötig und jetzt pro Loop unabhängig.

--- 2. JOBS PARALLEL, ABER BEGRENZT ---
_tick(): fällige Jobs nicht sequentiell awaiten. Pro Job ein Task, begrenzt
über eine Semaphore (Settings-Key "scheduler.max_parallel_jobs", Default 4).

WICHTIG — Serialisierung PRO NODE beibehalten: zwei gleichzeitige vzdump auf
demselben Host sind schädlich. Also:
  - globale Semaphore für die Gesamtlast
  - PLUS ein per-Node-Lock (dict[node_name] -> asyncio.Lock), sodass pro Node
    immer nur ein Job läuft
Jobs verschiedener Nodes laufen damit parallel, Jobs desselben Nodes weiterhin
hintereinander. Das ist der eigentliche Punkt.

--- 3. DOPPELSTART VERHINDERN ---
Das Slot-Claiming (next_run_at VOR dem Lauf setzen) bleibt. Zusätzlich ein
In-Memory-Set laufender job_ids, damit ein Job, der länger als sein Intervall
braucht, nicht ein zweites Mal gestartet wird. Beim Überspringen eine
Log-Zeile schreiben ("job X läuft noch, überspringe") — stilles Überspringen
ist später nicht diagnostizierbar.

--- 4. SHUTDOWN ---
Die Tasks müssen bei Backend-Shutdown sauber beendet werden
(asyncio.CancelledError abfangen, laufende Jobs NICHT abbrechen sondern
auslaufen lassen, Timeout dafür). main.py:110 startet run_scheduler() als
create_task — den Handle festhalten und im shutdown-Event canceln.

--- 5. TESTS ---
tests/test_scheduler_concurrency.py:
  - zwei Jobs auf VERSCHIEDENEN Nodes starten gleichzeitig
  - zwei Jobs auf DEMSELBEN Node laufen nacheinander
  - Semaphore-Grenze wird eingehalten (5 Jobs, max_parallel=2 -> nie 3 parallel)
  - ein Job, der länger als sein Intervall läuft, wird nicht doppelt gestartet
  - ein Tick-Fehler in einem Loop stoppt die anderen Loops nicht
  (Jobs mocken, keine echten Agent-Calls)

NICHT ändern: Job-Semantik, JOB_ACTION_MAP, record_job_run,
Notification-Verhalten.

Definition of Done

  • Ein laufendes Backup blockiert keine anderen Ticks
  • Jobs verschiedener Nodes laufen parallel
  • Jobs desselben Nodes bleiben serialisiert
  • Parallelität begrenzt und konfigurierbar
  • Überlanger Job wird nicht doppelt gestartet
  • Sauberer Shutdown
**Blockiert das Anbinden weiterer Produktiv-Nodes.** ## Befund `server/scheduler.py:370` — `run_scheduler()` ist eine einzige sequentielle Schleife, und `_tick()` (Zeile 114) arbeitet fällige Jobs **nacheinander mit `await`** ab: ```python for job in due: ... status, output = await _run_job(job) # blockiert bis fertig ``` `_run_job()` setzt für Backups `timeout = 3660` (61 Minuten), für `command.run` bis zu 3600 s (`command_router.py`, `min(3600, timeout)`). ## Auswirkung Solange ein Backup läuft, läuft **im gesamten Scheduler nichts anderes**: - keine Backups anderer Nodes - kein `_cve_scan_tick()` - kein `_docker_check_tick()` - kein `maybe_run_docgen()` - kein `_auto_update_tick()` Alle diese Ticks stehen in `run_scheduler()` **hinter** `await _tick()`. Rechnung: 10 Produktiv-Nodes, nächtliches Backup à 40 min → 400 min = **6,7 Stunden serialisiert**. Ein Backup-Fenster von 4 Stunden reicht nicht. Job 1 läuft um 2:00, Job 10 um 8:00 während der Arbeitszeit. Mit einem Node ist das unauffällig. Genau deshalb fällt es jetzt nicht auf und beim Hochskalieren sofort. ## Sekundäreffekt Ein hängender Agent (siehe #214) hält den vollen Timeout. 61 Minuten Scheduler-Stillstand durch einen einzigen unerreichbaren Node. --- ## Prompt für Claude Code ``` Mache den Scheduler in theProx nebenläufig, ohne die Semantik zu ändern. --- 1. TICKS ENTKOPPELN --- run_scheduler(): die fünf Ticks (_tick, _cve_scan_tick, _docker_check_tick, maybe_run_docgen, _auto_update_tick) laufen heute streng nacheinander in EINER Schleife. Jeder bekommt seine EIGENE Endlosschleife als separater Task: async def _loop(name, fn, interval): while True: try: await fn() except Exception as e: log.error("%s tick error: %s", name, e) await asyncio.sleep(interval) async def run_scheduler(): await asyncio.gather( _loop("jobs", _tick, 60), _loop("cve", _cve_scan_tick, 60), _loop("docker", _docker_check_tick, 60), _loop("docgen", maybe_run_docgen, 60), _loop("auto", _auto_update_tick, 60), ) Die bestehenden Minuten-Gates (_LAST_CVE_TICK etc.) bleiben unverändert — sie sind weiterhin nötig und jetzt pro Loop unabhängig. --- 2. JOBS PARALLEL, ABER BEGRENZT --- _tick(): fällige Jobs nicht sequentiell awaiten. Pro Job ein Task, begrenzt über eine Semaphore (Settings-Key "scheduler.max_parallel_jobs", Default 4). WICHTIG — Serialisierung PRO NODE beibehalten: zwei gleichzeitige vzdump auf demselben Host sind schädlich. Also: - globale Semaphore für die Gesamtlast - PLUS ein per-Node-Lock (dict[node_name] -> asyncio.Lock), sodass pro Node immer nur ein Job läuft Jobs verschiedener Nodes laufen damit parallel, Jobs desselben Nodes weiterhin hintereinander. Das ist der eigentliche Punkt. --- 3. DOPPELSTART VERHINDERN --- Das Slot-Claiming (next_run_at VOR dem Lauf setzen) bleibt. Zusätzlich ein In-Memory-Set laufender job_ids, damit ein Job, der länger als sein Intervall braucht, nicht ein zweites Mal gestartet wird. Beim Überspringen eine Log-Zeile schreiben ("job X läuft noch, überspringe") — stilles Überspringen ist später nicht diagnostizierbar. --- 4. SHUTDOWN --- Die Tasks müssen bei Backend-Shutdown sauber beendet werden (asyncio.CancelledError abfangen, laufende Jobs NICHT abbrechen sondern auslaufen lassen, Timeout dafür). main.py:110 startet run_scheduler() als create_task — den Handle festhalten und im shutdown-Event canceln. --- 5. TESTS --- tests/test_scheduler_concurrency.py: - zwei Jobs auf VERSCHIEDENEN Nodes starten gleichzeitig - zwei Jobs auf DEMSELBEN Node laufen nacheinander - Semaphore-Grenze wird eingehalten (5 Jobs, max_parallel=2 -> nie 3 parallel) - ein Job, der länger als sein Intervall läuft, wird nicht doppelt gestartet - ein Tick-Fehler in einem Loop stoppt die anderen Loops nicht (Jobs mocken, keine echten Agent-Calls) NICHT ändern: Job-Semantik, JOB_ACTION_MAP, record_job_run, Notification-Verhalten. ``` ## Definition of Done - [ ] Ein laufendes Backup blockiert keine anderen Ticks - [ ] Jobs verschiedener Nodes laufen parallel - [ ] Jobs desselben Nodes bleiben serialisiert - [ ] Parallelität begrenzt und konfigurierbar - [ ] Überlanger Job wird nicht doppelt gestartet - [ ] Sauberer Shutdown
Author
Owner

Aus dem Code-Review vom 2026-07-27 (Commit 4152c4d). Gesamtblock: #226, #227, #228, #229, #230, #231.

Abarbeitung niedrig → hoch: #231#230#229#228#227#226.

Aus dem Code-Review vom 2026-07-27 (Commit 4152c4d). Gesamtblock: #226, #227, #228, #229, #230, #231. Abarbeitung niedrig → hoch: #231 → #230 → #229 → #228 → #227 → #226.
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
chinux/theProx#226
No description provided.