---
module: reports.runs
title: Run History
tab: Architect
order: 30
audience: dev
---

How **Run History** is built. It is a thin CRUD/list surface over the `report_runs` table, backed by an **async executor → live downstream fetch → store → Kafka distribute** pipeline. hiveops-reports owns the run lifecycle end to end; it holds no fleet data of its own. See [platform.service-ownership].

## Services involved

| Service | Role for this feature |
|---|---|
| **hiveops-reports** | Owns run records, executor, CSV export, and the runs API. |
| hiveops-incident | Data source for `INCIDENTS`, `ATM_HEALTH`, `FLEET_TASKS`, `AGENT_VERSIONS`, `AGENT_MODULES` (paginated GETs at run time). |
| hiveops-journal | Data source for `TRANSACTIONS`. |
| hiveops-messaging | Consumes the completion Kafka event and produces the in-app message to recipients (distribution). |
| hiveops-devices | Upstream of `hiveops.device.events`, which keeps the reports-local `device_summary` mirror fresh (used by other data sources, not the runs list itself). |

## Run lifecycle (executor → store → Kafka)

1. A run is created `PENDING` in `report_runs`, either by `ReportRunService.triggerUserRun` (from `POST /api/reports/templates/{id}/run`) or `triggerScheduledRun` (from `ReportSchedulerService`, `@Scheduled(fixedDelay=60s)`).
2. `executeAsync(runId, bearerToken)` runs on the `reportExecutor` pool (`AsyncConfig`, core=2/max=5). Status → `RUNNING`, `started_at` set.
3. The matching `ReportDataFetcher` for `template.dataSource` makes **live paginated HTTP calls** to the downstream service, forwarding a bearer token:
   - **User run:** the caller's own `Authorization` header.
   - **Scheduled run:** a self-minted 5-min ADMIN JWT from `InternalTokenGenerator`, HMAC-signed with `JWT_SECRET` (`Keys.hmacShaKeyFor(secret.getBytes(UTF_8))`).
4. `ReportExportService` projects columns (or `groupAndAggregate` when `groupByFields` is set). First 100 rows are stored to `report_runs.result_data` (jsonb) for preview **regardless of output format**.
5. If `outputFormat = CSV`, a CSV is written to `REPORT_UPLOAD_DIR/{uuid}.csv` and the path saved to `report_runs.csv_path`. CSV bytes never enter the DB.
6. Status → `COMPLETED`, `row_count` + `completed_at` set. On any exception the whole thing is caught: status → `FAILED`, `error_message = e.getMessage()`.
7. `distributeIfConfigured` runs post-completion when the template has `distributionConfig.recipientUserIds`.

## Distribution data flow (Kafka)

`ReportDistributorService.distribute` does **not** call messaging over HTTP — it publishes an event and lets messaging react. See [platform.kafka].

```
run COMPLETED
  → ReportDistributorService.distribute(run, recipientUserIds, token)
      → ReportCompletedEventProducer.publish(ReportCompletedEvent)
          → kafka topic  hiveops.reports.completed   (3 partitions, key = institutionKey | "global")
      → one report_distribution_log row per recipient (channel=IN_APP, status=SENT|FAILED)
  hiveops-messaging consumes hiveops.reports.completed → produces the in-app message
```

`ReportCompletedEvent` payload: run uuid, template name, row count, recipient user ids, institutionKey, triggered-by email, completedAt, source `"hiveops-reports"`. If publish throws, the run stays COMPLETED and the distribution_log rows are marked `FAILED` with the error — the run is never rolled back on a distribution failure.

> Note: `hiveops-reports/CLAUDE.md` still describes distribution as a direct POST to `hiveops-messaging /api/v1/messages`. The current code is Kafka-only via `ReportCompletedEventProducer`; the CLAUDE.md is stale on this point.

## DB tables (in `hiveiq_reports`)

| Table | Read/Write for this feature |
|---|---|
| `report_runs` | Primary. Written by executor (status/rowCount/resultData/csvPath/errorMessage), read by the list/preview/download/get endpoints. Columns: `uuid`, `status`, `triggered_by`, `triggered_by_user_id`, `triggered_by_email`, `query_id`, `filter_overrides` (jsonb), `row_count`, `result_data` (jsonb), `csv_path`, `error_message`, `started_at`, `completed_at`, `created_at`. |
| `report_templates` | Read (FK from run) for name, columns, filters, output format, distribution config. |
| `report_distribution_log` | Written per recipient during distribution. |
| `device_summary` | Kafka-fed mirror (V8) — feeds other data-source fetchers, not the runs list. |

See [platform.data-architecture]. DB connection uses split `DB_HOST`/`DB_PORT`/`DB_NAME`/`DB_USERNAME`/`DB_PASSWORD` (not `DB_URL`). H2 in-memory under the `dev` profile.

## Key API endpoints (`ReportRunController`, base `/api/reports/runs`)

| Method | Path | Notes |
|---|---|---|
| GET | `/api/reports/runs` | Pageable; `findByTriggeredByUserIdOrderByCreatedAtDesc` — owner-scoped. |
| GET | `/api/reports/runs/{id}` | Owner-scoped; `@Cacheable("reportRuns")` for COMPLETED/FAILED. |
| GET | `/api/reports/runs/{id}/preview` | COMPLETED only (else 409); returns ≤100 rows from `result_data`. |
| GET | `/api/reports/runs/{id}/download` | CSV stream; COMPLETED + `csv_path` on disk required. |
| DELETE | `/api/reports/runs/{id}` | Deletes CSV file then row. |
| POST | `/api/reports/runs/{id}/distribute` | Manual distribution; body `recipientUserIds`. |

## Design characteristics

- **Ownership = authorization.** No method-level role checks on runs; the repository's `triggeredByUserId` predicate is the only boundary. Institution isn't used for run access.
- **Fetch-at-run, not store.** No fleet data is persisted beyond the projected result snapshot — resilience and staleness both derive from the downstream services.
- **Executor is the bottleneck.** Small pool (max 5) plus synchronous downstream HTTP means throughput is downstream-latency-bound.
- **Distribution is fire-and-forget** across a Kafka boundary; run success and delivery success are independent.

Cross-links: [platform.data-architecture] · [platform.kafka] · [platform.service-ownership]
