Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/korp-collectors

tests/daemon_names.rs

//! **The daemon roster and the collector registry must agree on every name.**
//!
//! korp has TWO rosters. `korp_collectors::registry()` yields `Collector`s — the
//! things that `collect()` a `Bounds` into a `BatchSink` and declare a `Freshness`.
//! `daemon::daemon_registry()` yields `daemon::Collector`s — the things that own a
//! never-ending `run()` loop. Different traits, different jobs, and a source that has
//! both (`polisen-events` has a daemon AND a REST preset) is ONE source seen from two
//! sides.
//!
//! The name is the only thing tying the two halves together. It is the Settings key,
//! the registry key and the matrix cell, so when the two rosters spell a source
//! differently nothing errors — the daemon simply stops having a twin, and every
//! feature reaching across the seam (freshness, read marks, business keys, the graph
//! mapping) quietly skips it. That is what makes drift here expensive: invisible at
//! compile time, and it degrades one collector at a time.
//!
//! MEASURED 2026-09-03, before the fix — 21 daemons against 46 collectors: 16 exact
//! twins, 0 separator variants, and **5 daemons whose name is a PREFIX of the twin's**
//! (`dcat` vs `dcat-ap-se`, `pxweb` vs `pxweb-jsonstat2`, `gbif` vs `gbif-occurrences`,
//! `worldbank` vs `worldbank-indicators` AND `worldbank-sdmx`, `osm-admin`). The
//! daemons carry the short source name, the registry the qualified one. `worldbank` is
//! the case that shows why it has to be fixed rather than papered over with a fuzzy
//! lookup: the short name matches TWO different collectors reading two different APIs,
//! so there is no answer to "which twin" that a lookup could guess.

#![cfg(feature = "daemon")]

use korp_collectors::{daemon, registry};

/// The canonical form for comparing two spellings of one source: case-folded, every
/// separator dropped. `entsoe_day_ahead` and `entsoe-day-ahead` fold together.
fn fold(name: &str) -> String {
    name.chars()
        .filter(|c| c.is_ascii_alphanumeric())
        .map(|c| c.to_ascii_lowercase())
        .collect()
}

/// Is `short` the qualified name `long` with its qualifier cut off? Only at a
/// SEPARATOR boundary, so `gbif` matches `gbif-occurrences` but `entso` does not
/// match `entsoe`.
fn is_qualified_form_of(long: &str, short: &str) -> bool {
    long.len() > short.len()
        && long.starts_with(short)
        && matches!(long.as_bytes()[short.len()], b'-' | b'_')
}

// Gated on `osm` because the census is only as wide as the roster it can SEE. It ran
// green under `--features daemon` over a repo with FIVE drifted names: the fifth twin
// (`osm-admin-boundaries`) is behind that feature and was not compiled, so `osm-admin`
// read as a daemon with no twin rather than one whose twin it cannot name. A
// feature-gated collector is exactly where a name census goes blind. Declaring the
// feature set it needs is honest; running anyway and reporting an all-clear is not, and
// so is leaving it permanently red in the narrower build, which is how a guard becomes
// something people learn to skip past.
#[cfg(feature = "osm")]
#[test]
fn a_daemon_and_its_registry_twin_spell_the_source_the_same_way() {
    let daemons: Vec<String> = daemon::daemon_registry()
        .iter()
        .map(|c| c.name().to_string())
        .collect();
    let collectors: Vec<String> = registry().iter().map(|c| c.meta().name.clone()).collect();

    // Non-vacuity FIRST: this test is worthless if either roster came back empty, and
    // an empty roster is exactly what a mis-set feature flag produces.
    assert!(
        daemons.len() >= 20,
        "the daemon roster came back with {} entries — the census is reading nothing",
        daemons.len(),
    );
    assert!(
        collectors.len() >= 40,
        "the collector registry came back with {} entries",
        collectors.len(),
    );

    let mut drift: Vec<String> = Vec::new();
    for d in &daemons {
        if collectors.contains(d) {
            continue; // an exact twin — the two halves agree
        }
        // No exact twin. A name that differs only by SEPARATOR is drift…
        if let Some(c) = collectors.iter().find(|c| fold(c) == fold(d)) {
            drift.push(format!("daemon `{d}` vs registry `{c}` (separator)"));
            continue;
        }
        // …and so is a name that is the twin's, with the qualifier cut off. Every
        // candidate is listed, because the ambiguous ones are the point.
        let quals: Vec<&String> = collectors
            .iter()
            .filter(|c| is_qualified_form_of(c, d))
            .collect();
        if !quals.is_empty() {
            drift.push(format!("daemon `{d}` vs registry {quals:?} (unqualified)"));
        }
        // A daemon with no candidate at all is NOT drift: most daemons are the only
        // implementation of their source, which is a fine thing to be.
    }

    assert!(
        drift.is_empty(),
        "{} daemon name(s) do not match their registry twin — the twin is unreachable \
         by name, so freshness, read marks and business keys all skip it:\n  {}",
        drift.len(),
        drift.join("\n  "),
    );
}


/// The keys this repo is known to have persisted under, named HERE rather than read
/// from `RENAMED_DAEMONS`. That duplication is deliberate and it is the only reason
/// this test can fail: a guard that loops over the rename table cannot notice a
/// MISSING entry, because deleting the row deletes the assertion with it. Measured —
/// the first version of this test did exactly that, and dropping the `pxweb` row left
/// it green.
///
/// These four are history, not policy. Nothing should ever be removed from this list;
/// a fifth rename adds a fifth line.
const KEYS_ALREADY_IN_CONFIG_FILES: &[&str] =
    &["dcat", "pxweb", "worldbank", "gbif", "osm-admin"];

/// **A rename is a migration.** Every collector name is a key in somebody's
/// `config.json`, so a renamed daemon must still answer to what it was called —
/// otherwise an operator's enabled collector reads as "not a registered daemon" on the
/// next start and switches itself off without saying anything.
#[test]
fn a_renamed_daemon_still_answers_to_its_persisted_key() {
    for old in KEYS_ALREADY_IN_CONFIG_FILES {
        let new = daemon::migrated_name(old);
        assert_ne!(
            new, *old,
            "`{old}` is a key already written into config files and it migrates to \
             nothing — add it to `daemon::RENAMED_DAEMONS`",
        );
        let found = daemon::daemon_by_name(old).unwrap_or_else(|| {
            panic!("`{old}` (a key already in config files) resolves to no daemon")
        });
        assert_eq!(found.name(), new, "`{old}` must land on the renamed daemon");

        // And the new name is one the collector registry actually knows — the whole
        // point of the rename. A typo in the table would migrate a live key onto a name
        // nothing answers to, which is strictly worse than the drift it replaced.
        //
        // Skipped for a twin this build did not COMPILE: `osm-admin-boundaries` is
        // behind the `osm` feature, and "the registry does not carry it" then says
        // something about the feature set, not about the rename. The migration itself
        // is still asserted above in every build, which is the part that must never
        // depend on a feature — a persisted key has to resolve wherever it is read.
        if !registry().iter().any(|c| c.meta().name == new) {
            assert!(
                !cfg!(feature = "osm") && new.starts_with("osm-"),
                "`{old}` migrates to `{new}`, which the collector registry does not know",
            );
        }
    }
}

/// The table's own entries must each be honest, too: this catches a rename ADDED
/// wrongly, where the one above catches a rename FORGOTTEN. Both directions, because
/// they fail differently.
#[test]
fn every_rename_in_the_table_points_at_a_daemon_that_exists() {
    assert!(
        daemon::RENAMED_DAEMONS.len() >= KEYS_ALREADY_IN_CONFIG_FILES.len(),
        "the rename table has shrunk below the keys known to be in config files",
    );
    for (old, new) in daemon::RENAMED_DAEMONS {
        assert!(
            daemon::daemon_registry().iter().any(|c| c.name() == *new),
            "`{old}` migrates to `{new}`, which is not a registered daemon",
        );
    }
}