feat(graphql): InfrahubRepositoryBranchStatus per-branch attribute read from the graph (IFC-3127)

Merged
OG
2mo ago
+2390-461

Closes IFC-3127. PR 3 of 5 under
IFC-3104. Base: cross-branch-repo-status-infp-671.

Replaces the fabricated attribute values from increment A with a real per-branch read through a new
core primitive, makes the three value filters real, and deletes the stub. The GraphQL contract
does not change
beyond dropping the "preview" suffix from the field description.

What landed

  • RepositoryBranchAttributesQuery in backend/infrahub/core/query/repository.py: one statement
    resolving N branches x M attributes for one or more repositories, with the per-branch visibility
    predicate and the standard election order.
  • RepositoryBranchAttributesReader in backend/infrahub/core/repository_branch_status/reader.py,
    behind the existing RepositoryBranchAttributesSource protocol.
  • Resolver swapped from stub.py (deleted) to the reader; apply_value_filters wired in so
    sync_status__value, internal_status__value and own_values_only narrow rows and count
    server-side.
  • A Branch(name) RANGE index (see Performance).
  • at is now rejected on this field (see Behaviour notes).
  • 119 tests, a query benchmark, two dev/knowledge pages, a docs section, two changelog fragments.

Sign-off requested

  • Database: one new index declaration. node_indexes is declarative and applied by
    index_manager.init at startup and by infrahub db index, so no numbered migration and existing
    deployments self-heal on restart.
  • GraphQL: no SDL change other than the field description. schema/schema.graphql and the
    frontend generated types are regenerated and committed.

Performance: the Branch(name) index

The statement opens UNWIND $branch_names AS branch_name MATCH (br:Branch {name: branch_name}).
There was no Branch entry in backend/infrahub/core/graph/index.py, so that compiled to
NodeByLabelScan under an Apply, rescanning every Branch node per unwound name: O(branch_names
x Branch-node-count), quadratic in branch-set size.

Measured at 200 branches, median of 30 timed runs after warmup, A/B/A/B:

brancheswithout indexwith index
53.2 ms2.1 ms
508.9 ms5.7 ms
20030.0 ms22.3 ms

Server-only time at 200 branches: 13 ms to 6 ms. PROFILE db-hits: 118,600 to 38,000, the
whole delta being NodeByLabelScan (40,600) plus Filter br.name = branch_name (40,400)
collapsing into a single NodeIndexSeek at 400. The client-side ratio is diluted by ~16 ms of
Python hydration for 1200 rows, which no index touches.

The index does not measurably help Branch.get_by_name on the registry cache-miss path
(db-hits 428 to 24, wall-clock unmoved) - that lookup is round-trip-bound. It is justified by this
query alone.

EXPLAIN plan (T034), 200 branches, 1 repository, 3 attributes

All four criteria pass:

  • Branch.name uses NodeIndexSeek (this failed before the index)
  • Node.uuid uses NodeIndexSeek
  • no CartesianProduct
  • no eager operator between the UNWIND and the subqueries
+ ProduceResults@neo4j  repository_id, branch_name, attribute_name, attribute_id, value, value_branch, updated_at
  + Projection@neo4j  cache[a.uuid] AS attribute_id, cache[r_value.branch] AS value_branch, cache[n.uuid] AS repository_id, av.value AS value, cache[r_value.from] AS updated_at, cache[a.name] AS attribute_name
    + CacheProperties@neo4j  cache[a.name], cache[a.uuid]
      + Filter@neo4j  cache[r_value.status] = $autostring_1
        + Apply@neo4j
          + Filter@neo4j  cache[r.status] = $autostring_0
            + Apply@neo4j
              + Distinct@neo4j  n, a, branch_name, default_window
                + Filter@neo4j  (a:Attribute AND cache[a.name] IN $attribute_names)
                  + Expand(All)@neo4j  (n)-[:HAS_ATTRIBUTE]->(a)
                    + Apply@neo4j
                      + Projection@neo4j  CASE
  WHEN br.is_isolated THEN br.branched_from
  ELSE $at
END AS default_window
                        + Apply@neo4j
                          + Unwind@neo4j  $branch_names AS branch_name
                          + NodeIndexSeek@neo4j  RANGE INDEX br:Branch(name) WHERE name = branch_name
                      + NodeIndexSeek@neo4j  RANGE INDEX n:Node(uuid) WHERE uuid = $repository_ids[0], cache[n.uuid]
              + Top@neo4j  `r.branch_level` DESC, `r.from` DESC, `r.status` ASC LIMIT 1
                + Projection@neo4j  cache[r.branch_level] AS `r.branch_level`, cache[r.from] AS `r.from`, cache[r.status] AS `r.status`
                  + CacheProperties@neo4j  cache[r.branch_level], cache[r.from], cache[r.status]
                    + Distinct@neo4j  n, a, branch_name, r, default_window
                      + Union@neo4j
                        + Union@neo4j
                          + Union@neo4j
                            + Filter@neo4j  n = anon_9 AND a = anon_10 AND (r.to IS NULL AND cache[r.from] <= $at)
                              + DirectedRelationshipIndexSeek@neo4j  RANGE INDEX (anon_9)-[r:HAS_ATTRIBUTE(branch)]->(anon_10) WHERE branch IN [branch_name, $global_branch_name]
                            + Filter@neo4j  cache[r.from] <= $at AND n = anon_19 AND a = anon_20 AND r.to > $at
                              + DirectedRelationshipIndexSeek@neo4j  RANGE INDEX (anon_19)-[r:HAS_ATTRIBUTE(branch)]->(anon_20) WHERE branch IN [branch_name, $global_branch_name]
                          + Filter@neo4j  NOT branch_name = $default_branch_name AND cache[r.from] <= default_window AND n = anon_29 AND a = anon_30 AND r.to IS NULL
                            + DirectedRelationshipIndexSeek@neo4j  RANGE INDEX (anon_29)-[r:HAS_ATTRIBUTE(branch)]->(anon_30) WHERE branch = $default_branch_name
                        + Filter@neo4j  NOT branch_name = $default_branch_name AND cache[r.from] <= default_window AND n = anon_39 AND a = anon_40 AND r.to > default_window
                          + DirectedRelationshipIndexSeek@neo4j  RANGE INDEX (anon_39)-[r:HAS_ATTRIBUTE(branch)]->(anon_40) WHERE branch = $default_branch_name
          + Top@neo4j  `r_value.branch_level` DESC, `r_value.from` DESC, `r_value.status` ASC LIMIT 1
            + Projection@neo4j  cache[r_value.branch_level] AS `r_value.branch_level`, cache[r_value.from] AS `r_value.from`, cache[r_value.status] AS `r_value.status`
              + CacheProperties@neo4j  cache[r_value.branch_level], cache[r_value.from], cache[r_value.status]
                + Filter@neo4j  av:AttributeValue
                  + Distinct@neo4j  default_window, a, r_value, av, branch_name
                    + Union@neo4j
                      + Union@neo4j
                        + Union@neo4j
                          + Filter@neo4j  a = anon_67 AND (r_value.to IS NULL AND cache[r_value.from] <= $at)
                            + DirectedRelationshipIndexSeek@neo4j  RANGE INDEX (anon_67)-[r_value:HAS_VALUE(branch)]->(av) WHERE branch IN [branch_name, $global_branch_name], cache[r_value.branch]
                          + Filter@neo4j  cache[r_value.from] <= $at AND a = anon_72 AND r_value.to > $at
                            + DirectedRelationshipIndexSeek@neo4j  RANGE INDEX (anon_72)-[r_value:HAS_VALUE(branch)]->(av) WHERE branch IN [branch_name, $global_branch_name], cache[r_value.branch]
                        + Filter@neo4j  NOT branch_name = $default_branch_name AND cache[r_value.from] <= default_window AND a = anon_77 AND r_value.to IS NULL
                          + DirectedRelationshipIndexSeek@neo4j  RANGE INDEX (anon_77)-[r_value:HAS_VALUE(branch)]->(av) WHERE branch = $default_branch_name, cache[r_value.branch]
                      + Filter@neo4j  NOT branch_name = $default_branch_name AND cache[r_value.from] <= default_window AND a = anon_82 AND r_value.to > default_window
                        + DirectedRelationshipIndexSeek@neo4j  RANGE INDEX (anon_82)-[r_value:HAS_VALUE(branch)]->(av) WHERE branch = $default_branch_name, cache[r_value.branch]

Error-path review (T046)

Audited against dev/guidelines/backend/exceptions.md:
backend/infrahub/core/repository_branch_status/, backend/infrahub/core/query/repository.py,
backend/infrahub/graphql/queries/repository_branch_status/.

1. except clauses - PASS. The only except clause in the whole slice is
permissions.py:61, catching exactly InitializationError and re-raising PermissionDeniedError from exc after a log.warning. The other grep hits are from infrahub.exceptions import ...
lines, not handlers. No bare except, no except Exception, no # noqa: BLE001, no try around
code that cannot raise. core/query/repository.py and every module under
core/repository_branch_status/ contain no except at all.

2 and 3. Every raise, its class, and the test that provokes it:

#SiteClassInfrahub Error subclass?Provoking test
1core/repository_branch_status/models.py:38ValueErrorNo - see noteunit/.../test_repository_branch_status.py::TestRepositoryBranchAttributes::test_duplicate_triple_raises
2kind_dispatch.py:66ValidationErrorYesunit/...::TestPolicyForKind::test_unsupported_kind_names_the_kind
3permissions.py:43PermissionDeniedErrorYescomponent/...::TestRepositoryBranchStatusPermissions::test_view_permission_matrix[default-branch-no-grant], [user-branch-no-grant], ::test_denial_runs_no_database_query, ::test_anonymous_session_without_a_grant_is_denied
4permissions.py:56PermissionDeniedErrorYescomponent/...::test_a_grant_on_one_kind_does_not_cover_the_other_kind
5permissions.py:67PermissionDeniedErrorYescomponent/...::test_context_without_loaded_permissions_is_denied
6resolver.py:57 (at)ValidationErrorYescomponent/...::test_a_request_for_a_past_point_in_time_is_rejected (negative control: ::test_a_request_carrying_no_at_parameter_is_accepted)
7resolver.py:117 (limit)ValidationErrorYescomponent/...::test_invalid_paging_arguments_are_rejected[limit-below-one], [explicit-null-limit]
8resolver.py:119 (offset)ValidationErrorYescomponent/...::test_invalid_paging_arguments_are_rejected[negative-offset], [explicit-null-offset]
9resolver.py:141 (non-repository node)NodeNotFoundErrorYescomponent/...::test_a_node_of_another_kind_is_reported_as_not_found

core/query/repository.py raises nothing of its own; nor do reader.py, interface.py,
field.py, paging.py, payload.py.

Two errors propagated unchanged rather than raised here, both covered: NodeNotFoundError from the
repository lookup (::test_unknown_repository_is_reported_as_not_found) and ValidationError from
standard_node_ordering_from_order_input (::test_contradictory_order_is_rejected).

Deviation, row 1. RepositoryBranchAttributes.from_values raises a plain ValueError on a
duplicate (repository, branch, attribute) triple, deliberately not an Infrahub Error. The
Cypher makes the duplicate impossible (WITH DISTINCT before the election, LIMIT 1 in each
subquery), so reaching it is a server-side data-integrity bug; an Error subclass would render it
to the client as a clean, actionable message with data: null as though the caller had erred.
Reviewer input welcome - this is a knowing deviation from T046's wording.

4. Miss renders null, database errors propagate - PASS. build_attribute_payload returns
None for a None value, which serialises as GraphQL null; covered end to end by
::test_a_repository_that_was_never_imported_returns_rows_without_a_commit and
::test_ref_is_null_on_the_read_write_kind_and_set_on_the_read_only_kind. No fabricated value at
any layer. There is no handler on the read path at all, so driver errors reach the GraphQL error
formatter unmodified.

Behaviour notes

  • at is rejected on this field. The rows come from Branch.get_list, which takes no at, so
    honouring ?at= would pair a current branch set with historical values: rows for branches that
    did not exist then, none for branches deleted since. The guard reads
    request.query_params.get("at") rather than graphql_context.at, because
    prepare_graphql_params coerces via Timestamp(at) and Timestamp(None) is now, so
    graphql_context.at is never None. The at parameter is still plumbed to the reader for
    increment C.
  • Known limitation: the guard covers the /graphql endpoint. A saved CoreGraphQLQuery
    fetched through /api/query/<name>?at= or the transformation endpoints passes no request into
    prepare_graphql_params, so it would still return current branches with historical values. That
    is not an expected way to run this query; it becomes its own issue if it ever comes up.
  • Value filters now widen the read by the attribute they compare, so sync_status__value works
    whether or not the document selects sync_status. This was a bug found in review: before the
    fix, a filter without the matching field in the selection returned count: 0 and no rows, with
    no error.
  • For CoreReadOnlyRepository, commit and ref are AWARE and land on the current branch at
    creation, so own_values_only is true on the default branch without an import, and a branch
    forked before the repository existed resolves no commit/ref. Documented.

Testing

119 tests: 10 primitive component, 82 GraphQL component, 27 unit. Plus an opt-in query benchmark in
backend/tests/query_benchmark/. /pre-ci green; re-verified after the review fixes (2523 backend
unit tests, whole-repo ruff, mypy on 1670 files, all four generated-file validations, docs lint).

Quickstart B1 through B8 are covered, stub.py is deleted, and the query count for one page is
equal at 5 and 200 branches.

Known gaps, deferred deliberately

Recorded in dev/specs/infp-671-cross-branch-repo-status/opsmill-implement-report-phase4.md:

  • No value coverage for CoreReadOnlyRepository, whose AWARE commit/ref take a different
    edge-placement path. The most likely place for a surprise.
  • The differential test cannot catch a strict/non-strict boundary flip, because every fixture
    timestamp is strictly ordered.
  • default_window omits the at > branched_from guard that get_branches_and_times_to_query_global
    applies; unreachable from GraphQL now that at is rejected, but the primitive still accepts one.
  • No explicit ceiling on the result set: self.limit steers into the single-shot branch, so
    query_with_size_limit chunking never applies.
  • No coverage for a soft-deleted value or repository.

Not done

T047's acceptance names "Jira subtask from T032 closed". No such issue exists - IFC-3104's
children are IFC-3125 through IFC-3132. T032 asked for a "Remove InfrahubRepositoryBranchStatus
stub" task that was never created. Moot in substance, since the stub is deleted in this PR.