Asynchronous UI, lazy tabs and background services¶
1. Design rules¶
The Qt event loop must remain responsive while SQLite queries, backups, signature verification and tachograph processing run. Three rules enforce this:
- The Qt main thread constructs and changes widgets/models only.
- Database, file, network and CPU work runs in workers or dedicated services.
- Invisible workspaces and detail tabs are constructed and loaded only when needed.
flowchart LR
U["User input"] --> Q["Qt main thread"]
Q --> L["Lazy-tab factory"]
Q --> J["UiJobController"]
J --> R["Read pool: 4"]
J --> W["Write lane: 1"]
J --> P["Prefetch lane: 1"]
R --> D["Worker-owned SQLite connection"]
W --> D
P --> D
D --> C["DTO / list / dictionary"]
C --> Q
B["Background services"] --> A["Activity registry"]
J --> A
A --> S["Status bar"]
H["UI heartbeat"] --> S
2. Startup order¶
The application applies a pending restore, runs central versioned database bootstrap, opens runtime repositories with ensure_schema=False, installs UiJobController and UiHeartbeatMonitor, creates the window, authenticates the user, loads only the visible start view and then starts background services.
sequenceDiagram
participant App as QApplication
participant Restore as Pending restore
participant Bootstrap as DB bootstrap
participant Jobs as UiJobController
participant Window as MainWindow
participant Services as Background services
App->>Restore: Verify marker and atomically replace if present
App->>Bootstrap: Configure WAL and run migrations once
Bootstrap-->>App: quick_check = ok
App->>Jobs: Install pools and heartbeat
App->>Window: Build UI without runtime DDL
Window->>Services: Start timers, backup and optional REST
Window->>Jobs: Load visible view only
3. Lazy tabs¶
FileWidget._add_lazy_tab() stores a lightweight placeholder and factory. On currentChanged, the previous page is asked to cancel_load(), a QSignalBlocker prevents recursion, the factory creates the real widget, and the placeholder is replaced at the same index. ensure_loaded() then starts one data job if required.
Vehicle deadlines, costs, invoices, tachograph references, M files, activities, events, speed, technical data, calibrations, positions and audit are lazy. Constructors must not perform large queries. Each page should provide a stable job key, ensure_loaded() and cancel_load().
stateDiagram-v2
[*] --> Placeholder
Placeholder --> Constructing: tab activated
Constructing --> LoadedWidget: factory completes
LoadedWidget --> LoadingData: ensure_loaded / refresh
LoadingData --> Ready: current job succeeds
LoadingData --> Cancelled: tab changed or closed
Cancelled --> LoadingData: activated again
Ready --> LoadingData: refresh
Ready --> Disposed: record rebuilt
4. UiJobController¶
A UiJobSpec defines a stable key, visible name, work(token, report), lane, priority and metadata. Read jobs use a four-thread pool. Writes are serialised through one worker. Prefetch has one worker.
Submitting the same key cancels the previous token and increments a generation. Signals are delivered only when handle, generation and key are still current. Destroying the owner cancels its token, so an old slow result cannot update a new or closed view.
JobProgress reports text, phase and optional current/total. JobResult includes request ID, duration and metadata such as wait time, phase time, record count and cache hit. Workers return thread-neutral values only; no widget, model or other QObject may cross the worker boundary.
sequenceDiagram
participant View
participant Controller as UiJobController
participant Pool as Worker pool
participant DB as SQLite
View->>Controller: submit(key, next generation)
Controller->>Controller: cancel old token for key
Controller->>Pool: start QRunnable
Pool->>DB: open connection in worker
DB-->>Pool: rows / DTOs
Pool->>Controller: success(request_id, result)
Controller->>Controller: validate key, generation and token
Controller-->>View: emit only current result
View->>View: update model in Qt thread
5. Worker database pattern¶
Every worker opens and closes its own connection through the shared runtime factory. Connections are never passed from the UI thread. Schema changes belong to startup bootstrap only.
def work(token, report):
token.throw_if_cancelled()
repo = VehicleUnitRepository(db_path, ensure_schema=False)
report(JobProgress("Loading calibrations", phase="query"))
rows = repo.list_vehicle_facts("ddd_vu_calibration", vehicle_id, lower, upper, limit, offset)
token.throw_if_cancelled()
return [dict(row) for row in rows]
Write jobs commit in the write lane and trigger a new read job only after commit. Filters are captured before worker submission, and large result sets are paginated.
6. Background services and status¶
BackgroundActivityRegistry is a thread-safe map from stable task key to display text and optional progress. Every service must clear its entry in finally. The status bar polls at 400 ms and prioritises shutdown, active/recent UI stall, background work, transient messages and finally Background: ready.
The BackupCoordinator owns one daemon thread and one non-reentrant run lock. A cycle handles small batches: up to 5 tachograph archives, 20 local financial migrations, 20 financial backups, 10 REST export retries and 50 remote presence checks, plus a due database snapshot. Backup progress pauses in 100-ms increments while a user write job is waiting. SQLite backup uses small page batches; network transfer and read-back occur outside functional transactions.
The scheduler and notification timer runs every 30 seconds. Notification delivery uses a non-blocking lock and daemon worker. Scheduled tasks and the DriverCard REST API run in dedicated threads. Signature re-verification and deadline reconciliation use the serial write lane.
flowchart TD
A["Service starts"] --> B["Activity registry: set"]
B --> C{"User write waiting?"}
C -->|"Yes, backup"| D["Wait briefly and expose status"]
C -->|"No"| E["Process bounded batch"]
D --> C
E --> F["Keep DB transaction short"]
F --> G["Network/read-back outside transaction"]
G --> H{"More work?"}
H -->|"Yes"| E
H -->|"No/error"| I["Activity registry: clear in finally"]
7. UI heartbeat and UI_STALL¶
A Qt timer beats every 250 ms. A separate watcher marks UI_STALL after one second without a beat and logs additional main-thread stack samples after 5, 15 and 30 seconds. The log includes delay, active jobs/background tasks and a compact main-thread stack. Recovery is logged on the next beat.
The heartbeat diagnoses blocking; it does not make blocking code asynchronous. A stack ending in SQLite, file or network code in the main thread must be moved to a worker.
stateDiagram-v2
[*] --> Responsive
Responsive --> Stalled: no beat for 1 s
Stalled --> Stalled: stack samples at 5/15/30 s
Stalled --> Recovered: next Qt beat
Recovered --> Responsive: retention expires
8. Controlled shutdown¶
System and tray exits share one idempotent path. Normal status timers are stopped and the visible shutdown sequence is: stop scheduler/notification timer, stop DriverCard REST, stop backup coordinator, hide tray, display completion, cancel UiJobs, wait for pools for a bounded period, stop heartbeat, then close Qt.
sequenceDiagram
participant User
participant UI as MainWindow
participant REST as DriverCard REST
participant Backup as BackupCoordinator
participant Jobs as UiJobController
participant App as QApplication
User->>UI: Exit
UI->>UI: Lock shutdown status and stop timers
UI->>REST: stop()
UI->>Backup: stop(timeout)
UI->>Jobs: cancel tokens and clear queues
Jobs->>Jobs: bounded waitForDone
UI->>App: close
9. Troubleshooting and acceptance¶
Search app.log for UI_STALL, inspect main_stack, correlate active task names and follow the same request ID through UI_JOB start/success/failed/cancelled. Verify that repositories use ensure_schema=False, result sets are bounded, cancellation is checked between phases and no UI callback performs blocking I/O.
A new page or service is accepted only when construction and tab switching perform no substantial main-thread query, stale results are ignored, worker connections are thread-owned, writes are serial and committed before refresh, activity state is always cleared, shutdown remains idempotent, and navigation remains responsive during parallel backup load.