Gunnar

nordisk/korp-collectors

.nornir/collectors-daemon-design.md

<!-- nornir:class: design -->
# korp Collectors — the daemon architecture (design)

> **Collector vs Pipeline.** A **collector deals with OUTSIDE data** — it ingests an
> external source into korp. A korp **pipeline** is **in-house data movement +
> transformation** (SDP, Iceberg→FalkorDB). Collectors = ingress daemons; pipelines =
> internal transforms. This repo is collectors only.

A **collector** is a **continuous daemon** that *strives* to reach and hold **ONLINE**
for everything in its config, self-healing across restarts. It is a **state machine**,
not a one-shot job. `osm-cut` (planet → regions → the lettered a–g cut) is the first implementor; the
existing Iceberg collectors (DCAT-AP-SE, PxWeb, ENTSO-E, REST) are **redone** to this
design.

![Collectors daemon architecture](assets/collectors-daemon.svg)

## 1. The traits (object-safe, so new collectors just slot in)

```
trait CollectorConfig  // what you SET     → Settings tab
    fn enabled(&self) -> bool
    fn schema()  -> ConfigSchema           // fields, incl. multi-target (region pick-list)
    (serde in/out of korp's settings JSON: collectors.<name> = { enabled, …custom })

trait CollectorState   // what it's DOING  → infra status matrix + click-expand
    fn phase(&self) -> Phase               // the state-machine value (see §3)
    fn healthy(&self) -> bool              // green when Online+healthy
    fn facet(&self) -> &dyn Facet          // its OWN custom UI area (state_json + ui)

trait Collector : CollectorConfig + CollectorState   // the daemon
    fn run(&mut self, ctx: CollectorCtx) -> !          // the never-ending loop
    // ticks its state machine toward ONLINE for every configured target
```

`CollectorCtx` hands the collector its infra: **both** storage backends (§4), a **jera
daemon-job handle** (§5), the **normal log**, and the **BIOS bus** (§6).

## 2. Continuous daemon (not a one-shot)

The collector loop runs forever: reconcile config → do the next needed work → sleep/watch
→ repeat. For `osm-cut`: keep the planet fresh, keep every configured region cut into
`<work_dir>/<region>/`, re-cut when a region is added or the planet updates. "Idle & Online" is just the
steady state of the same loop.

## 3. State machine (pure facts — BIOS-style, never an "error" to be not-yet-online)

Per target **and** overall:

```
Offline → Downloading(planet, %) → Indexing(planet → region catalog)
        → Cutting(region, "<letter> <step>") → Online   (+ Degraded/Failed{reason} off any)
```

Being `Downloading` / `not online yet` is a **normal fact**, not a failure. Transitions
are appended to the BIOS bus (§6). The machine is **restart-safe**: on korp-server
restart it resumes from what's already on disk / in the bucket (batched files are reused,
not re-fetched).

## 4. Storage — every collector gets BOTH

Some data is servable (belongs in S3); some needs random access and is dumb to keep in S3
(the planet file, indexes). So `CollectorCtx` provides:

- **S3 bucket(s)** in the rustfs infra — servable artifacts. `osm-cut` uses none: its
  output is FILES under `<work_dir>/<region>/`, which is what korp-server reads.
- **A per-collector on-disk dir** under korp's user dir — `~/.korp/collectors/<name>/` —
  the planet `.pbf`, the region catalog, random-access caches, batched intermediates.

## 5. jera — a TRUE never-ending daemon job (jera gets extended)

Today jera models fire-and-complete jobs. Add a first-class **daemon** job kind:

- a job **expected to run indefinitely** — a long `Running` with a **phase** + **progress**
  + a **healthy** flag; **never counts as failed for not completing**;
- may cycle sub-phases (`Downloading → Online → Watching → …`);
- **restart-safe** — survives korp-server restarts, resumes its phase;
- stoppable/startable from the config toggle.

One daemon job **per enabled collector**. Its phase/progress is exactly what the State
trait surfaces to the matrix.

## 6. Two logs — the work log AND the BIOS bus

- **Normal log** (the jera job log): detailed, per-collector — "planet 42%", "cut sweden
  → step e networks produced, 1.2 GB", warnings, errors. What you read to debug one collector.
- **BIOS status bus**: a coarse, append-only, **pure-fact** stream the *server* aggregates
  at startup — like a hardware BIOS POST that **tunes in over time without blocking or
  invalidating startup**:
  ```
  collector map:    not online yet (downloading planet 12%)
  collector map:    indexing regions…
  collector energy: ONLINE
  collector map:    ONLINE
  ```
  Startup does **not** wait for collectors; the bus just reports facts as each tunes in.
  The infra matrix (§8) and a startup banner render this bus.

## 7. Region pre-list, sucked out of the planet (map collector)

After the torrent completes, **osm-katana enumerates the planet's regions/sections** (it's
fast, gatling-parallel) into a **cached region catalog** on disk. Then:

- Settings shows a **multi-select pick-list** of available regions;
- the collector answers **"what regions exist?"** from the cached catalog — **never
  re-scanning the planet per query**;
- before the planet exists the catalog is empty → Settings shows "planet not downloaded
  yet" (a fact, not an error). This is why the machine has an explicit `Indexing` phase.

## 8. Config (Settings) + Status (infra matrix) — the two faces

- **Settings ▸ Collectors**: per-collector **on/off** + custom config (region pick-list,
  the last step to run, the clip, cache caps). Driven by the **Config trait**.
- **Infra ▸ Collectors matrix**: one cell per collector — **green = Online+healthy**,
  **amber = working** (downloading/cutting), **grey = off**, **red = failed**. Each cell
  **click-expands** the collector's **State-trait Facet** (its own UI: phase, progress,
  the step letter it is on and a report per letter, last run). Driven by the **State
  trait** + the BIOS bus.

## 9. Torrent code moves INTO the collector

The librqbit planet-torrent fetch (currently `korp/src/download.rs`) **moves into the
`osm-cut` collector** — the collector owns its own acquisition. `download.rs` becomes a
thin re-export or is absorbed.

## 10. Bencher — prove the tiling uses ALL cores via gatling (NOT rayon)

Add a bencher (osm-katana), **heavy + non-heavy**, asserting the region cut (`convert`)
saturates all cores through **znippy-zoomies `gatling`** fork-join — the whole point of
osm-katana being rayon-free. Non-heavy = a small fixture (CI); heavy = a Sweden-scale cut.
Follows the bencher law (host = a new row, same-window comparisons only; "has a bencher"
is the gate, "fast enough" is not).

## 11. Adopt the existing collectors

DCAT-AP-SE / PxWeb / ENTSO-E / REST are **redone** as `Collector` daemons (Config + State
+ run-loop), sink to their Iceberg tables as before, and appear in the same matrix. The
pattern is generic; `osm-cut` (files on disk) and the Iceberg collectors (warehouse-sink)
differ only in `CollectorCtx` usage.

**WAVE 1 — DONE** (`src/iceberg_daemons.rs`, feature `daemon`): `osm-admin`, `dcat`,
`pxweb` adopted as the first three warehouse-sink daemons; `daemon_registry()` now lists
**4** (osm-cut + these three, under `osm-daemon`; the three alone under `daemon`). Two
new `CollectorCtx` seams make the pattern generic over both sink kinds:

- **`WarehouseSink`** — the Iceberg counterpart of `ObjectStore`: `append(table, batch)`.
  Since 2026-09-01 it is ALSO the backing of the one-shot path: `BatchSink::warehouse(sink,
  table)` (feature `daemon`) hands every `flush`ed batch to the host's `append` on the
  blocking pool and reports the host's count as `landed()`; `run_with_sink(collector,
  cfg, sink)` is the runner a host calls so EVERY `Collector` (osm, csv, fixedwidth,
  mainframe, trafikverket, gribnc, the REST presets) lands through the host's seam —
  in korp, `Streaming::land` on the configured backend row (`collect::RowSink`). `run(dir)`
  + `BatchSink::iceberg` (the leaf opening skade itself) stay for standalone consumers
  and the fixture tests. `znippy_pack` emits no `RecordBatch` (archive bytes) and does not
  go through it; `osm_cut` emits no MAP `RecordBatch` either — it writes FILES — but it
  does land one SUMMARY row per (region, step) into `osm_cut_steps` through this seam,
  so the cut shows up in the 🏬 Warehouse like every other collector. Gate:
  `sink::warehouse_backing_tests` (a swallowing sink reports `landed() == 0`, never the
  push tally).
  korp backs it with a skade `Table` (`BatchSink::iceberg` → `fast_append`); tests with
  `MemWarehouse`. This is the "sink to its Iceberg table via a warehouse seam" the adopt
  step needed (S3-sink daemons ignore it; warehouse-sink daemons ignore `bucket`).
- **`Fetcher`** — a `Fn(url) → bytes` **GET** closure PLUS a `Fn(url, body, content-type) →
  bytes` **POST** closure, so a source that answers a query POSTed in the request body
  (Trafikverket's XML `<REQUEST>` carrying its authentication key) rides the same seam as
  the plain-GET sources. The run-loop is proven headless with **no live gov API**: a test
  wires `Fetcher::from_map(SYNTHETIC_*)` — which serves BOTH verbs by URL, so a POST-body
  daemon is exercised offline against the same fixture — and korp wires the rustls HTTP
  core. A daemon selects its verb through the local `Source` enum (`Get` / `Post`); the one
  `reconcile_iceberg`/`daemon_run_loop` body handles both, so a POST source is not a second
  skeleton — only its request shape differs.
- **`SecretStore`** — where a daemon's **secret** (API key, token, password) comes from, so
  NO daemon calls `std::env::var` (or reads a config literal) directly and a vault can be
  swapped in later WITHOUT the leaf gaining a vault dependency. Object-safe:
  `fn secret(&self, name: &str) -> Option<SecretString>`, injected on `CollectorCtx.secret`.
  The value is a **`SecretString`** — a wrapper whose `Debug`/`Display` render a fixed
  `***redacted***`, whose bytes are zeroized on drop, and whose value is readable only at
  the moment of use via `expose()` (so a stray `{:?}` / log line / panic cannot leak it; the
  leaf needs no `zeroize` crate for this). The leaf ships **`EnvSecretStore`** (an inline
  literal checked first, then the process env — today's behavior, unchanged, and the default
  `CollectorCtx::in_memory` wires). `None` for a needed secret is a loud `Failed`, never a
  silent green: the daemon re-resolves EACH tick (a key absent now may be leased later) and
  never fetches without it. **`trafikverket-trainpos`** is the first consumer — its key
  resolves through the store (an explicit config `key` override wins, otherwise
  `store.secret(key_env)`), and the resolved key is exposed only into the POST body, never
  logged. *TODO(vault): the future host impl is a `VaultSecretStore` over **nornir-vault /
  OpenBao** (`vault.g11.se`, per-site zone) reading `secret/collectors/<name>/<key>` — it
  lives in korp (the HOST), injected via the ctx; this crate keeps only the trait + the env
  default. See the `// TODO(vault):` at `SecretStore` in `src/daemon.rs`.*

Each adopted daemon reuses its existing pure parser (`dcat::parse_dcat_rdfxml`,
`pxweb::parse_jsonstat2`, `osm_admin::extract_admin_units`, `rest::parse_records`, …) and
reaches ONLINE through the shared `Offline → Downloading → Indexing → Online` skeleton
(empty-is-RED → `Failed`).

**WAVE 2 — DONE** (`src/iceberg_daemons.rs`, feature `daemon`): the mechanical follow-on,
in batches, each a new `iceberg_daemon!(name, url, table, parse, schema)` line —
**batch 1**: sdmx · usgs · nordpool; **batch 2**: openaq · erddap · wfs; **batch 3**:
energy-charts · rss · entsoe{,-day-ahead,-generation}; **batch 4**: gtfs-rt-vehicles ·
gtfs-stops; **batch 5**: worldbank · gbif · eea-wise-water; **batch 6**: the Swedish
real-time public-sector trio — **`polisen-events`** and **`smhi-weather`** (plain-GET JSON
through `rest::parse_records` + the matching `RestCollector` preset field map) and
**`trafikverket-trainpos`** (the one POST-body daemon: it builds the XML `<REQUEST>` with
its key from config `key` / env `key_env` and POSTs it, then parses
`/RESPONSE/RESULT/0/TrainPosition` — hand-written over the SAME shared reconcile, only its
`Source` is a `Post`). `daemon_registry()` now lists **21** under `daemon` (+ `gtfs-stops`
under `gtfs`, + `osm-cut` under `osm-daemon`). Every registered daemon self-registers into
`collector-register.redb` (`register.rs`), so a `RegisterReader` in another process (korp's
Collectors panel, or `korp-collectors status`) sees each one's phase + facet — proven
RED→GREEN per daemon in `tests/collector_daemons_register.rs`. The remaining follow-on
(gribnc · rest · mainframe presets) is the same `iceberg_daemon!` line.

---

### Build order
1. **jera daemon-job kind** (§5) — foundation.
2. **Collector traits + CollectorCtx + registry** (§1, §4, §6).
3. **`osm-cut`** as the first daemon (§2, §3, §7, §9) + its **bencher** (§10).
4. **Settings + infra matrix** (§8).
5. **Adopt the existing collectors** (§11).

(HISTORICAL, 2026-08: the urgent map **un-blank** landed FIRST and independently. The
whole tiled route it fixed has since been deleted — korp map v2, `e66f846..1d53b1b` — and
the map is served from the packed cut this collector produces.)


## §14 — the REQUIREMENT declaration (lane CP, 2026-09-02)

A daemon **declares what it needs before it can reach ONLINE**, through the same
`crate::requires::Requirements` the one-shot ingest `Collector` trait answers with:

```rust
fn requirements(&self) -> crate::requires::Requirements;   // CollectorConfig, defaulted
```

The default reads the source out of the daemon's own `defaults()` (`url` / `endpoint` /
`source` / `base_url`) and declares no secret. **A daemon that reads
`CollectorCtx::secret` MUST override it and name the variable** — a daemon whose key is
absent can otherwise only report `Failed` at the wire, which is the state this declaration
exists to replace with `NeedsConfig`.

`SecretString` / `SecretStore` / `EnvSecretStore` MOVED to `crate::requires` (unchanged;
`daemon::` re-exports them, so every existing path still resolves). The seam had to be
readable without the `daemon` feature, because readiness answers for the ingest registry
too and a second copy behind a second gate would be two answers to "is this key set?".

`requires::SecretPresence` is the one-method question readiness asks; the blanket
`impl<T: SecretStore + ?Sized> SecretPresence for T` is the ONLY writer of the answer, so
a host that swaps in a vault store changes what the UI calls runnable without readiness
learning what a vault is.