Skip to content

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:

  1. The Qt main thread constructs and changes widgets/models only.
  2. Database, file, network and CPU work runs in workers or dedicated services.
  3. 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.