Skip to content

Commit d16d4eb

Browse files
committed
worldmonitor patterns: circuit breaker + refresh scheduler + progressive hydration
- TypeScript CircuitBreaker (dashboard/lib/core/circuit-breaker.ts) with cooldown, SWR, LRU eviction, IndexedDB persistence (16 tests) - Python CircuitBreaker (backend/utils/circuit_breaker.py) Redis-backed for arq workers (9 tests) - RefreshScheduler (dashboard/lib/core/refresh-scheduler.ts) merged poll loop + scheduler with dedup, backoff, visibility awareness (13 tests) - Wire breakers into 4 arq jobs: review_poll, competitor_watch, menu_sync, appointment_followup - Wire RefreshScheduler into DashboardShell via React context - Wire breakers into dashboard api.get() per-path - Progressive hydration: lazy-load CompetitorTab via React.lazy + Suspense - All 59 dashboard + 246 backend tests pass, Next.js build green
1 parent 3e6cb82 commit d16d4eb

21 files changed

Lines changed: 2583 additions & 209 deletions

backend/jobs/appointment_followup.py

Lines changed: 55 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414

1515
from db import get_db
1616
from services.claude import generate_followup_message
17+
from utils.circuit_breaker import CircuitBreaker
1718
from services import cliniko as cliniko_adapter
1819
from services import square_appointments as square_adapter
1920
from services import nookal as nookal_adapter
@@ -41,6 +42,20 @@
4142

4243
# All seven booking adapters. Stub adapters (hotdoc/jane/practicepal) register
4344
# with SUPPORTS_REBOOK=False so the job fails safe instead of 404ing.
45+
46+
# Module-level breaker for Claude follow-up message generation
47+
_claude_breaker: CircuitBreaker | None = None
48+
49+
50+
def _get_followup_breaker(redis) -> CircuitBreaker:
51+
global _claude_breaker
52+
if _claude_breaker is None:
53+
_claude_breaker = CircuitBreaker(
54+
"claude-followup", redis, max_failures=2, cooldown_seconds=300, cache_ttl_seconds=0,
55+
)
56+
return _claude_breaker
57+
58+
4459
_BOOKING_ADAPTERS = {
4560
"cliniko": cliniko_adapter,
4661
"square": square_adapter,
@@ -257,7 +272,7 @@ async def run_appointment_followup_all_clients(arq_pool=None) -> None: # type:
257272

258273
for patient in lapsed:
259274
try:
260-
await _process_lapsed_patient(db, client, patient, arq_pool)
275+
await _process_lapsed_patient(db, client, patient, arq_pool, redis=arq_pool)
261276
except Exception as e:
262277
logger.error(
263278
"Follow-up failed for patient %s (client %s): %s",
@@ -272,6 +287,7 @@ async def _process_lapsed_patient(
272287
client: dict,
273288
patient: dict,
274289
arq_pool=None, # type: ignore[no-untyped-def]
290+
redis=None,
275291
) -> None:
276292
"""Check do-not-contact, generate message, send SMS, log to appointments table.
277293
@@ -282,6 +298,9 @@ async def _process_lapsed_patient(
282298
``practitioner_name`` and ``claim_type`` into message generation, routes the SMS
283299
through the durable ``send_sms_task`` (C4), and writes followup/practitioner/
284300
claim columns to ``appointments`` (columns added in migration 016).
301+
302+
Pass *redis* to enable circuit breaker protection on the Claude message
303+
generation call.
285304
"""
286305
booking_system = client.get("booking_system", "cliniko")
287306
adapter = get_booking_adapter(booking_system)
@@ -354,15 +373,42 @@ async def _process_lapsed_patient(
354373
return
355374

356375
# --- Message generation (thread practitioner_name + claim_type) ---
376+
# Wrap Claude call in circuit breaker (outer layer); @retry_on_failure
377+
# is the inner layer applied inside generate_followup_message.
357378
claim = patient.get("claim") or {}
358-
message = await generate_followup_message(
359-
patient_name=patient.get("patient_name", "Patient"),
360-
last_treatment=patient.get("treatment_type", "treatment"),
361-
business_name=client.get("business_name", ""),
362-
channel="sms",
363-
practitioner_name=patient.get("practitioner_name"),
364-
claim_type=claim.get("type"),
365-
)
379+
try:
380+
if redis is not None:
381+
breaker = _get_followup_breaker(redis)
382+
message = await breaker.execute(
383+
fn=lambda: generate_followup_message(
384+
patient_name=patient.get("patient_name", "Patient"),
385+
last_treatment=patient.get("treatment_type", "treatment"),
386+
business_name=client.get("business_name", ""),
387+
channel="sms",
388+
practitioner_name=patient.get("practitioner_name"),
389+
claim_type=claim.get("type"),
390+
),
391+
default_value="",
392+
)
393+
if not message:
394+
# Breaker returned default — log and skip SMS
395+
logger.warning(
396+
"Claude breaker on cooldown for patient %s — skipping message generation",
397+
patient_id,
398+
)
399+
return
400+
else:
401+
message = await generate_followup_message(
402+
patient_name=patient.get("patient_name", "Patient"),
403+
last_treatment=patient.get("treatment_type", "treatment"),
404+
business_name=client.get("business_name", ""),
405+
channel="sms",
406+
practitioner_name=patient.get("practitioner_name"),
407+
claim_type=claim.get("type"),
408+
)
409+
except Exception as e:
410+
logger.error("generate_followup_message failed for patient %s: %s", patient_id, e)
411+
return
366412

367413
sid, sent, error = await _enqueue_sms(arq_pool, phone, message, client.get("state", "NSW"))
368414

backend/jobs/competitor_watch.py

Lines changed: 62 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -13,12 +13,51 @@
1313
diff_structured,
1414
)
1515
from utils.retry import retry_on_failure
16+
from utils.circuit_breaker import CircuitBreaker
1617

1718
logger = logging.getLogger(__name__)
1819

20+
# Module-level breaker caches
21+
_snapshot_breakers: dict[str, CircuitBreaker] = {}
22+
_brief_breaker: CircuitBreaker | None = None
23+
24+
25+
def _get_snapshot_breaker(redis, url: str) -> CircuitBreaker:
26+
name = f"competitor-snapshot-{hashlib.md5(url.encode()).hexdigest()[:12]}"
27+
if name not in _snapshot_breakers:
28+
_snapshot_breakers[name] = CircuitBreaker(
29+
name, redis, max_failures=2, cooldown_seconds=300, cache_ttl_seconds=3600,
30+
)
31+
return _snapshot_breakers[name]
32+
33+
34+
def _get_brief_breaker(redis) -> CircuitBreaker:
35+
global _brief_breaker
36+
if _brief_breaker is None:
37+
_brief_breaker = CircuitBreaker(
38+
"claude-competitor-brief", redis, max_failures=2, cooldown_seconds=300, cache_ttl_seconds=0,
39+
)
40+
return _brief_breaker
41+
42+
43+
async def snapshot_website(redis, url: str) -> tuple[str, str, str]:
44+
"""Fetch a competitor URL with circuit breaker protection.
45+
46+
``@retry_on_failure`` remains on ``_raw_snapshot_website`` as the inner layer.
47+
"""
48+
if redis is None:
49+
return await _raw_snapshot_website(url)
50+
51+
breaker = _get_snapshot_breaker(redis, url)
52+
return await breaker.execute(
53+
fn=lambda: _raw_snapshot_website(url),
54+
default_value=("", "", ""),
55+
cache_key=url,
56+
)
57+
1958

2059
@retry_on_failure()
21-
async def snapshot_website(url: str) -> tuple[str, str, str]:
60+
async def _raw_snapshot_website(url: str) -> tuple[str, str, str]:
2261
"""Fetch a competitor URL, strip non-content elements, and return (md5_hash, clean_text, raw_html).
2362
2463
The raw HTML is returned so the caller can run ``extract_structured`` on
@@ -69,7 +108,7 @@ def _format_structured_diff(diff: list[dict]) -> list[str]:
69108
return lines
70109

71110

72-
async def detect_changes(client_id: str, competitor_url: str) -> dict | None:
111+
async def detect_changes(redis, client_id: str, competitor_url: str) -> dict | None:
73112
"""Compare the latest snapshot against a fresh fetch.
74113
75114
Returns None when the content is unchanged or the fetch failed.
@@ -90,7 +129,7 @@ async def detect_changes(client_id: str, competitor_url: str) -> dict | None:
90129
.execute()
91130
)
92131

93-
new_hash, new_text, raw_html = await snapshot_website(competitor_url)
132+
new_hash, new_text, raw_html = await snapshot_website(redis, competitor_url)
94133
if not new_hash:
95134
return None
96135

@@ -143,12 +182,27 @@ def _parse_threat_level(brief: str) -> str:
143182
return "MEDIUM"
144183

145184

185+
async def _generate_brief_safe(redis, business_name: str, changes_summary: str) -> str:
186+
"""Generate competitor brief with circuit breaker + retry protection.
187+
188+
``@retry_on_failure`` remains on ``_raw_generate_brief`` as the inner layer.
189+
"""
190+
if redis is None:
191+
return await _raw_generate_brief(business_name, changes_summary)
192+
193+
breaker = _get_brief_breaker(redis)
194+
return await breaker.execute(
195+
fn=lambda: _raw_generate_brief(business_name, changes_summary),
196+
default_value="",
197+
)
198+
199+
146200
@retry_on_failure()
147-
async def _generate_brief_safe(business_name: str, changes_summary: str) -> str:
201+
async def _raw_generate_brief(business_name: str, changes_summary: str) -> str:
148202
return await generate_competitor_brief(business_name, changes_summary)
149203

150204

151-
async def run_competitor_snapshots_all_clients() -> None:
205+
async def run_competitor_snapshots_all_clients(ctx: dict | None = None) -> None:
152206
"""APScheduler job — runs Sunday 10pm AEST.
153207
154208
For every client with ``'competitor_watch'`` in ``active_jobs``:
@@ -164,6 +218,7 @@ async def run_competitor_snapshots_all_clients() -> None:
164218
Each client is wrapped in its own try/except so one failure never crashes
165219
the entire job run.
166220
"""
221+
redis = ctx.get("redis") if ctx else None
167222
db = get_db()
168223

169224
resp = (
@@ -194,7 +249,7 @@ async def run_competitor_snapshots_all_clients() -> None:
194249
try:
195250
changes = []
196251
for url in competitor_urls:
197-
result = await detect_changes(client_id, url)
252+
result = await detect_changes(redis, client_id, url)
198253
if result is not None:
199254
changes.append({"url": url, **result})
200255

@@ -220,7 +275,7 @@ async def run_competitor_snapshots_all_clients() -> None:
220275
)
221276
changes_summary = "\n\n".join(parts)
222277

223-
brief = await _generate_brief_safe(business_name, changes_summary)
278+
brief = await _generate_brief_safe(redis, business_name, changes_summary)
224279
threat_level = _parse_threat_level(brief)
225280

226281
for c in changes:

backend/jobs/menu_sync.py

Lines changed: 60 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
import httpx
1616

1717
from db import get_db
18+
from utils.circuit_breaker import CircuitBreaker
1819

1920
logger = logging.getLogger(__name__)
2021

@@ -24,6 +25,28 @@
2425
RETRY_MAX_ATTEMPTS = 2
2526
RETRY_DELAY_SECONDS = 5
2627

28+
# Module-level breaker caches
29+
_square_breakers: dict[str, CircuitBreaker] = {}
30+
_gbp_breakers: dict[str, CircuitBreaker] = {}
31+
32+
33+
def _get_square_breaker(redis, location_id: str) -> CircuitBreaker:
34+
name = f"square-catalog-{location_id}"
35+
if name not in _square_breakers:
36+
_square_breakers[name] = CircuitBreaker(
37+
name, redis, max_failures=2, cooldown_seconds=300, cache_ttl_seconds=0,
38+
)
39+
return _square_breakers[name]
40+
41+
42+
def _get_gbp_breaker(redis, location_id: str) -> CircuitBreaker:
43+
name = f"gbp-menu-{location_id}"
44+
if name not in _gbp_breakers:
45+
_gbp_breakers[name] = CircuitBreaker(
46+
name, redis, max_failures=2, cooldown_seconds=300, cache_ttl_seconds=0,
47+
)
48+
return _gbp_breakers[name]
49+
2750

2851
# ---------------------------------------------------------------------------
2952
# Content hash + canonical store
@@ -120,9 +143,13 @@ async def _retry_push(label: str, coro_fn):
120143

121144

122145
async def _push_to_square(
123-
location: dict, client: dict, menu_item: dict, link: dict | None
146+
redis, location: dict, client: dict, menu_item: dict, link: dict | None
124147
) -> dict:
125-
"""Push a menu item to Square catalog via per-client OAuth token."""
148+
"""Push a menu item to Square catalog via per-client OAuth token.
149+
150+
Circuit breaker wraps the outbound Square API call. ``_retry_push``
151+
remains as the inner layer (transient 5xx retries).
152+
"""
126153
from services.square_oauth import get_valid_token
127154
from services.square_catalog import upsert_item as square_upsert
128155

@@ -153,13 +180,23 @@ async def _do():
153180
"external_version": result.get("version"),
154181
}
155182

156-
return await _retry_push("Square", _do)
183+
if redis is None:
184+
return await _retry_push("Square", _do)
185+
186+
breaker = _get_square_breaker(redis, location.get("id", "unknown"))
187+
return await breaker.execute(
188+
fn=lambda: _retry_push("Square", _do),
189+
default_value={"synced": False, "message": "Circuit breaker: Square temporarily unavailable"},
190+
)
157191

158192

159193
async def _push_to_gbp(
160-
location: dict, client: dict, menu_item: dict, link: dict | None
194+
redis, location: dict, client: dict, menu_item: dict, link: dict | None
161195
) -> dict:
162-
"""Push a menu item to GBP Menu API v1 using location's gbp_account_id."""
196+
"""Push a menu item to GBP Menu API v1 using location's gbp_account_id.
197+
198+
Circuit breaker wraps the outbound GBP API call.
199+
"""
163200
from services.crypto import decrypt
164201

165202
account_id = location.get("gbp_account_id")
@@ -191,14 +228,22 @@ async def _do():
191228
resp.raise_for_status()
192229
return {"synced": True, "message": "Synced to GBP"}
193230

194-
return await _retry_push("GBP", _do)
231+
if redis is None:
232+
return await _retry_push("GBP", _do)
233+
234+
breaker = _get_gbp_breaker(redis, location.get("id", "unknown"))
235+
return await breaker.execute(
236+
fn=lambda: _retry_push("GBP", _do),
237+
default_value={"synced": False, "message": "Circuit breaker: GBP temporarily unavailable"},
238+
)
195239

196240

197241
async def reconcile_item(
198242
location: dict,
199243
client: dict,
200244
menu_item: dict,
201245
skip_platform: str | None = None,
246+
redis=None,
202247
) -> dict:
203248
"""Reconcile a canonical menu item to its configured sync targets.
204249
@@ -241,9 +286,9 @@ async def reconcile_item(
241286

242287
# Push to platform
243288
if target == "square":
244-
result = await _push_to_square(location, client, menu_item, link)
289+
result = await _push_to_square(redis, location, client, menu_item, link)
245290
elif target == "gbp":
246-
result = await _push_to_gbp(location, client, menu_item, link)
291+
result = await _push_to_gbp(redis, location, client, menu_item, link)
247292
elif target == "website":
248293
result = {"synced": False, "message": "Website CMS sync not yet implemented"}
249294
elif target in ("ubereats", "doordash", "lightspeed"):
@@ -317,12 +362,16 @@ async def sync_menu_item(
317362
location_id: str | None = None,
318363
item: dict | None = None,
319364
origin: str = "sheets",
365+
redis=None,
320366
) -> dict:
321367
"""Sync a menu item: upsert canonical + reconcile to targets.
322368
323369
New signature: ``sync_menu_item(client_id, location_id, item, origin='sheets')``.
324370
Backward-compat: ``sync_menu_item(client_id, item)`` resolves the client's
325371
``is_default`` location.
372+
373+
Pass *redis* (arq's ``ctx["redis"]``) to enable circuit breaker protection
374+
on outbound Square/GBP calls.
326375
"""
327376
# Backward-compat: sync_menu_item(client_id, item) — 2 positional args
328377
if item is None and location_id is not None:
@@ -383,7 +432,7 @@ async def sync_menu_item(
383432
menu_item = await upsert_canonical(client_id, location["id"], item, origin)
384433

385434
# Reconcile
386-
result = await reconcile_item(location, client, menu_item)
435+
result = await reconcile_item(location, client, menu_item, redis=redis)
387436

388437
return {"status": "completed", "targets": result.get("targets", {})}
389438

@@ -392,7 +441,7 @@ async def sync_menu_item(
392441
# Square inbound (bi-directional)
393442
# ---------------------------------------------------------------------------
394443

395-
async def apply_square_inbound(client_id: str, changed_objects: list[dict]) -> dict:
444+
async def apply_square_inbound(client_id: str, changed_objects: list[dict], redis=None) -> dict:
396445
"""Apply Square catalog changes to the canonical store.
397446
398447
For each changed Square ITEM object:
@@ -475,7 +524,7 @@ async def apply_square_inbound(client_id: str, changed_objects: list[dict]) -> d
475524
.execute()
476525
)
477526
if loc_resp.data and client_resp.data:
478-
await reconcile_item(loc_resp.data, client_resp.data, updated_item, skip_platform="square")
527+
await reconcile_item(loc_resp.data, client_resp.data, updated_item, skip_platform="square", redis=redis)
479528

480529
applied += 1
481530

0 commit comments

Comments
 (0)