SPB Git

spb/api-ka

Public

API-KA — plateforme centrale : collecte quotidienne des 8 services KA, historisation append-only et API publique sur www.api-ka.com

feat(monitoring): statut retired — retrait des sources désactivées ou disparues

Le registre connector_health est persisté et jamais purgé : une source
désactivée côté app (ex. stores fabri-ka enabled=0) restait broken/stale à
perpétuité et les agents gardiens la missionnaient sans fin (2026-08-24 :
37 missions ka6 sur origin-north.ai). Désormais :
- nouvelle colonne last_seen (dernière apparition dans une fenêtre de sync,
  ALTER TABLE appliqué en prod + backfill sur checked_at) ;
- statut retired si la source est déclarée désactivée par l app
  (disabled_stores/disabled_sources du /api/stats — 58 sources fabri-ka
  retirées au premier run) ou absente de tout journal depuis > 14 j ;
- une source retired qui réapparaît dans le journal est réévaluée
  normalement (last_seen remis à jour, statut recalculé) ;
- retired exposé dans les résumés (monitoring, /health) et le détail
  (last_seen ajouté), jamais alerté, ignoré par les gardiens (trigger
  broken/stale inchangé).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Simon-Pierre Boucher committed (Aug 24, 2026) parent 38bd2f4 Browse files

4 changed files +69 −10

modified src/api/routes/monitoring.py +1 −0
@@ -39,6 +39,7 @@ def _row_to_dict(row: ConnectorHealth) -> dict[str, Any]:
39 "median_found": row.median_found, 39 "median_found": row.median_found,
40 "consecutive_failures": row.consecutive_failures, 40 "consecutive_failures": row.consecutive_failures,
41 "message": row.message, 41 "message": row.message,
42+ "last_seen": row.last_seen.isoformat() if row.last_seen else None,
42 } 43 }
43 44
44 45
modified src/database/models.py +6 −1
@@ -216,7 +216,7 @@ class ConnectorHealth(Base):
216 ) 216 )
217 status: Mapped[str] = mapped_column( 217 status: Mapped[str] = mapped_column(
218 Text, nullable=False 218 Text, nullable=False
219− ) # ok / degraded / broken / stale 219+ ) # ok / degraded / broken / stale / retired
220 last_success: Mapped[datetime.datetime | None] = mapped_column( 220 last_success: Mapped[datetime.datetime | None] = mapped_column(
221 DateTime(timezone=True), nullable=True 221 DateTime(timezone=True), nullable=True
222 ) 222 )
@@ -226,3 +226,8 @@ class ConnectorHealth(Base):
226 Integer, nullable=False, default=0 226 Integer, nullable=False, default=0
227 ) 227 )
228 message: Mapped[str | None] = mapped_column(Text, nullable=True) 228 message: Mapped[str | None] = mapped_column(Text, nullable=True)
229+ # Dernière apparition de la source dans une fenêtre de sync (pilote le
230+ # retrait ``retired`` des sources disparues du journal).
231+ last_seen: Mapped[datetime.datetime | None] = mapped_column(
232+ DateTime(timezone=True), nullable=True
233+ )
modified src/monitoring/connector_health.py +61 −9
@@ -67,7 +67,19 @@ STATUS_OK = "ok"
67 STATUS_DEGRADED = "degraded" 67 STATUS_DEGRADED = "degraded"
68 STATUS_BROKEN = "broken" 68 STATUS_BROKEN = "broken"
69 STATUS_STALE = "stale" 69 STATUS_STALE = "stale"
70−ALL_STATUSES = (STATUS_OK, STATUS_DEGRADED, STATUS_BROKEN, STATUS_STALE) 70+# Source retirée de la supervision active : désactivée explicitement par
71+# l'app (``disabled_stores``/``disabled_sources`` du /api/stats) ou disparue
72+# de toute fenêtre de sync depuis > RETIRED_AFTER_DAYS. Sans ce statut, la
73+# ligne restait broken/stale à perpétuité (le registre est persisté, jamais
74+# purgé) et les agents gardiens missionnaient sans fin des sources mortes
75+# (2026-08-24 : 37 missions ka6 sur origin-north.ai, connecteur désactivé).
76+# La ligne se réévalue normalement si la source réapparaît dans le journal.
77+STATUS_RETIRED = "retired"
78+ALL_STATUSES = (STATUS_OK, STATUS_DEGRADED, STATUS_BROKEN, STATUS_STALE, STATUS_RETIRED)
79+
80+# Retrait après N jours sans apparaître dans aucune fenêtre de sync (marge
81+# large au-dessus des rotations observées, ~11-30 h selon les apps).
82+RETIRED_AFTER_DAYS = 14
71 83
72 # broken après N échecs (ou 0 résultat) consécutifs. 84 # broken après N échecs (ou 0 résultat) consécutifs.
73 BROKEN_THRESHOLD = 3 85 BROKEN_THRESHOLD = 3
@@ -110,6 +122,7 @@ class SourceState:
110 median_found: float | None = None 122 median_found: float | None = None
111 consecutive_failures: int = 0 123 consecutive_failures: int = 0
112 checked_at: datetime.datetime | None = None 124 checked_at: datetime.datetime | None = None
125+ last_seen: datetime.datetime | None = None
113 126
114 127
115 @dataclass 128 @dataclass
@@ -397,6 +410,7 @@ def _state_from_row(row: ConnectorHealth | None) -> SourceState:
397 median_found=row.median_found, 410 median_found=row.median_found,
398 consecutive_failures=row.consecutive_failures or 0, 411 consecutive_failures=row.consecutive_failures or 0,
399 checked_at=_ensure_utc(row.checked_at), 412 checked_at=_ensure_utc(row.checked_at),
413+ last_seen=_ensure_utc(row.last_seen),
400 ) 414 )
401 415
402 416
@@ -415,8 +429,14 @@ def _apply(
415 source: str, 429 source: str,
416 assessment: Assessment, 430 assessment: Assessment,
417 now: float, 431 now: float,
432+ seen: bool = False,
418 ) -> str: 433 ) -> str:
419− """Upsert de la ligne connector_health + alerte sur transition broken/stale.""" 434+ """Upsert de la ligne connector_health + alerte sur transition broken/stale.
435+
436+ ``seen`` indique que la source est apparue dans la fenêtre de sync de ce
437+ run : ``last_seen`` est alors horodaté — c'est lui qui pilote le retrait
438+ (``retired``) des sources disparues du journal depuis > RETIRED_AFTER_DAYS.
439+ """
420 row = rows.get(source) 440 row = rows.get(source)
421 previous_status = row.status if row is not None else None 441 previous_status = row.status if row is not None else None
422 442
@@ -425,6 +445,9 @@ def _apply(
425 row = ConnectorHealth(service=service, source=source) 445 row = ConnectorHealth(service=service, source=source)
426 session.add(row) 446 session.add(row)
427 rows[source] = row 447 rows[source] = row
448+ row.last_seen = checked_at
449+ if seen:
450+ row.last_seen = checked_at
428 row.checked_at = checked_at 451 row.checked_at = checked_at
429 row.status = assessment.status 452 row.status = assessment.status
430 row.last_success = assessment.last_success 453 row.last_success = assessment.last_success
@@ -475,28 +498,57 @@ def _check_service(service: str, now: float) -> dict[str, Any]:
475 # de 2 h, fabrika en bord de rotation de ~11 h pour un seuil de 24 h). 498 # de 2 h, fabrika en bord de rotation de ~11 h pour un seuil de 24 h).
476 source_stale_after = app_stale_after * (rotation_factor(payload, by_source) + 1) 499 source_stale_after = app_stale_after * (rotation_factor(payload, by_source) + 1)
477 500
501+ # Sources désactivées déclarées par l'app elle-même (signal explicite,
502+ # ex. ``disabled_stores`` de fabri-ka) : retrait immédiat.
503+ disabled = {
504+ str(s)
505+ for s in (payload.get("disabled_stores") or payload.get("disabled_sources") or [])
506+ if s
507+ }
508+
478 with session_scope() as session: 509 with session_scope() as session:
479 rows = _load_rows(session, service) 510 rows = _load_rows(session, service)
480 511
481 assessment = assess_source( 512 assessment = assess_source(
482 [app_entry], _state_from_row(rows.get(APP_SOURCE)), now, app_stale_after 513 [app_entry], _state_from_row(rows.get(APP_SOURCE)), now, app_stale_after
483 ) 514 )
484− counts[_apply(session, rows, service, APP_SOURCE, assessment, now)] += 1 515+ counts[_apply(session, rows, service, APP_SOURCE, assessment, now, seen=True)] += 1
485 516
486 for source, entries in by_source.items(): 517 for source, entries in by_source.items():
487 assessment = assess_source( 518 assessment = assess_source(
488 entries, _state_from_row(rows.get(source)), now, source_stale_after 519 entries, _state_from_row(rows.get(source)), now, source_stale_after
489 ) 520 )
490− counts[_apply(session, rows, service, source, assessment, now)] += 1 521+ counts[_apply(session, rows, service, source, assessment, now, seen=True)] += 1
491 522
492− # Sources connues mais absentes de la fenêtre courante : réévaluer la 523+ # Sources connues mais absentes de la fenêtre courante : retrait si
493− # staleness (le streak et la médiane restent inchangés). 524+ # désactivées par l'app ou disparues du journal depuis trop longtemps,
525+ # sinon réévaluer la staleness (streak et médiane inchangés).
494 for source in list(rows): 526 for source in list(rows):
495 if source == APP_SOURCE or source in by_source: 527 if source == APP_SOURCE or source in by_source:
496 continue 528 continue
497− assessment = assess_source( 529+ prev = _state_from_row(rows.get(source))
498− [], _state_from_row(rows.get(source)), now, source_stale_after 530+ retired_reason = None
499− ) 531+ if source in disabled:
532+ retired_reason = "désactivée par l'app (liste disabled du /api/stats)"
533+ elif prev.last_seen is not None and (
534+ now - prev.last_seen.timestamp() > RETIRED_AFTER_DAYS * 86400
535+ ):
536+ days = (now - prev.last_seen.timestamp()) / 86400
537+ retired_reason = (
538+ f"absente de tout journal de sync depuis {days:.0f} j "
539+ f"(seuil : {RETIRED_AFTER_DAYS} j)"
540+ )
541+ if retired_reason is not None:
542+ assessment = Assessment(
543+ status=STATUS_RETIRED,
544+ last_success=prev.last_success,
545+ found_last=prev.found_last,
546+ median_found=prev.median_found,
547+ consecutive_failures=prev.consecutive_failures,
548+ message=f"retirée de la supervision — {retired_reason}",
549+ )
550+ else:
551+ assessment = assess_source([], prev, now, source_stale_after)
500 counts[_apply(session, rows, service, source, assessment, now)] += 1 552 counts[_apply(session, rows, service, source, assessment, now)] += 1
501 553
502 return { 554 return {
modified tests/test_connector_health.py +1 −0
@@ -389,4 +389,5 @@ def test_endpoints_monitoring(clean_connector_table):
389 "degraded": 0, 389 "degraded": 0,
390 "broken": 1, 390 "broken": 1,
391 "stale": 0, 391 "stale": 0,
392+ "retired": 0,
392 } 393 }
393 394
Menu

Theme