diff --git a/juniper_data/api/observability.py b/juniper_data/api/observability.py index 52520e4..4469e13 100644 --- a/juniper_data/api/observability.py +++ b/juniper_data/api/observability.py @@ -105,45 +105,27 @@ async def __call__(self, scope, receive, send): def _ensure_dataset_metrics() -> dict: """Create dataset-related Prometheus metrics on first access. - Idempotent against the global ``prometheus_client.REGISTRY``: if the - module-level cache has been cleared (e.g. by a test fixture - resetting ``_dataset_metrics = None``) but the underlying - counters / histogram / gauge are still registered, this re-fetches - the existing collectors instead of raising - ``ValueError: Duplicated timeseries``. Same shape as - ``juniper_observability.middleware.prometheus.PrometheusMiddleware`` - (juniper-ml PR #211) and ``juniper-canopy/src/observability.py - :_ensure_canopy_metrics`` (canopy V34a). Production behaviour - unchanged on the happy path. + Idempotent against the global ``prometheus_client.REGISTRY`` via + :func:`juniper_observability.register_or_reuse`: if the module-level + cache has been cleared (e.g. by a test fixture resetting + ``_dataset_metrics = None``) but the underlying counters / histogram + / gauge are still registered, the helper re-fetches the existing + collectors instead of raising ``ValueError: Duplicated timeseries``. + Production behaviour unchanged on the happy path. """ global _dataset_metrics if _dataset_metrics is None: - from prometheus_client import REGISTRY, Counter, Gauge, Histogram - - def _get_or_create(factory, name, *args, **kwargs): - try: - return factory(name, *args, **kwargs) - except ValueError: - # Already registered — typically test pollution or an - # in-process re-init. Re-fetch the existing collector so - # callers always get a working metric. ``prometheus_client`` - # registers each collector under both the bare name and - # the suffixed sample names (``_total`` / ``_created`` / - # ``_bucket`` / ``_sum`` / ``_count``), all pointing at - # the same collector object. - existing = REGISTRY._names_to_collectors.get(name) - if existing is None: - raise - return existing + from juniper_observability import register_or_reuse + from prometheus_client import Counter, Gauge, Histogram _dataset_metrics = { - "generations_total": _get_or_create( + "generations_total": register_or_reuse( Counter, "juniper_data_dataset_generations_total", "Total dataset generation requests", ["generator", "status"], ), - "generation_duration_seconds": _get_or_create( + "generation_duration_seconds": register_or_reuse( Histogram, "juniper_data_dataset_generation_duration_seconds", # METRICS-MON R4.1: bucket layout is **tentative pending @@ -156,7 +138,7 @@ def _get_or_create(factory, name, *args, **kwargs): ["generator"], buckets=DATASET_GENERATION_DURATION_BUCKETS, ), - "datasets_cached": _get_or_create( + "datasets_cached": register_or_reuse( Gauge, "juniper_data_datasets_cached", "Number of datasets currently cached in storage", @@ -166,7 +148,7 @@ def _get_or_create(factory, name, *args, **kwargs): # actual generation work (cache misses); this counts every # incoming POST so capacity-planning queries don't undercount # deterministic re-POSTs (see roadmap §7 R4.5). - "post_total": _get_or_create( + "post_total": register_or_reuse( Counter, "juniper_data_dataset_post_total", "Total POST /v1/datasets requests, split by cache outcome", diff --git a/juniper_data/storage/cached.py b/juniper_data/storage/cached.py index d23e569..0e62f73 100644 --- a/juniper_data/storage/cached.py +++ b/juniper_data/storage/cached.py @@ -5,7 +5,11 @@ import numpy as np -from juniper_data.api.observability import set_datasets_cached +# ``set_datasets_cached`` is imported lazily inside ``_emit_cached_count`` +# below — top-level import here triggers a circular import via +# ``juniper_data.api.__init__`` → ``api.app`` → ``juniper_data.storage`` +# (introduced by PR #92). Lazy import breaks the cycle without changing +# any production behaviour. from juniper_data.core.models import DatasetMeta from juniper_data.storage.constants import DEFAULT_LIST_LIMIT, DEFAULT_LIST_OFFSET @@ -62,6 +66,10 @@ def _emit_cached_count(self) -> None: discipline used everywhere else in this class. """ try: + # Lazy import — see top-of-file comment for the cycle + # avoidance rationale. + from juniper_data.api.observability import set_datasets_cached + count = len(self._cache.list_datasets(limit=_CACHE_COUNT_PROBE_LIMIT)) set_datasets_cached(count) except Exception: diff --git a/pyproject.toml b/pyproject.toml index 688c26a..a86be56 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -57,7 +57,7 @@ api = [ # >=0.1.1 to match the cascor / canopy floor (audit-doc C.2 fix); # juniper-ml#155 published 0.1.0a0, juniper-ml has since shipped # 0.1.1. - "juniper-observability>=0.1.1", + "juniper-observability>=0.2.0", ] test = [ "pytest>=7.0.0",