---
module: reports.schedule
title: Scheduling & Distribution
tab: Internal
order: 20
audience: internal
---

Ops/support view of the report **scheduler + distribution** path. Owner service: **hiveops-reports** (DB `hiveiq_reports`, internal `8088` / OpenMetal external `8011`, NGINX `api.bcos.cloud/reports/`). Delivery hops through **hiveops-messaging** via Kafka.

## Roles & access (read this first)
- There is **no `MSP_ADMIN`/`BCOS_ADMIN` gate on scheduling**. `SecurityConfig` is `.anyRequest().authenticated()` — every `/api/reports/**` endpoint just needs a valid JWT. Templates, queries, and runs are scoped **per-user** by `principal.userId()` (service-layer `...ForUser(id, userId)` checks), not by role.
- The **only** method-level `@PreAuthorize` in the service is on the device picker: `GET /api/devices/atms` → `hasAnyRole('USER','CUSTOMER','ADMIN','MSP_ADMIN','BCOS_ADMIN')`. A user with none of those roles can't populate the ATM list when building a schedule's saved query, but can still schedule.
- Scheduled runs execute under a **self-minted service JWT** (see below), not the owner's live session — so a scheduled report keeps running after the owner logs out.

## How a scheduled run fires
1. `ReportSchedulerService.triggerDueReports()` runs `@Scheduled(fixedDelay = 60_000)` — one poll per instance per ~60s.
2. It calls `templateRepository.findScheduledDue(Instant.now())` (index `idx_report_templates_scheduled` on `schedule_enabled=true, next_run_at`).
3. For each due template: mint token → `runService.triggerScheduledRun(template, serviceToken)` → recompute `next_run_at` via Spring `CronExpression.next(...)` in **UTC**.
4. Run executes async, fetches from downstream, writes CSV, sets status, then (if recipients configured) distributes.

## First things to check when it misbehaves

**"Scheduled report never runs"**
- `report_templates.schedule_enabled = true` AND `schedule_cron` is a valid **6-field** Spring cron. `computeNextRun` catches a bad cron and sets `next_run_at = NULL` → the template is then **never** due. Symptom: schedule badge shows but Next Run is blank.
- `next_run_at` in the future / far future? Check the row. Cron is evaluated in **UTC** — a customer expecting "8am local" will see it fire at 8am UTC.
- A scheduled report needs a **scheduled saved query** (`report_templates.schedule_query_id` → `report_queries.id`) for its date range/ATMs. Set via `POST .../queries/{queryId}/set-scheduled`.
- Only **one instance** should own the 60s poll. `findScheduledDue` + save is `@Transactional` but there's no distributed lock — if reports is ever scaled to >1 replica, expect duplicate runs.

**"Run fires but FAILS"**
- Reports **fetches, doesn't store** fleet data. A run makes live paginated HTTP calls to incident/journal/fleet; a downstream outage surfaces as a **FAILED** `report_runs` row with the error, not empty data. Check `report_runs.error_message` / logs.
- Scheduled runs use the internal token. If `JWT_SECRET` drifts between reports and the downstream service, downstream returns 401/403 → run FAILS. Confirm `JWT_SECRET` is identical everywhere.

**"Run COMPLETED but no message delivered"**
- Distribution is Kafka, not a direct HTTP call. reports publishes `hiveops.reports.completed`; **hiveops-messaging** consumes it (group `hiveops-messaging-reports`) and creates one **DIRECT** in-app message per recipient. If Kafka is down or messaging's consumer is stopped, the report is done but silent.
- No recipients configured (`distributionConfig.recipientUserIds` empty) = nothing is published (`distribute` returns early). Expected, not a bug.
- Check `report_distribution_log`: one row per recipient, `status = SENT` (event published) or `FAILED` (publish threw). SENT only means reports handed it to Kafka — delivery still depends on messaging.

**"Device/ATM list wrong in the schedule's query"**
- `GET /api/devices/atms` reads the local `device_summary` mirror, fed only by Kafka `hiveops.device.events` (consumer group `hiveops-reports-device-sync`). **No reconciler** — if that topic stops, ATM options go stale. Cross-check against hiveops-devices.

## Manual levers
- Re-run now (bypass schedule): `POST /api/reports/templates/{id}/run` (202 + run id) — forwards the caller's JWT.
- Re-send a completed run: `POST /api/reports/runs/{id}/distribute` with `{"recipientUserIds":[...]}` — 409 if the run isn't COMPLETED.
- CSVs live under `REPORT_UPLOAD_DIR` (default `/app/uploads/reports`, bind-mounted `/data/hiveiq/reports/` in OpenMetal) — **not** in the DB. Missing file = broken download even for a COMPLETED run.

See [technical.reports] for the full service map.
