---
module: reports.schedule
title: Scheduling & Distribution
tab: Architect
order: 30
audience: dev
---

How the **schedule + distribution** feature is built. Owner: **hiveops-reports**. Two collaborators at runtime: whichever data-source service the template targets (incident/journal/fleet) and **hiveops-messaging** for delivery. See [platform.service-ownership].

## Services involved (this feature)
| Service | Role in this feature |
|---|---|
| hiveops-reports | Owns templates, queries, runs, the cron scheduler, CSV export, and the distribution event. DB `hiveiq_reports`. |
| hiveops-incident | Data source for `INCIDENTS`, `ATM_HEALTH`, `FLEET_TASKS`, `AGENT_VERSIONS`, `AGENT_MODULES` (live HTTP at run time). |
| hiveops-journal | Data source for `TRANSACTIONS`. |
| hiveops-messaging | Consumes the completion event and fans out in-app DIRECT messages. |

## Data flow (executor → Kafka → delivery)
1. **Define** — user saves a template (`PUT/POST /api/reports/templates`) with `schedule_enabled`, a 6-field `schedule_cron`, and a scheduled saved query (`schedule_query_id`) for date range + ATMs.
2. **Poll** — `ReportSchedulerService.triggerDueReports()` `@Scheduled(fixedDelay=60_000)` queries `ReportTemplateRepository.findScheduledDue(now)`.
3. **Mint token** — `InternalTokenGenerator.generateForInstitution(template.institutionKey)`: HS-signed JWT via `Keys.hmacShaKeyFor(JWT_SECRET.getBytes(UTF_8))`, 300s TTL. Non-null institution → `role=MSP_ADMIN` + `institutionKey` claim; null → unscoped `role=ADMIN`. Same HMAC scheme used to validate downstream, so the token is accepted service-to-service.
4. **Execute** — `ReportRunService.triggerScheduledRun(template, token)` creates a `report_runs` row (PENDING) and runs async (`AsyncConfig` pool core=2/max=5). It fetches downstream via the fetcher for the data source, projects columns, applies groupBy/aggregations, writes CSV to `REPORT_UPLOAD_DIR`, and sets status COMPLETED (rowCount) or FAILED (error_message). User-triggered runs (`POST .../templates/{id}/run`) instead forward the caller's `Authorization` header.
5. **Advance** — scheduler recomputes `next_run_at` = `CronExpression.parse(cron).next(ZonedDateTime.now(UTC))`; a parse failure logs a warning and leaves `next_run_at = null`.
6. **Distribute** — on completion with configured recipients, `ReportDistributorService.distribute(...)` builds a `ReportCompletedEvent` and `ReportCompletedEventProducer.publish(...)` sends to Kafka topic **`hiveops.reports.completed`** (key = `institutionKey` or `"global"`, 3 partitions). One `report_distribution_log` row is written per recipient (SENT / FAILED). See [platform.kafka].
7. **Deliver** — hiveops-messaging `ReportCompletedConsumer` (`@KafkaListener` group **`hiveops-messaging-reports`**) deserializes the event and, per `recipientUserId`, builds a `MessageEvent{ type: "DIRECT", sourceService: "hiveops-reports" }` → persisted as an in-app message ("Report ready: …"). Delivery is decoupled: reports never calls messaging over HTTP for this.

## Kafka topics ([platform.kafka])
| Topic | Reports role | Consumer |
|---|---|---|
| `hiveops.reports.completed` | **Produces** (`ReportsKafkaConfig.TOPIC_REPORTS_COMPLETED`) | hiveops-messaging, group `hiveops-messaging-reports` |
| `hiveops.device.events` | **Consumes** (`DeviceEventConsumer`, group `hiveops-reports-device-sync`, offset `earliest`) | keeps `device_summary` mirror for the ATM picker |

## DB tables (`hiveiq_reports`) — read/written by this feature ([platform.data-architecture])
| Table | Written by | Notes |
|---|---|---|
| `report_templates` | template CRUD | `schedule_enabled`, `schedule_cron`, `next_run_at`, `schedule_query_id`, `institution_key` (V7). Partial index `idx_report_templates_scheduled` (V1). |
| `report_queries` | query CRUD (V3) | saved date-range + ATM filters; one is flagged scheduled via `report_templates.schedule_query_id`. |
| `report_runs` | run executor | PENDING → RUNNING → COMPLETED/FAILED, `row_count`, `triggered_by`, `triggered_by_email`, CSV path. |
| `report_distribution_log` | distributor | per-recipient SENT/FAILED at publish time. |
| `device_summary` | Kafka device consumer (V8/V10) | mirror only; no reconciler. |

## Key endpoints (schedule surface)
- `POST/PUT /api/reports/templates[/{id}]` — set `scheduleEnabled` + `scheduleCron`.
- `GET /api/reports/templates/{templateId}/queries`, `POST .../queries`, `POST .../queries/{queryId}/set-scheduled`, `DELETE .../queries/scheduled` — manage the scheduled query.
- `POST /api/reports/templates/{id}/run` — manual async run (202).
- `POST /api/reports/runs/{id}/distribute` — re-emit the completion event.

## Design notes
- **Cron is Spring 6-field, UTC.** Frontend `parseCron` expects exactly 6 space-separated fields; the backend evaluates in `ZoneOffset.UTC`. No per-user timezone.
- **Poll model, not per-job cron.** One `fixedDelay=60s` sweep drives all templates; no `@Scheduled(cron=...)` per template, no quartz. Resolution is coarse (≈1 min).
- **No distributed lock** on the sweep — safe only while reports runs single-instance (relevant to the horizontal-scaling roadmap).
