feat(assets): CI impact-graph traversal endpoint (PMS-475) #347

Merged
YousifShkara merged 1 commit from feat/PMS-475-ci-impact-traversal into main 2026-06-24 07:52:20 +02:00
Owner

PMS-456 phase 1 shipped the ITIL CI classification columns (asset_types.itil_category + assets.itil_lifecycle_stage) but the existing asset_relationships table sat unused beyond the manual list view. An operator asking "if I retire this database, what services break?" had to walk the table by hand. PMS-475 closes that with a server-side recursive traversal so the SPA's forthcoming CI Map tab has something to render.

What landed (server-side; the SPA force-directed graph is a separate ticket):

  • New endpoint GET /api/v1/assets/{id}/impact?direction=upstream|downstream|both&depth=N. The handler accepts direction (default both) and depth (default 10 = the hard server ceiling). The service clamps the effective walk depth to min(query_depth, tenant_setting, 10) so a caller cannot blow the query plan even when the tenant raised the cap to 99 by editing the row directly.

  • compute_impact_graph in assets::service runs two single-direction WITH RECURSIVE CTEs and unions the results in Rust when direction=both. Each CTE seeds from rows where the root sits on the relevant side of the edge, then recurses by joining the seed's other side to the next layer's matching side. The d.depth < $3 predicate bounds the walk; cycles terminate at the cap rather than looping forever (visited-set memoisation rejected as overkill for the modest CMDB sizes this surface targets).

  • Migration 074_ci_impact_max_depth.sql seeds the well-known settings key (category='ci', key='impact_max_depth') for every existing tenant with the default value of 5. The validator in settings::models accepts integers in 1..=10. The reader (read_ci_impact_max_depth) defaults to 5 when the row is absent (new tenants provisioned post-migration) and clamps to 1..=10 defensively so a row predating the validator cannot break the ceiling.

  • DTOs in assets::models: AssetImpactNode flattens edges from both halves into a uniform shape with direction discriminator + parent_asset_id + child_asset_id + relationship_type + depth, so the SPA renders nodes with one card template. AssetImpactResponse carries root_asset_id + the effective depth after clamping (the SPA can render "truncated at depth N" without re-doing the clamp math) + the requested direction.

  • Both CTEs join assets so the result rows include the friendly name for each neighbour. Tenant-scoped throughout: the CTE's seed-row and the join both predicate on tenant_id = $1, so a cross-tenant edge in the table (none exist, but defence in depth) cannot leak a name across the boundary.

Integration tests at tests/asset_ci_impact.rs (4 cases, all run live):

  • Linear chain A -> B -> C -> D walked downstream from A surfaces B/C/D at depths 1/2/3; walked upstream from D surfaces C/B/A at the same depths. Root never appears in its own impact set.
  • ci/impact_max_depth set to 2 truncates the chain at depth 2 (D not in the result), and the response's depth field reflects the effective cap.
  • A 2-node cycle (A <-> B) terminates at the cap and surfaces neighbours rather than hanging.
  • direction=both returns nodes stamped with their respective upstream / downstream discriminator.

Phase-3 follow-ups deferred to their own tickets:

  • Force-directed SPA visualisation on the asset detail page (client-side).
  • Visited-set memoisation if a tenant ever runs into a CMDB graph dense enough to make depth-10 traversals slow.

#PMS-475

PMS-456 phase 1 shipped the ITIL CI classification columns (`asset_types.itil_category` + `assets.itil_lifecycle_stage`) but the existing `asset_relationships` table sat unused beyond the manual list view. An operator asking "if I retire this database, what services break?" had to walk the table by hand. PMS-475 closes that with a server-side recursive traversal so the SPA's forthcoming CI Map tab has something to render. What landed (server-side; the SPA force-directed graph is a separate ticket): * New endpoint `GET /api/v1/assets/{id}/impact?direction=upstream|downstream|both&depth=N`. The handler accepts `direction` (default `both`) and `depth` (default 10 = the hard server ceiling). The service clamps the effective walk depth to `min(query_depth, tenant_setting, 10)` so a caller cannot blow the query plan even when the tenant raised the cap to 99 by editing the row directly. * `compute_impact_graph` in `assets::service` runs two single-direction `WITH RECURSIVE` CTEs and unions the results in Rust when `direction=both`. Each CTE seeds from rows where the root sits on the relevant side of the edge, then recurses by joining the seed's other side to the next layer's matching side. The `d.depth < $3` predicate bounds the walk; cycles terminate at the cap rather than looping forever (visited-set memoisation rejected as overkill for the modest CMDB sizes this surface targets). * Migration `074_ci_impact_max_depth.sql` seeds the well-known settings key `(category='ci', key='impact_max_depth')` for every existing tenant with the default value of 5. The validator in `settings::models` accepts integers in 1..=10. The reader (`read_ci_impact_max_depth`) defaults to 5 when the row is absent (new tenants provisioned post-migration) and clamps to 1..=10 defensively so a row predating the validator cannot break the ceiling. * DTOs in `assets::models`: `AssetImpactNode` flattens edges from both halves into a uniform shape with `direction` discriminator + `parent_asset_id` + `child_asset_id` + `relationship_type` + `depth`, so the SPA renders nodes with one card template. `AssetImpactResponse` carries `root_asset_id` + the effective `depth` after clamping (the SPA can render "truncated at depth N" without re-doing the clamp math) + the requested `direction`. * Both CTEs join `assets` so the result rows include the friendly `name` for each neighbour. Tenant-scoped throughout: the CTE's seed-row and the join both predicate on `tenant_id = $1`, so a cross-tenant edge in the table (none exist, but defence in depth) cannot leak a name across the boundary. Integration tests at `tests/asset_ci_impact.rs` (4 cases, all run live): * Linear chain A -> B -> C -> D walked downstream from A surfaces B/C/D at depths 1/2/3; walked upstream from D surfaces C/B/A at the same depths. Root never appears in its own impact set. * `ci/impact_max_depth` set to 2 truncates the chain at depth 2 (D not in the result), and the response's `depth` field reflects the effective cap. * A 2-node cycle (A <-> B) terminates at the cap and surfaces neighbours rather than hanging. * `direction=both` returns nodes stamped with their respective `upstream` / `downstream` discriminator. Phase-3 follow-ups deferred to their own tickets: * Force-directed SPA visualisation on the asset detail page (client-side). * Visited-set memoisation if a tenant ever runs into a CMDB graph dense enough to make depth-10 traversals slow. #PMS-475
YousifShkara force-pushed feat/PMS-475-ci-impact-traversal from cab31d5015
Some checks failed
E2E / Playwright against staging (pull_request) Successful in 1m4s
Check / fmt + clippy + build + tests (pull_request) Successful in 4m49s
Integration / integration tests (pull_request) Has been cancelled
to 5cc093e5bd
All checks were successful
E2E / Playwright against staging (pull_request) Successful in 2m1s
Check / fmt + clippy + build + tests (pull_request) Successful in 3m48s
Integration / integration tests (pull_request) Successful in 10m46s
Create release / Create release from merged PR (pull_request) Successful in 6s
2026-06-24 07:36:58 +02:00
Compare
YousifShkara deleted branch feat/PMS-475-ci-impact-traversal 2026-06-24 07:52:21 +02:00
Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
psa-systems/mokosh-server!347
No description provided.