From b7b158972cd2ecc7cb72a5bbb6a5a56f47609359 Mon Sep 17 00:00:00 2001 From: Robbie1977 Date: Fri, 4 Sep 2026 15:58:15 +0000 Subject: [PATCH] Never let a class-connectivity backend failure read as count 0 _aggregate_class_connectivity returned [] on five infrastructure failures (Neo4j membership query raising or returning False, per-instance connectivity unobtainable for every instance, partner classes unresolved, partner membership query failing). The callers turned that into {'count': 0}, @with_solr_cache stored it (count >= 0 is "valid"), and the v3-cached nginx edge pinned the 200 for CACHE_STALE_TIME. One transient outage became a month of "There is no data to display" for gamma Kenyon cell downstream connectivity (2026-09-04). Raise ConnectivityBackendError on those paths instead. The decorator does not catch it and ha_api answers 5xx, which neither cache stores. A real zero (no instances, or no positive-weight edges) is unchanged. A result computed with some instances' connectivity missing is now tagged 'partial': the Solr result cache refuses to store it and ha_api sends X-Accel-Expires: 600 so the edge keeps the underestimate only briefly. Any empty run_query result is sent with X-Accel-Expires: 0 (plus Cache-Control: no-store) so the edge never stores an empty body; a true zero is served from the Solr cache in milliseconds, so the cost is negligible. X-Accel-Expires is honoured by owl_cache's nginx as-is (only Cache-Control/Expires/Set-Cookie are in proxy_ignore_headers), so no edge change or redeploy is needed. --- .../test_class_connectivity_no_silent_zero.py | 200 ++++++++++++++++++ src/vfbquery/ha_api.py | 67 +++++- src/vfbquery/solr_result_cache.py | 21 ++ src/vfbquery/vfb_queries.py | 136 +++++++++++- 4 files changed, 412 insertions(+), 12 deletions(-) create mode 100644 src/test/test_class_connectivity_no_silent_zero.py diff --git a/src/test/test_class_connectivity_no_silent_zero.py b/src/test/test_class_connectivity_no_silent_zero.py new file mode 100644 index 0000000..210228f --- /dev/null +++ b/src/test/test_class_connectivity_no_silent_zero.py @@ -0,0 +1,200 @@ +"""A class-connectivity backend failure must never read as ``count: 0``. + +Backend-free: every Neo4j/Solr/Owlery touch point inside +``_aggregate_class_connectivity`` is monkeypatched, so these run in CI without +credentials and exercise exactly the paths that used to ``return []``. + +Why: on 2026-09-04 gamma Kenyon cell (FBbt_00100247) showed "There is no data +to display" for DownstreamClassConnectivity. The origin had returned +``{'count': 0}`` during a moment it could not fetch per-instance connectivity, +the Solr result cache stored it (``count >= 0`` is "valid"), and the +v3-cached nginx edge pinned that 200 for a month. Three caches, one lie. +""" +import types + +import pytest + +from vfbquery import ha_api +from vfbquery import solr_result_cache as src +from vfbquery import vfb_queries as vq + + +CLASS = "FBbt_00100247" +INSTANCES = {"VFB_1", "VFB_2", "VFB_3"} + + +class _NC: + """Stand-in for ``vc.nc`` whose ``commit_list`` returns whatever we say.""" + + def __init__(self, reply): + self._reply = reply + + def commit_list(self, statements): + reply = self._reply + if isinstance(reply, Exception): + raise reply + return reply + + +def _membership_reply(instances=INSTANCES): + """A Neo4j transaction result listing *instances* under CLASS.""" + return [{ + "columns": ["cid", "label", "iids"], + "data": [{"row": [CLASS, "gamma Kenyon cell", sorted(instances)]}], + }] + + +def _wire(monkeypatch, *, membership=None, edges=None, missing=(), + partner_entries=None, ancestors=None, partner_membership=None): + """Patch every backend seam of ``_aggregate_class_connectivity``. + + Defaults describe a healthy query with one downstream partner class. + """ + if membership is None: + membership = _membership_reply() + if edges is None: + edges = {i: [{"id": "VFB_p", "outputs": 5}] for i in INSTANCES} + if partner_entries is None: + partner_entries = [{"object": {"short_form": "FBbt_p"}}] + if ancestors is None: + ancestors = ({"FBbt_p"}, {"FBbt_p": "partner"}) + if partner_membership is None: + partner_membership = {"VFB_p": {"FBbt_p"}} + + fake_vc = types.SimpleNamespace( + nc=_NC(membership), + vfb=types.SimpleNamespace(oc=types.SimpleNamespace( + get_subclasses=lambda **kw: [])), + ) + monkeypatch.setattr(vq, "vc", fake_vc) + monkeypatch.setattr(vq, "_bulk_fetch_per_instance_connectivity", + lambda ids: (dict(edges), list(missing))) + monkeypatch.setattr(vq, "_fetch_connectivity_entries", + lambda *a, **k: list(partner_entries)) + monkeypatch.setattr(vq, "_get_partner_class_ancestors", + lambda *a, **k: ancestors) + monkeypatch.setattr(vq, "_build_partner_instance_class_membership", + lambda ids: dict(partner_membership)) + + +# --------------------------------------------------------------------------- +# _aggregate_class_connectivity +# --------------------------------------------------------------------------- + +def test_healthy_query_returns_rows(monkeypatch): + _wire(monkeypatch) + status = {} + rows = vq._aggregate_class_connectivity(CLASS, "downstream", status=status) + assert [r["id"] for r in rows] == ["FBbt_p"] + assert rows[0]["connected_n"] == 3 and rows[0]["total_n"] == 3 + assert status == {"missing": 0, "total": 3} + + +def test_class_with_no_instances_is_a_true_zero(monkeypatch): + _wire(monkeypatch, membership=[{"columns": ["cid", "label", "iids"], + "data": []}]) + assert vq._aggregate_class_connectivity(CLASS, "downstream") == [] + + +def test_instances_with_no_positive_edges_is_a_true_zero(monkeypatch): + _wire(monkeypatch, edges={i: [] for i in INSTANCES}, + partner_entries=[], ancestors=(set(), {})) + assert vq._aggregate_class_connectivity(CLASS, "downstream") == [] + + +def test_membership_query_exception_raises(monkeypatch): + _wire(monkeypatch, membership=RuntimeError("neo4j down")) + with pytest.raises(vq.ConnectivityBackendError): + vq._aggregate_class_connectivity(CLASS, "downstream") + + +def test_membership_query_false_reply_raises(monkeypatch): + # commit_list signals a transaction error by returning False, which + # dict_cursor used to swallow into [] — the silent path. + _wire(monkeypatch, membership=False) + with pytest.raises(vq.ConnectivityBackendError): + vq._aggregate_class_connectivity(CLASS, "downstream") + + +def test_no_per_instance_connectivity_at_all_raises(monkeypatch): + _wire(monkeypatch, edges={}, missing=sorted(INSTANCES)) + with pytest.raises(vq.ConnectivityBackendError): + vq._aggregate_class_connectivity(CLASS, "downstream") + + +def test_partner_classes_unresolved_despite_edges_raises(monkeypatch): + _wire(monkeypatch, partner_entries=[], ancestors=(set(), {})) + with pytest.raises(vq.ConnectivityBackendError): + vq._aggregate_class_connectivity(CLASS, "downstream") + + +def test_partner_membership_failure_raises(monkeypatch): + _wire(monkeypatch, partner_membership={}) + with pytest.raises(vq.ConnectivityBackendError): + vq._aggregate_class_connectivity(CLASS, "downstream") + + +def test_partial_coverage_is_reported_in_status(monkeypatch): + edges = {"VFB_1": [{"id": "VFB_p", "outputs": 5}]} + _wire(monkeypatch, edges=edges, missing=["VFB_2", "VFB_3"]) + status = {} + rows = vq._aggregate_class_connectivity(CLASS, "downstream", status=status) + assert rows and status == {"missing": 2, "total": 3} + + +# --------------------------------------------------------------------------- +# the public functions: partial flag, and the Solr cache refusing it +# --------------------------------------------------------------------------- + +def test_partial_result_is_flagged_and_complete_result_is_not(monkeypatch): + _wire(monkeypatch) + full = vq.get_downstream_class_connectivity.__wrapped__( + CLASS, return_dataframe=False) + assert full["count"] == 1 and vq.PARTIAL_RESULT_KEY not in full + + _wire(monkeypatch, edges={"VFB_1": [{"id": "VFB_p", "outputs": 5}]}, + missing=["VFB_2", "VFB_3"]) + partial = vq.get_downstream_class_connectivity.__wrapped__( + CLASS, return_dataframe=False) + assert partial["count"] == 1 + assert partial[vq.PARTIAL_RESULT_KEY]["missing_instances"] == 2 + assert partial[vq.PARTIAL_RESULT_KEY]["total_instances"] == 3 + assert src.result_is_partial(partial) and not src.result_is_partial(full) + + +def test_upstream_shares_the_same_paths(monkeypatch): + _wire(monkeypatch, membership=False) + with pytest.raises(vq.ConnectivityBackendError): + vq.get_upstream_class_connectivity.__wrapped__( + CLASS, return_dataframe=False) + + +# --------------------------------------------------------------------------- +# ha_api: what the edge is told +# --------------------------------------------------------------------------- + +def test_empty_result_is_not_cached_at_the_edge(): + headers = ha_api._edge_cache_headers({"headers": {}, "rows": [], "count": 0}) + assert headers["X-Accel-Expires"] == "0" + assert headers["Cache-Control"] == "no-store" + + +def test_partial_result_gets_a_short_edge_ttl(): + result = {"headers": {}, "rows": [{"id": "x"}], "count": 1, + ha_api.PARTIAL_RESULT_KEY: {"missing_instances": 2}} + headers = ha_api._edge_cache_headers(result) + assert headers["X-Accel-Expires"] == str(ha_api.PARTIAL_RESULT_EDGE_TTL) + assert headers["Cache-Control"] == "max-age=%d" % ha_api.PARTIAL_RESULT_EDGE_TTL + + +def test_complete_result_gets_the_edge_default(): + assert ha_api._edge_cache_headers( + {"headers": {}, "rows": [{"id": "x"}], "count": 1}) == {} + + +def test_page_past_the_end_is_not_empty(): + # count is the authority: a later page of a 40-row result has no rows + # in this slice but is not a "no data" answer. + assert not ha_api._result_is_empty({"rows": [], "count": 40}) + assert ha_api._result_is_empty({"rows": []}) + assert not ha_api._result_is_empty({"error": "boom"}) diff --git a/src/vfbquery/ha_api.py b/src/vfbquery/ha_api.py index fafda49..0d534d2 100644 --- a/src/vfbquery/ha_api.py +++ b/src/vfbquery/ha_api.py @@ -1354,6 +1354,68 @@ def _slice_page(result, offset=0, page_size=None): return page +#: Edge-cache lifetime, in seconds, for a result flagged as an underestimate +#: (see ``vfbquery.solr_result_cache.PARTIAL_RESULT_KEY``). Long enough that a +#: burst of visitors shares one computation, short enough that the corrected +#: numbers replace it the same morning rather than next month. +PARTIAL_RESULT_EDGE_TTL = int(os.getenv("VFBQUERY_PARTIAL_EDGE_TTL", "600") or "600") + +#: Same string as ``solr_result_cache.PARTIAL_RESULT_KEY``, repeated here +#: rather than imported so this module stays free of pysolr at import time. +PARTIAL_RESULT_KEY = "partial" + + +def result_is_partial(result): + """True for a dict result the query function flagged as an underestimate.""" + return isinstance(result, dict) and bool(result.get(PARTIAL_RESULT_KEY)) + + +def _result_is_empty(result): + """True for a dict result with no rows to show. + + ``count`` is the authority when present (a paged slice past the end has + no rows but a positive count and is not empty); otherwise an empty + ``rows`` list decides. + """ + if not isinstance(result, dict): + return False + count = result.get("count") + if isinstance(count, (int, float)) and not isinstance(count, bool): + return count == 0 + rows = result.get("rows") + return isinstance(rows, list) and not rows + + +def _edge_cache_headers(result): + """Headers telling the v3-cached nginx layer how long to keep *result*. + + That layer ignores ``Cache-Control`` and ``Expires`` (``proxy_ignore_headers`` + in owl_cache's nginx.conf.template) but honours nginx's own + ``X-Accel-Expires``, so this is the one lever the origin has over the + edge without an nginx change. + + - An **empty** result (``count`` 0) is never stored at the edge. A true + zero costs one origin hit per visitor, answered from the Solr result + cache in milliseconds; a false zero — the origin momentarily unable to + compute (gamma Kenyon cell, 2026-09-04) — would otherwise be served as + "There is no data to display" for ``CACHE_STALE_TIME`` (a month) to + everyone. + - A **partial** result (flagged by the query function as an + underestimate) is kept for :data:`PARTIAL_RESULT_EDGE_TTL` seconds. + - Anything else gets no header and the edge's default lifetime. + + ``Cache-Control`` is set alongside for any intermediary that does honour + it (browsers, a future edge that stops ignoring it). + """ + if result_is_partial(result): + ttl = PARTIAL_RESULT_EDGE_TTL + return {"X-Accel-Expires": str(ttl), + "Cache-Control": "max-age=%d" % ttl} + if _result_is_empty(result): + return {"X-Accel-Expires": "0", "Cache-Control": "no-store"} + return {} + + def _page_out(result, func_name, offset=0, page_size=None): """Finalise a result for sending: AllAlignedImages is already a single server page (just bound it); everything else is sliced from its full set.""" @@ -1462,7 +1524,10 @@ def finish(result): out = _page_out(result, func_name, offset, page_size) if include_graph: out = _maybe_add_graph(out, func_name, short_form) - return web.json_response(_with_warnings(out, warnings)) + # Judge emptiness on the full stored result, not the page: a page past + # the end of a non-empty result is not a "no data" answer. + return web.json_response(_with_warnings(out, warnings), + headers=_edge_cache_headers(result)) # Normalize key — AllDatasets ignores the id parameter if func_name == "get_all_datasets": diff --git a/src/vfbquery/solr_result_cache.py b/src/vfbquery/solr_result_cache.py index f2950aa..2fd85a3 100644 --- a/src/vfbquery/solr_result_cache.py +++ b/src/vfbquery/solr_result_cache.py @@ -155,6 +155,16 @@ def cache_doc_glob(namespace: Optional[str] = None) -> str: PREVIEW_STATUS_PENDING = 'pending' PREVIEW_STATUS_COMPLETE = 'complete' +#: Key a query function sets on a dict result whose numbers are known to be +#: incomplete (``vfb_queries.PARTIAL_RESULT_KEY`` is the same string). Such a +#: result is returned to the caller but never written to the cache. +PARTIAL_RESULT_KEY = 'partial' + + +def result_is_partial(result) -> bool: + """True for a dict result flagged as an underestimate.""" + return isinstance(result, dict) and bool(result.get(PARTIAL_RESULT_KEY)) + def preview_is_resolved(query: Dict[str, Any]) -> bool: """True when a query's preview holds a final answer. @@ -1423,6 +1433,13 @@ def _call(*call_args, **call_kwargs): full_is_valid = full_result.get('count', -1) >= 0 else: full_is_valid = bool(full_result) + if full_is_valid and result_is_partial(full_result): + # An underestimate is served but never stored: + # storing it would make every later caller inherit + # wrong counts with no way to tell. + full_is_valid = False + logger.warning( + f"Not caching partial result for {query_type}({term_id})") elif isinstance(full_result, (list, str)): full_is_valid = len(full_result) > 0 else: @@ -1505,6 +1522,10 @@ def _call(*call_args, **call_kwargs): result_is_error = count_value < 0 # Mark as error if count is negative else: result_is_valid = bool(result) # For dicts without count field + if result_is_valid and result_is_partial(result): + result_is_valid = False + logger.warning( + f"Not caching partial result for {query_type}({term_id})") elif isinstance(result, (list, str)): result_is_valid = len(result) > 0 else: diff --git a/src/vfbquery/vfb_queries.py b/src/vfbquery/vfb_queries.py index eb86436..d0e8c67 100644 --- a/src/vfbquery/vfb_queries.py +++ b/src/vfbquery/vfb_queries.py @@ -125,6 +125,36 @@ def get_dict_cursor(): OWLERY_SUBCLASS_TIMEOUT = 30 +class ConnectivityBackendError(RuntimeError): + """A class-connectivity aggregation could not be computed. + + Raised by :func:`_aggregate_class_connectivity` when a Neo4j or Solr call + it depends on fails, or when it could obtain per-instance connectivity for + none of the queried instances. Before this existed, those cases all fell + through to ``return []`` and the caller turned that into + ``{'count': 0, 'rows': []}`` — indistinguishable from a class with no + partners. That zero was then written to the Solr result cache (``count >= + 0`` is its definition of valid) and pinned at the v3-cached nginx edge for + ``CACHE_STALE_TIME``, so one transient outage became a month of "There is + no data to display" for every visitor (gamma Kenyon cell, 2026-09-04). + + ``@with_solr_cache`` does not catch exceptions from the wrapped function, + and ``ha_api`` turns an uncaught exception into a 5xx, which neither cache + stores. Raising is therefore the honest answer: "not known", not "zero". + """ + + +#: Result key set by :func:`get_downstream_class_connectivity` / +#: :func:`get_upstream_class_connectivity` when the aggregation ran but some +#: queried instances contributed nothing (their per-instance connectivity +#: could not be fetched or computed), so ``connected_n`` / +#: ``pairwise_connections`` / ``total_weight`` are underestimates. The Solr +#: result cache refuses to store a result carrying this key, and ``ha_api`` +#: gives it a short edge TTL, so the underestimate self-heals instead of being +#: served as fact for a month. +PARTIAL_RESULT_KEY = 'partial' + + def _neo4j_subclass_ids(short_form): """Neo4j ``SUBCLASSOF`` closure (the class plus every asserted subclass), the fallback used when Owlery is unavailable or too slow. Returns ``[]`` on @@ -4630,8 +4660,20 @@ def _bulk_fetch_per_instance_connectivity(instance_ids): return found, missing +def _has_positive_edge(edge_rows, weight_key): + """True if any per-instance partner row carries a positive weight.""" + for prow in edge_rows or []: + try: + if float(prow.get(weight_key) or 0) > 0: + return True + except (TypeError, ValueError): + continue + return False + + def _aggregate_class_connectivity(short_form, direction, - neuron_root=NEURON_ROOT_SHORT_FORM): + neuron_root=NEURON_ROOT_SHORT_FORM, + status=None): """Aggregate class-level partner connectivity for the queried class AND each of its subclasses individually, correctly under FBbt multi-inheritance using set-union over instance memberships. @@ -4647,8 +4689,21 @@ def _aggregate_class_connectivity(short_form, direction, membership) are computed once for the whole subtree and instances are then partitioned by queried (sub)class, so cost is roughly independent of the number of subclasses. + + An empty list means "no partners": the class has no connectivity + instances, or they have no positive-weight edges to in-scope partner + classes. It never means "a backend call failed" — that raises + :class:`ConnectivityBackendError` so that no cache layer can store a zero + that was really an outage. + + ``status`` may be a dict; on return it carries ``missing`` (the number of + queried instances whose per-instance connectivity could not be obtained, + so the counts are underestimates) and ``total`` (queried instances). """ from collections import defaultdict + if status is None: + status = {} + status.update(missing=0, total=0) # 1a. Queried (sub)classes in scope: the input term plus every subclass. # Reuse Owlery's reasoner subclass closure (the canonical subclass set @@ -4684,10 +4739,20 @@ def _aggregate_class_connectivity(short_form, direction, "collect(DISTINCT n.short_form) AS iids" % sorted(query_class_ids) ) try: - rows = get_dict_cursor()(vc.nc.commit_list([membership_q])) + raw = vc.nc.commit_list([membership_q]) except Exception as e: - print(f"Queried-side membership query failed for {short_form}: {e}") - return [] + raise ConnectivityBackendError( + f"Queried-side membership query failed for {short_form}: {e}" + ) from e + if not isinstance(raw, list): + # commit_list reports a transaction error by returning False, not by + # raising, and dict_cursor then turns False into [] — which is how a + # Neo4j outage used to read as "no instances". A class with no + # instances comes back as a list with an empty ``data``. + raise ConnectivityBackendError( + f"Queried-side membership query returned no result for {short_form}" + ) + rows = get_dict_cursor()(raw) query_class_to_instances = defaultdict(set) query_labels = {} all_instances = set() @@ -4708,14 +4773,21 @@ def _aggregate_class_connectivity(short_form, direction, # pairwise / total_weight will be a slight underestimate when this # happens. found_edges, missing = _bulk_fetch_per_instance_connectivity(all_instances) + status.update(missing=len(missing), total=len(all_instances)) if missing: print( - f"Warning: per-instance connectivity cache missing for " + f"Warning: per-instance connectivity unavailable for " f"{len(missing)}/{len(all_instances)} instances under {short_form}; " - f"those will be skipped (results may be a slight underestimate)." + f"those will be skipped (results are an underestimate)." ) if not found_edges: - return [] + # Every queried instance is a has_neuron_connectivity individual, so + # each has a per-instance result; getting none of them is a Solr/Neo4j + # failure, not a class with no partners. + raise ConnectivityBackendError( + f"Per-instance connectivity unavailable for all " + f"{len(all_instances)} instances under {short_form}" + ) weight_key = 'outputs' if direction == 'downstream' else 'inputs' @@ -4741,6 +4813,16 @@ def _aggregate_class_connectivity(short_form, direction, direct_partner_ids, neuron_root, ) if not partner_class_ids: + if any(_has_positive_edge(edges, weight_key) + for edges in found_edges.values()): + # Instances have partners but no partner *class* came back: + # _fetch_connectivity_entries swallows Solr errors as [] and + # _get_partner_class_ancestors swallows Neo4j errors, so this is + # an outage, not a class whose partners are unclassified. + raise ConnectivityBackendError( + f"No partner classes resolved for {short_form} although its " + f"instances have {direction} connectivity" + ) return [] # 5. Build partner_instance_id -> {class_ids it belongs to}, restricted @@ -4750,6 +4832,16 @@ def _aggregate_class_connectivity(short_form, direction, # closure), which is the denominator when the partner is the presynaptic # side (the upstream direction — see VFB_connect parity below). instance_to_partner_classes = _build_partner_instance_class_membership(partner_class_ids) + if not instance_to_partner_classes: + # partner_class_ids is non-empty here and every one of those classes + # has at least one connectivity instance (that is how it became a + # partner), so an empty membership map is the Neo4j query failing — + # the helper swallows the exception and returns {} — and would + # otherwise yield count 0 with no error. + raise ConnectivityBackendError( + f"Partner class membership query failed for {short_form} " + f"({len(partner_class_ids)} partner classes)" + ) partner_class_total = defaultdict(int) _partner_class_members = defaultdict(set) for iid, classes in instance_to_partner_classes.items(): @@ -4838,6 +4930,26 @@ def block_for(query_id): return rows +def _mark_partial(result, status): + """Tag a class-connectivity result whose counts are known to be low. + + ``status`` is the dict filled by :func:`_aggregate_class_connectivity`. + When some queried instances contributed no edges the result is still the + best available answer, so it is returned — but under + :data:`PARTIAL_RESULT_KEY` so that the Solr result cache does not store it + and the edge cache keeps it only briefly. + """ + missing = int((status or {}).get('missing') or 0) + if missing: + result[PARTIAL_RESULT_KEY] = { + 'missing_instances': missing, + 'total_instances': int(status.get('total') or 0), + 'reason': 'per-instance connectivity unavailable for some ' + 'instances; counts are underestimates', + } + return result + + def _format_class_connectivity_rows(rows, partner_key, query_key): """Populate both markdown-link class columns expected by the v2 layout and drop the internal ``_label`` / ``_query_label`` fields. @@ -4905,7 +5017,8 @@ class (Owlery's get_instances was observed to hang for some classes; :param limit: maximum number of results to return (default -1, returns all results) :return: Downstream partner neuron classes with connectivity statistics """ - rows = _aggregate_class_connectivity(short_form, 'downstream') + status = {} + rows = _aggregate_class_connectivity(short_form, 'downstream', status=status) if not rows: if return_dataframe: return pd.DataFrame() @@ -4937,7 +5050,7 @@ class (Owlery's get_instances was observed to hang for some classes; 'total_weight': {'title': 'Total Weight', 'type': 'number', 'order': 6}, 'avg_weight': {'title': 'Avg Weight', 'type': 'number', 'order': 7}, } - return {'headers': headers, 'rows': rows, 'count': total_count} + return _mark_partial({'headers': headers, 'rows': rows, 'count': total_count}, status) @with_solr_cache('upstream_class_connectivity_query') @@ -4978,7 +5091,8 @@ def get_upstream_class_connectivity(short_form: str, return_dataframe=True, limi :param limit: maximum number of results to return (default -1, returns all results) :return: Upstream partner neuron classes with connectivity statistics """ - rows = _aggregate_class_connectivity(short_form, 'upstream') + status = {} + rows = _aggregate_class_connectivity(short_form, 'upstream', status=status) if not rows: if return_dataframe: return pd.DataFrame() @@ -5010,7 +5124,7 @@ def get_upstream_class_connectivity(short_form: str, return_dataframe=True, limi 'total_weight': {'title': 'Total Weight', 'type': 'number', 'order': 6}, 'avg_weight': {'title': 'Avg Weight', 'type': 'number', 'order': 7}, } - return {'headers': headers, 'rows': rows, 'count': total_count} + return _mark_partial({'headers': headers, 'rows': rows, 'count': total_count}, status) # ---------------------------------------------------------------------------