# Typed Scheduled Actions and qBittorrent Polling Design

**Date:** 2026-07-14
**Status:** Proposed

## Overview

Manage currently collects qBittorrent speed samples as a side effect of a browser polling the widget-data endpoint. This design moves collection into a backend-owned typed scheduler so samples continue when no page is open, while keeping widget reads read-only.

The first scheduled action is qBittorrent speed polling. The scheduler is intentionally extensible but does not execute arbitrary widgets, SSH commands, or user-provided code.

## Goals

- Collect qBittorrent download/upload speed independently of browser presence.
- Configure polling per qBittorrent service instance.
- Preserve per-service SQLite isolation and existing cascade-delete behavior.
- Provide current status, stale-data state, run history, and a manual `Run now` action.
- Reuse the existing lifespan worker pattern and remain safe under the single-backend-worker deployment model.
- Expose metrics and structured logs without adding a new notification channel.

## Non-goals

- Distributed scheduling across replicas.
- External worker infrastructure or a task queue.
- Scheduling arbitrary saved SSH tasks.
- Moving every widget-backed integration to the scheduler in this release.
- A global scheduler administration page.

## Decisions

| Area | Decision |
| --- | --- |
| Architecture | Typed action registry with qBittorrent as the first action |
| Worker model | One lifespan-managed backend worker; deployment must run one scheduler-capable backend process |
| Activation | Enabled qBittorrent services poll by default; polling is opt-out |
| Defaults | 15-second interval; valid range 5–300 seconds |
| Samples | 30-minute default history; valid range 1–24 hours; configurable lower sample cap with a hard maximum of 1,200 rows |
| Timing | Immediate first run with per-service startup staggering; fixed delay after completion |
| Concurrency | No overlapping runs and no queued missed ticks per service |
| Failure | Keep enabled, record failure, retry with bounded exponential backoff |
| Manual run | Supported; successful manual run clears backoff |
| Widget data | Read-only; scheduler is the only qBittorrent sampler |
| Run history | Dedicated generic scheduled-action run records; retain 30 days or 1,000 runs per service/action |
| Service lifecycle | Disable pauses and retains history; delete purges service-owned data through cascade deletion |
| UI | Controls and status/history live with each qBittorrent service |
| Alerts | UI and metrics only in this release |

## Current and target flow

### Current

```text
React Query interval
  -> GET /api/widgets/instances/{id}/data
  -> QbittorrentWidgetSource.fetch()
  -> qBittorrent API
  -> append speed sample
  -> return chart data
```

### Target

```text
Backend lifespan
  -> TypedScheduler
  -> registered QbittorrentSpeedAction
  -> qBittorrent API
  -> QbittorrentSampleStore
  -> SchedulerRunStore

React Query / service UI
  -> scheduler status/history/sample endpoints
  -> read-only SQLite queries
```

## Configuration model

The existing qBittorrent service config gains validated non-secret fields:

```json
{
  "base_url": "https://qbit.example",
  "timeout_seconds": 60,
  "polling_enabled": true,
  "poll_interval_seconds": 15,
  "sample_retention_seconds": 1800,
  "sample_max_rows": 1200
}
```

Suggested validation:

- `polling_enabled`: boolean, default `true` for backward compatibility.
- `poll_interval_seconds`: integer from 5 through 300, default 15.
- `sample_retention_seconds`: integer from 60 through 86,400, default 1,800.
- `sample_max_rows`: integer from 60 through 1,200, default 1,200. The upper bound is a server safety limit, not merely a UI hint.
- Secrets remain exclusively in the existing encrypted secret fields.

The service type metadata must expose these fields so the existing schema-driven service editor renders them. Existing qBittorrent records receive defaults through normalization rather than a destructive migration.

## Scheduler architecture

### Registry and contracts

Add a small scheduler service with explicit action registration:

```python
class ScheduledAction(Protocol):
    action_key: str
    async def run(self, service: ServiceRecord, context: ActionContext) -> ActionResult: ...
```

The registry maps an action key to its implementation and metadata. The first key is `qbittorrent.speed_sample`. The scheduler never evaluates arbitrary config as executable code.

A scheduler cycle should:

1. Read enabled service records.
2. Select services whose typed action is enabled.
3. Reconcile changed interval/enabled settings.
4. Run due actions serially per service.
5. Persist a run record and update in-memory status.
6. Wait using a stop event so shutdown is responsive.

A thread-based coordinator is appropriate for the first release because `BackupAlertPoller` already establishes the project’s lifespan-managed worker pattern and qBittorrent’s client is blocking. The action may use the existing authenticated client cache, but the sampler should be extracted from the widget adapter so collection and presentation are not coupled.

### Timing and backoff

- First eligible service run starts immediately after startup, with a small deterministic stagger based on service ordering.
- Normal scheduling uses fixed delay: the next due time is calculated after the previous attempt completes.
- A per-service action lock prevents overlap.
- A failed run uses bounded exponential backoff, capped below the configured interval’s operational maximum. Backoff must not enqueue missed runs.
- A successful scheduled or manual run resets consecutive failures and clears `backoff_until`.
- Config changes are observed during the next reconciliation cycle; an interval change affects the next due calculation.
- Disabling a service prevents new work and allows the current run to finish before the action becomes idle.

### Single-worker constraint

The initial design assumes one backend process owns scheduler execution. Running multiple Uvicorn workers or replicas would duplicate polls and run records. Startup logs and operational documentation must make this constraint explicit. A future distributed lease can be added without changing the action contract.

## Storage

### Speed samples

Extend `QbittorrentSampleStore` to prune by both:

- `service_id` and `ts >= now - sample_retention_seconds`;
- most recent `sample_max_rows`, bounded by 1,200.

The existing `qbittorrent_speed_samples` table remains the source for chart data. Its API should accept a requested display window and return ordered samples. Deleting a service must continue to cascade into this concern.

### Scheduled-action runs

Add a generic scheduler storage concern, separate from `service_task_runs`, with fields equivalent to:

| Field | Description |
| --- | --- |
| `id` | Run identifier |
| `service_id` | Owning service instance |
| `action_key` | Registered action key, e.g. `qbittorrent.speed_sample` |
| `trigger` | `schedule` or `manual` |
| `started_at` / `finished_at` | Attempt timing |
| `status` | `running`, `success`, `failure`, `backoff`, or `cancelled` |
| `duration_ms` | Elapsed time |
| `attempt` | Retry/backoff attempt number |
| `error` | Secret-safe error text, truncated |
| `created_at` | Record creation time |

Indexes should cover `(service_id, action_key, started_at DESC)` and `(status, started_at DESC)`. Prune records older than 30 days and enforce a maximum of 1,000 records per service/action.

No credentials, request headers, or raw qBittorrent payloads may be stored in run history.

## Backend API

Add a dedicated scheduler router. Exact response models should be typed and should not expose secrets.

| Endpoint | Purpose |
| --- | --- |
| `GET /api/scheduler/services/{service_id}/status` | Current action state, last attempt/success, stale state, failures, backoff, and effective config |
| `GET /api/scheduler/services/{service_id}/runs` | Paginated run history with status/trigger filters |
| `POST /api/scheduler/services/{service_id}/run` | Run the registered qBittorrent action immediately; return a run/status response |
| `GET /api/scheduler/services/{service_id}/samples` | Read-only speed samples for a selected display window |

The existing `PUT /api/services/instances/{id}` remains the write path for schedule configuration. The widget-data endpoint must stop calling qBittorrent for the `speed` kind; it should read samples through the same store/query helper used by the scheduler API.

Manual runs must use the same action registry and persistence path as scheduled runs. A successful manual run clears backoff; a failed manual run records the failure and applies the same bounded retry state.

## Stale-data semantics

The status response should include `last_success_at`, `last_error`, `consecutive_failures`, `backoff_until`, and `is_stale`. Suggested initial stale rule:

```text
is_stale = no successful run
           OR now - last_success_at > max(2 * effective_interval, 60 seconds)
```

The speed widget should retain and render the last known samples with a warning containing the last-success time and current error. It should not replace useful history with an empty state solely because the latest poll failed.

## Frontend design

The existing schema-driven qBittorrent service editor should gain a scheduling section containing:

- polling enabled switch;
- interval field with bounds/error text;
- sample retention duration;
- maximum sample rows;
- effective next-run and last-success summary;
- `Run now` button;
- current failure/backoff message.

A qBittorrent service detail/editor surface should also contain:

- stale-data banner;
- user-selectable chart windows (for example 5m, 30m, 1h, all retained);
- speed chart sourced from read-only sample data;
- paginated run-history table with trigger, status, duration, timestamp, and safe error detail;
- loading, empty, disabled, and failed states.

Add typed API functions, React Query hooks, and types under the existing `frontend/src/api`, `frontend/src/hooks`, and `frontend/src/types` patterns. Poll status/history at a slower UI cadence than the sampler; the UI must not drive collection.

## Observability

Add secret-safe metrics using bounded labels:

- scheduled action runs total by action and status;
- scheduled action duration by action;
- last successful run timestamp by action/service;
- current consecutive failures or stale state by action/service.

Avoid labels containing URLs, usernames, API keys, raw errors, or unbounded widget IDs. Structured logs should include service ID, action key, trigger, status, duration, and request ID where available.

## Security and operational constraints

- Only registered action keys can execute.
- Service credentials are loaded through existing decryption helpers and are never returned or persisted in run records.
- Manual-run endpoints use existing JWT/API authentication.
- The scheduler must stop cleanly during lifespan shutdown and should not leave a new thread running after tests finish.
- The deployment documentation must state the one-worker scheduler constraint.

## Open implementation details

- Choose final module names and whether scheduler run storage belongs in a new service-data concern or a dedicated settings-store table.
- Define exact retry cap and jitter values.
- Decide whether the scheduler status endpoint returns one action or a list of registered actions.
- Finalize chart window/downsampling behavior for the 24-hour/1,200-row maximum.
