Two things the entity window redesign surfaced but deliberately left alone. **blast_radius answered the wrong question.** It walked source→target for every relationship type, but which end of an edge is the dependent differs per type: "machine hosts container" means the target breaks, while "service depends-on service" and "ingress routes-to service" mean the SOURCE breaks. Walking everything forwards was right for hosts/provides and backwards for everything else — and swept in 2,800+ documents/involves/targets edges of pure bookkeeping, so the result contained tasks and executions that cannot break. Direction is now declared per relationship type in seeds/ontology.yaml (blast_direction: forward | backward | none), the same shape as the entity types' monitoring: declaration, and defaults to none so an undeclared edge contributes nothing rather than a confidently wrong answer. It also needed a modelling fix: `routes-to` names an ingress's BACKEND, so nothing recorded that all 21 public hostnames are terminated by caddy. A `served-by` edge type now says so. pool:ludo-lvm 2 -> 23 (every container storing on it, then their services) lxc:caddy 4 -> 22 (service:caddy, then all 21 ingress routes) service:authentik 7 (what authenticates via it) **Every ping check was reporting down.** Not a host:strong false positive: all seven, including ws:mac-mini — the Docker host itself. The scheduler runs in Docker on macOS, whose VM does not route ICMP to the LAN; loopback pings succeed and every LAN ping fails. Under health aggregation each broken probe dragged its entity to down. The question the check exists to answer is "is it reachable", and ICMP is only one way to ask it. checkPing now falls back to a TCP connect before concluding anything, which restores an honest verdict for the four hosts that are genuinely up while leaving the genuinely unreachable ones down. TestBlastRadiusTerminatesOnCycles asserted the old direction (caddy=1, authentik=2 — the cycle walked the wrong way); it now asserts the corrected depths, and its exact-node-count check is relaxed because walking the right way also surfaces the seed's own real dependents, which are correct answers. Co-Authored-By: Claude <noreply@anthropic.com>
76 lines
3.7 KiB
PL/PgSQL
76 lines
3.7 KiB
PL/PgSQL
-- 028_relationship_blast_direction.up.sql
|
|
-- Make blast_radius answer the question it is named after.
|
|
--
|
|
-- blast_radius walked source_id -> target_id for every relationship type. But
|
|
-- which end of an edge is the DEPENDENT differs per type:
|
|
--
|
|
-- machine --hosts--> container if the machine dies, the container dies
|
|
-- -> dependent is the TARGET (forward)
|
|
-- service --depends-on--> service if the target dies, the SOURCE breaks
|
|
-- -> dependent is the SOURCE (backward)
|
|
-- ingress --routes-to--> service if the service dies, the route 502s
|
|
-- -> dependent is the SOURCE (backward)
|
|
-- document --documents--> entity neither breaks the other
|
|
-- -> no runtime dependency at all
|
|
--
|
|
-- Walking everything forwards meant the answer was right for `hosts` and
|
|
-- `provides` and wrong for every backward edge, while `documents`, `involves`
|
|
-- and `targets` (2,800+ edges of pure bookkeeping) polluted the result with
|
|
-- tasks and executions that cannot "break".
|
|
--
|
|
-- Direction is therefore a property of the relationship type, declared in
|
|
-- seeds/ontology.yaml — the same shape as the `monitoring:` declaration on
|
|
-- entity types.
|
|
--
|
|
-- forward : if the SOURCE fails, the TARGET is affected
|
|
-- backward : if the TARGET fails, the SOURCE is affected
|
|
-- none : no runtime dependency (default — bookkeeping and documentation)
|
|
--
|
|
-- Defaulting to 'none' is deliberate: an undeclared edge contributes nothing
|
|
-- rather than silently producing a wrong answer, which is how the old
|
|
-- everything-is-forward behaviour went unnoticed.
|
|
|
|
ALTER TABLE relationship_types
|
|
ADD COLUMN IF NOT EXISTS blast_direction TEXT NOT NULL DEFAULT 'none'
|
|
CHECK (blast_direction IN ('forward', 'backward', 'none'));
|
|
|
|
COMMENT ON COLUMN relationship_types.blast_direction IS
|
|
'Which end of this edge depends on the other. forward = target depends on source. backward = source depends on target. none = no runtime dependency. Drives blast_radius().';
|
|
|
|
-- Walk the dependency graph in the direction each edge type declares.
|
|
--
|
|
-- Returns everything that is affected when start_id fails, with the number of
|
|
-- hops. Cycles are guarded by the path array, as before.
|
|
CREATE OR REPLACE FUNCTION blast_radius(start_id UUID, max_depth INT DEFAULT 3,
|
|
rel_types TEXT[] DEFAULT NULL)
|
|
RETURNS TABLE(entity_id UUID, depth INT) AS $$
|
|
WITH RECURSIVE walk AS (
|
|
SELECT start_id AS entity_id, 0 AS depth, ARRAY[start_id] AS path
|
|
UNION ALL
|
|
SELECT next_id, w.depth + 1, w.path || next_id
|
|
FROM walk w
|
|
JOIN LATERAL (
|
|
-- forward: this entity is the source, so the target depends on it
|
|
SELECT r.target_id AS next_id
|
|
FROM relationships r
|
|
JOIN relationship_types rt ON rt.name = r.type
|
|
WHERE r.source_id = w.entity_id
|
|
AND r.valid_to IS NULL
|
|
AND rt.blast_direction = 'forward'
|
|
AND (rel_types IS NULL OR r.type = ANY(rel_types))
|
|
UNION ALL
|
|
-- backward: this entity is the target, so the source depends on it
|
|
SELECT r.source_id AS next_id
|
|
FROM relationships r
|
|
JOIN relationship_types rt ON rt.name = r.type
|
|
WHERE r.target_id = w.entity_id
|
|
AND r.valid_to IS NULL
|
|
AND rt.blast_direction = 'backward'
|
|
AND (rel_types IS NULL OR r.type = ANY(rel_types))
|
|
) nxt ON TRUE
|
|
WHERE w.depth < LEAST(max_depth, 5)
|
|
AND NOT nxt.next_id = ANY(w.path)
|
|
)
|
|
SELECT entity_id, MIN(depth) FROM walk GROUP BY entity_id;
|
|
$$ LANGUAGE sql STABLE;
|