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:
- Der Qt-Hauptthread konstruiert und verändert ausschließlich Widgets und Modelle.
- Datenbank-, Datei-, Netzwerk- und Rechenarbeit läuft in Workern oder dedizierten Diensten.
- 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:
- Konfiguration und Logger laden.
- Einen vorgemerkten Datenbankrestore anwenden.
- Zentralen Datenbank-Bootstrap und komponentenbezogene Migrationen ausführen.
- Runtime-Repositories mit
ensure_schema=Falseöffnen. UiJobControllerundUiHeartbeatMonitorinstallieren.- Hauptfenster mit einer tatsächlichen beziehungsweise temporären Systemsitzung erstellen.
- Login durchführen und nur benötigte Startansichten laden.
- 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:
_add_lazy_tab(title, factory)legt Platzhalter und_fleet_lazy_factoryan.- Beim Tabwechsel wird der vorherige Tab über
cancel_load()zur kooperativen Stornierung aufgefordert. - Ein
QSignalBlockerverhindert rekursivecurrentChanged-Signale. - Die Factory erzeugt das echte Widget.
- Platzhalter und echter Tab werden positionsgleich ausgetauscht.
ensure_loaded()startet bei Bedarf genau einen Ladevorgang.- 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()sowiecancel_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:
- Shutdown;
- aktiver oder gerade beendeter UI-Stall;
- UI-Jobs, Scheduler, Backup, Verifizierung und Benachrichtigungen;
- temporäre Meldung;
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_30soderrecovered; - 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:
Anwendung wird beendet;- Scheduler- und Benachrichtigungstimer stoppen;
- DriverCard REST API stoppen;
- BackupCoordinator stoppen;
- Tray-Icon ausblenden;
Dienste beendet – Anwendung wird geschlossenanzeigen;- UiJobs stornieren und Threadpools begrenzt auslaufen lassen;
- 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:
app.lognachUI_STALL detectedund den Folgesamples durchsuchen.- Den letzten
main_stackprüfen; Datenbank-, Datei- oder Netzaufrufe im UI-Thread sind ein Architekturfehler. active_jobsmit der Statusleiste vergleichen. Ein aktiver Worker erklärt Last, aber nicht automatisch einen blockierten Qt-Thread.- Nach
UI_JOB start/success/failed/cancelledmit gleicher Request-ID suchen. - Prüfen, ob Repository-Konstruktoren versehentlich
ensure_schema=Trueverwenden. - Lange Schreibtransaktionen, fehlende Tokenprüfungen oder unbegrenzte Resultsets identifizieren.
- 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.