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.
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-cutuses 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
Runningwith 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
Indexingphase.
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 inCollectorCtxusage.
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 ofObjectStore:append(table, batch). Since 2026-09-01 it is ALSO the backing of the one-shot path:BatchSink::warehouse(sink, table)(featuredaemon) hands everyflushed batch to the host'sappendon the blocking pool and reports the host's count aslanded();run_with_sink(collector, cfg, sink)is the runner a host calls so EVERYCollector(osm, csv, fixedwidth, mainframe, trafikverket, gribnc, the REST presets) lands through the host's seam — in korp,Streaming::landon the configured backend row (collect::RowSink).run(dir)BatchSink::iceberg(the leaf opening skade itself) stay for standalone consumers and the fixture tests.znippy_packemits noRecordBatch(archive bytes) and does not go through it;osm_cutemits no MAPRecordBatcheither — it writes FILES — but it does land one SUMMARY row per (region, step) intoosm_cut_stepsthrough this seam, so the cut shows up in the 🏬 Warehouse like every other collector. Gate:sink::warehouse_backing_tests(a swallowing sink reportslanded() == 0, never the push tally). korp backs it with a skadeTable(BatchSink::iceberg→fast_append); tests withMemWarehouse. This is the "sink to its Iceberg table via a warehouse seam" the adopt step needed (S3-sink daemons ignore it; warehouse-sink daemons ignorebucket).
-
Fetcher— aFn(url) → bytesGET closure PLUS aFn(url, body, content-type) → bytesPOST 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 wiresFetcher::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 localSourceenum (Get/Post); the onereconcile_iceberg/daemon_run_loopbody 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 callsstd::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 onCollectorCtx.secret. The value is aSecretString— a wrapper whoseDebug/Displayrender a fixed***redacted***, whose bytes are zeroized on drop, and whose value is readable only at the moment of use viaexpose()(so a stray{:?}/ log line / panic cannot leak it; the leaf needs nozeroizecrate for this). The leaf shipsEnvSecretStore(an inline literal checked first, then the process env — today's behavior, unchanged, and the defaultCollectorCtx::in_memorywires).Nonefor a needed secret is a loudFailed, never a silent green: the daemon re-resolves EACH tick (a key absent now may be leased later) and never fetches without it.trafikverket-trainposis the first consumer — its key resolves through the store (an explicit configkeyoverride wins, otherwisestore.secret(key_env)), and the resolved key is exposed only into the POST body, never logged. TODO(vault): the future host impl is aVaultSecretStoreover nornir-vault / OpenBao (vault.g11.se, per-site zone) readingsecret/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):atSecretStoreinsrc/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
- jera daemon-job kind (§5) — foundation.
- Collector traits + CollectorCtx + registry (§1, §4, §6).
osm-cutas the first daemon (§2, §3, §7, §9) + its bencher (§10).- Settings + infra matrix (§8).
- 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:
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.