Zum Inhalt

Asynchrone UI, Lazy Tabs und Hintergrunddienste

1. Zielbild

Die Qt-Oberfläche muss auch bei großen SQLite-Datenbanken, Backups, Signaturprüfungen und umfangreichen Tachodaten bedienbar bleiben. Deshalb gelten drei Architekturregeln:

  1. Der Qt-Hauptthread konstruiert und verändert ausschließlich Widgets und Modelle.
  2. Datenbank-, Datei-, Netzwerk- und Rechenarbeit läuft in Workern oder dedizierten Diensten.
  3. Nicht sichtbare Arbeitsbereiche und Detailtabs werden erst bei Bedarf erzeugt und geladen.
flowchart LR
    U["Benutzereingabe"] --> Q["Qt-Hauptthread"]
    Q --> L["Lazy-Tab-Factory"]
    Q --> J["UiJobController"]
    J --> R["Read-Pool 4 Worker"]
    J --> W["Write-Lane 1 Worker"]
    J --> P["Prefetch-Lane 1 Worker"]
    R --> D["Eigene SQLite-Verbindung"]
    W --> D
    P --> D
    D --> C["DTO / Liste / Dictionary"]
    C --> Q
    B["Hintergrunddienste"] --> A["Activity Registry"]
    J --> A
    A --> S["Statusleiste"]
    H["UI-Heartbeat"] --> S

2. Startreihenfolge

Die Reihenfolge in main.py verhindert, dass Migrationen und Runtime-Abfragen gleichzeitig stattfinden:

  1. Konfiguration und Logger laden.
  2. Einen vorgemerkten Datenbankrestore anwenden.
  3. Zentralen Datenbank-Bootstrap und komponentenbezogene Migrationen ausführen.
  4. Runtime-Repositories mit ensure_schema=False öffnen.
  5. UiJobController und UiHeartbeatMonitor installieren.
  6. Hauptfenster mit einer tatsächlichen beziehungsweise temporären Systemsitzung erstellen.
  7. Login durchführen und nur benötigte Startansichten laden.
  8. Backup, Benachrichtigungen, Scheduler und optionale REST-Dienste starten.
sequenceDiagram
    participant App as QApplication
    participant Restore as Pending Restore
    participant Bootstrap as Database Bootstrap
    participant Jobs as UiJobController
    participant Window as MainWindow
    participant Services as Hintergrunddienste

    App->>Restore: Marker prüfen und ggf. atomar austauschen
    App->>Bootstrap: WAL konfigurieren und Migrationen einmalig ausführen
    Bootstrap-->>App: quick_check = ok
    App->>Jobs: Pools und Heartbeat installieren
    App->>Window: UI ohne Runtime-Migration erzeugen
    Window->>Window: Login und Sitzungsanzeige
    Window->>Services: Backup, Timer, Benachrichtigungen und REST starten
    Window->>Jobs: nur sichtbare Startansicht laden

3. Lazy Tabs

Ein Lazy Tab enthält zunächst nur einen leichten Platzhalter und eine Factoryfunktion. Erst currentChanged erzeugt das echte Widget.

Der Ablauf in FileWidget ist:

  1. _add_lazy_tab(title, factory) legt Platzhalter und _fleet_lazy_factory an.
  2. Beim Tabwechsel wird der vorherige Tab über cancel_load() zur kooperativen Stornierung aufgefordert.
  3. Ein QSignalBlocker verhindert rekursive currentChanged-Signale.
  4. Die Factory erzeugt das echte Widget.
  5. Platzhalter und echter Tab werden positionsgleich ausgetauscht.
  6. ensure_loaded() startet bei Bedarf genau einen Ladevorgang.
  7. Bereits erzeugte Tabs bleiben erhalten, bis die Akte vollständig neu aufgebaut wird.

Lazy sind insbesondere Fahrzeugfälligkeiten, Kosten, Rechnungen, Tachodatenbezüge, M-Dateien, Aktivitäten, Ereignisse, Geschwindigkeit, Technik, Kalibrierungen, Positionen und Audit.

stateDiagram-v2
    [*] --> Placeholder: Tab registriert
    Placeholder --> Constructing: Benutzer aktiviert Tab
    Constructing --> LoadedWidget: Factory erfolgreich
    LoadedWidget --> LoadingData: ensure_loaded / refresh
    LoadingData --> Ready: aktueller Job erfolgreich
    LoadingData --> Cancelled: Tabwechsel oder Akte geschlossen
    Cancelled --> LoadingData: erneute Aktivierung
    Ready --> LoadingData: Aktualisieren
    Ready --> Disposed: Akte wird neu aufgebaut
    Disposed --> [*]

Entwicklerregeln für Lazy Tabs

  • Konstruktoren dürfen keine umfangreiche Datenbankabfrage ausführen.
  • Ein Tab braucht einen stabilen Job-Key und möglichst ensure_loaded() sowie cancel_load().
  • Filterwerte werden im UI gelesen, bevor der Worker startet.
  • Beim Wiederaufbau müssen laufende Jobs storniert und Widgets mit deleteLater() freigegeben werden.
  • Ein Tab darf keine globale refresh_all()-Kaskade auslösen.

4. UiJobController

4.1 Jobbeschreibung

Ein UiJobSpec enthält:

Feld Bedeutung
key stabile Identität der Ansicht/Operation; neuer Job ersetzt den alten
name sichtbarer und protokollierter Taskname
work(token, report) Workerfunktion ohne Widgetzugriff
lane read, write oder prefetch
priority Qt-Threadpool-Priorität
metadata Diagnose- und Fachkontext

Die Pools sind bewusst begrenzt:

  • Read-Pool: maximal 4 parallele Jobs.
  • Write-Lane: genau 1 Job, damit Benutzeränderungen seriell committen.
  • Prefetch-Lane: genau 1 spekulativer Ladevorgang.

4.2 Generationen und veraltete Ergebnisse

Für jeden Job-Key führt der Controller eine Generation. Beim erneuten Submit desselben Keys wird der vorherige Token storniert und die Generation erhöht. Progress-, Erfolgs- und Fehlersignale werden nur ausgeliefert, wenn Handle, Generation und Key noch aktuell sind. Ein langsames altes Ergebnis kann daher keine inzwischen gewechselte Ansicht überschreiben.

Wird das besitzende Widget zerstört, storniert dessen destroyed-Signal den Token. Worker prüfen token.throw_if_cancelled() vor, während und nach längeren Phasen. SQLite-Abfragen sollten zusätzlich über Progress Handler oder zwischen Teilabfragen kooperativ abbrechbar bleiben.

sequenceDiagram
    participant View as Ansicht
    participant Controller as UiJobController
    participant Pool as Worker-Pool
    participant DB as SQLite

    View->>Controller: submit(key, generation n)
    Controller->>Controller: älteren Job mit gleichem Key stornieren
    Controller->>Pool: QRunnable starten
    Pool->>DB: eigene Verbindung öffnen
    DB-->>Pool: Zeilen / DTOs
    Pool->>Controller: succeeded(request_id, result)
    Controller->>Controller: Key, Generation und Token prüfen
    alt Ergebnis ist aktuell
        Controller-->>View: aktuelles Ergebnis als Qt-Signal
    else Ergebnis ist veraltet
        Controller->>Controller: Ergebnis verwerfen
    end
    View->>View: Modell/Widgets im Hauptthread aktualisieren

4.3 Ergebnis- und Fortschrittsmodell

JobProgress kann Text, Phase sowie current/total melden. JobResult enthält Wert, Request-ID, Jobname, Laufzeit und Metadaten. Der Controller protokolliert Wartezeit, Workerzeit, Phasenzeiten, Recordanzahl und Cachetreffer. Worker dürfen ausschließlich threadunabhängige Werte zurückgeben: Listen, Dictionaries, unveränderliche DTOs, Bytes oder Skalare. QObject, Modelle und Widgets dürfen den Thread nicht wechseln.

5. Datenbankarbeit in Workern

Jeder Worker öffnet seine SQLite-Verbindung selbst über die gemeinsame Runtime-Factory. Verbindungen werden niemals vom UI-Thread an Worker weitergereicht. Runtime-Repositories verwenden ensure_schema=False; DDL und Datenmigrationen gehören ausschließlich in den Start-Bootstrap.

Empfohlenes Muster:

def work(token, report):
    token.throw_if_cancelled()
    repo = VehicleUnitRepository(db_path, ensure_schema=False)
    report(JobProgress("Kalibrierungen werden geladen", phase="query"))
    rows = repo.list_vehicle_facts("ddd_vu_calibration", vehicle_id, date_from, date_to, limit, offset)
    token.throw_if_cancelled()
    return [dict(row) for row in rows]

Der Erfolgs-Callback setzt anschließend Zeilen, Seitennummern und Schaltflächen im Hauptthread. Schreibjobs committen vollständig in der Write-Lane und stoßen erst danach einen neuen Read-Job für die betroffene Ansicht an.

6. Hintergrunddienste

6.1 Gemeinsame Aktivitätsregistrierung

BackgroundActivityRegistry ist threadsicher und speichert pro stabilem Schlüssel einen sichtbaren Text, optional mit aktuell/gesamt. UI-Jobs, Backup und Benachrichtigungen liefern daraus einen gemeinsamen Snapshot. set() registriert oder aktualisiert, clear() entfernt die Aktivität zwingend in einem finally-Block.

Die Statusleiste fragt alle 400 ms ab und zeigt höchstens zwei Namen sowie die Zahl weiterer Tasks. Ihre Priorität ist:

  1. Shutdown;
  2. aktiver oder gerade beendeter UI-Stall;
  3. UI-Jobs, Scheduler, Backup, Verifizierung und Benachrichtigungen;
  4. temporäre Meldung;
  5. Hintergrund: bereit.

Links bleiben Benutzer, Rolle, Sprache und Region sichtbar; rechts stehen Fortschrittsbalken und Aktivität.

6.2 BackupCoordinator

Der BackupCoordinator besitzt einen dedizierten Daemon-Thread und läuft höchstens einmal gleichzeitig. Ein Durchlauf verarbeitet kleine Batches:

  • maximal 5 Tachographenarchive;
  • bis zu 20 lokale Finanzarchivmigrationen und 20 Finanzbackups;
  • bis zu 10 ausstehende REST-Buchungsexporte;
  • maximal 50 Remote-Präsenzprüfungen;
  • gegebenenfalls einen konsistenten Datenbanksnapshot.

Wartet ein Benutzer-Schreibjob, pausiert der Archivfortschritt in 100-ms-Schritten und zeigt Backup wartet auf eine Benutzeraktion. Datenbanksnapshots verwenden die SQLite-Backup-API in kleinen Seitenpaketen. Netzwerktransfer und Read-back finden außerhalb fachlicher SQLite-Transaktionen statt.

6.3 Scheduler, Benachrichtigungen und REST

  • Ein Qt-Timer prüft alle 30 Sekunden fällige Tasks und Benachrichtigungen.
  • Benachrichtigungen verwenden einen nichtblockierenden Lock und einen Daemon-Worker; parallele Läufe werden verworfen.
  • Geplante Tasks starten ihre Arbeit in eigenen Threads und melden laufende Tasknamen.
  • Die DriverCard REST API besitzt einen eigenen Serverthread und wird optional nach dem UI-Start aktiviert.
  • Signaturnachprüfung und Fristenabgleich starten als serialisierte UiJobs in der Write-Lane.
flowchart TD
    A["Dienst beginnt Arbeit"] --> B["Activity Registry: set"]
    B --> C{"Benutzer-Schreibjob aktiv?"}
    C -->|"Ja, Backup"| D["Kurz warten und Wartestatus anzeigen"]
    C -->|"Nein"| E["Kleinen Batch verarbeiten"]
    D --> C
    E --> F["DB-Transaktion kurz halten"]
    F --> G["Netzwerk/Read-back außerhalb der Transaktion"]
    G --> H{"Weitere Arbeit?"}
    H -->|"Ja"| E
    H -->|"Nein/Fehler"| I["Activity Registry: clear im finally"]

7. UI-Heartbeat und UI_STALL

Ein Qt-Timer schlägt alle 250 ms im Hauptthread. Ein separater Watcher prüft den Abstand zum letzten Beat. Ab einer Sekunde wird ein UI_STALL markiert und in der Statusleiste angezeigt. Zusätzliche Stack-Snapshots werden nach 5, 15 und 30 Sekunden protokolliert.

Ein Stall-Log enthält:

  • Phase detected, sample_5s, sample_15s, sample_30s oder recovered;
  • Verzögerung in Millisekunden;
  • aktive UiJobs und registrierte Hintergrundaktivitäten;
  • kompakten Stack des Qt-Hauptthreads.

Der Heartbeat ist Diagnose, keine Parallelisierung. Ein sichtbarer Stall bedeutet weiterhin, dass Arbeit im Hauptthread verblieben ist. Der Stack im app.log ist dann der Ausgangspunkt für die Korrektur.

stateDiagram-v2
    [*] --> Responsive
    Responsive --> Suspected: Beat älter als 1 s
    Suspected --> Stalled: UI_STALL detected protokollieren
    Stalled --> Stalled: Stack bei 5/15/30 s protokollieren
    Stalled --> Recovered: nächster Qt-Beat
    Recovered --> Responsive: Hinweis nach Haltezeit ausblenden

8. Kontrollierter Shutdown

Systemmenü und Tray verwenden denselben idempotenten Shutdownpfad. Normale Statusaktualisierungen und Timer werden gesperrt. Vor jedem potenziell blockierenden Stop wird die Qt-Oberfläche ohne neue Benutzereingaben aktualisiert.

Reihenfolge:

  1. Anwendung wird beendet;
  2. Scheduler- und Benachrichtigungstimer stoppen;
  3. DriverCard REST API stoppen;
  4. BackupCoordinator stoppen;
  5. Tray-Icon ausblenden;
  6. Dienste beendet – Anwendung wird geschlossen anzeigen;
  7. UiJobs stornieren und Threadpools begrenzt auslaufen lassen;
  8. Heartbeat stoppen und Qt beenden.
sequenceDiagram
    participant User as Benutzer/Tray
    participant UI as MainWindow
    participant REST as DriverCard REST
    participant Backup as BackupCoordinator
    participant Jobs as UiJobController
    participant App as QApplication

    User->>UI: Beenden
    UI->>UI: Shutdownstatus sperren und Fenster anzeigen
    UI->>UI: Scheduler/Benachrichtigungstimer stoppen
    UI->>REST: stop()
    UI->>Backup: stop(timeout)
    UI->>Jobs: laufende Tokens stornieren
    Jobs->>Jobs: Pools clear / waitForDone begrenzt
    UI->>App: schließen

9. Fehlersuche

Bei einer trägen oder eingefrorenen Oberfläche:

  1. app.log nach UI_STALL detected und den Folgesamples durchsuchen.
  2. Den letzten main_stack prüfen; Datenbank-, Datei- oder Netzaufrufe im UI-Thread sind ein Architekturfehler.
  3. active_jobs mit der Statusleiste vergleichen. Ein aktiver Worker erklärt Last, aber nicht automatisch einen blockierten Qt-Thread.
  4. Nach UI_JOB start/success/failed/cancelled mit gleicher Request-ID suchen.
  5. Prüfen, ob Repository-Konstruktoren versehentlich ensure_schema=True verwenden.
  6. Lange Schreibtransaktionen, fehlende Tokenprüfungen oder unbegrenzte Resultsets identifizieren.
  7. Erst nach Korrektur unter parallelem Backup und schnellem Tabwechsel testen.

10. Abnahmekriterien für neue Ansichten und Dienste

  • Konstruktion und Tabwechsel führen keine umfangreiche SQLite-Abfrage im Hauptthread aus.
  • Jeder Ladevorgang besitzt einen stabilen, ansichtsspezifischen Job-Key.
  • Alte Ergebnisse können keine neuere Ansicht aktualisieren.
  • SQLite-Verbindungen werden im ausführenden Thread geöffnet und geschlossen.
  • Write-Jobs sind serialisiert und lösen Aktualisierung erst nach Commit aus.
  • Große Tabellen verwenden fachlich korrekte Filter und Pagination.
  • Hintergrunddienste registrieren Aktivität und löschen sie auch bei Fehlern.
  • Shutdown kann mehrfach aufgerufen werden, beendet Dienste aber nur einmal.
  • Ein Lasttest mit parallelem Backup erzeugt keinen UI_STALL über eine Sekunde.