Public Access
feat: orchestrator step_scrape with retry + stale-data fallback
Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,62 @@
|
|||||||
|
"""
|
||||||
|
Orchestrator step functions.
|
||||||
|
|
||||||
|
Each step receives a WeeklyRun row and an open Session. Steps are idempotent:
|
||||||
|
a non-null timestamp column causes an immediate return. Steps commit their own
|
||||||
|
changes before returning.
|
||||||
|
|
||||||
|
All service imports are at module level so tests can monkeypatch via
|
||||||
|
`app.services.orchestrator.steps.<name>`.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
from app.config import settings
|
||||||
|
from app.models import FamilyMember, FamilyProfile, MealPlan, MealPlanItemStatus
|
||||||
|
from app.services.approval import issue_token
|
||||||
|
from app.services.email import get_email_backend
|
||||||
|
from app.services.orchestrator.alerts import send_admin_alert
|
||||||
|
from app.services.planner.generate import generate_meal_plan
|
||||||
|
from app.services.scraper_service import ScraperService
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
from app.models import WeeklyRun
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def step_scrape(run: "WeeklyRun", db: "Session") -> None:
|
||||||
|
if run.scraped_at is not None:
|
||||||
|
logger.info("step_scrape: already done for %s, skipping", run.week_start_date)
|
||||||
|
return
|
||||||
|
|
||||||
|
service = ScraperService(db)
|
||||||
|
last_result: dict = {}
|
||||||
|
for attempt in (1, 2):
|
||||||
|
last_result = service.run_scrape()
|
||||||
|
if last_result["status"] == "success":
|
||||||
|
run.scraped_at = datetime.now(timezone.utc)
|
||||||
|
run.used_stale_data = False
|
||||||
|
db.commit()
|
||||||
|
logger.info("step_scrape: success on attempt %d", attempt)
|
||||||
|
return
|
||||||
|
logger.warning(
|
||||||
|
"step_scrape: attempt %d failed: %s", attempt, last_result.get("error")
|
||||||
|
)
|
||||||
|
|
||||||
|
# Both attempts failed — mark stale and continue the cycle
|
||||||
|
run.scraped_at = datetime.now(timezone.utc)
|
||||||
|
run.used_stale_data = True
|
||||||
|
db.commit()
|
||||||
|
send_admin_alert(
|
||||||
|
subject=f"Scrape failed week {run.week_start_date}",
|
||||||
|
body=(
|
||||||
|
f"Both scrape attempts failed. Proceeding with stale grocery data.\n"
|
||||||
|
f"Error: {last_result.get('error')}"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
logger.warning("step_scrape: both attempts failed; marked stale")
|
||||||
@@ -168,3 +168,40 @@ def test_send_admin_alert_calls_backend(monkeypatch):
|
|||||||
assert len(sent) == 1
|
assert len(sent) == 1
|
||||||
assert "[MealPlanner ALERT] boom" in sent[0]["subject"]
|
assert "[MealPlanner ALERT] boom" in sent[0]["subject"]
|
||||||
assert sent[0]["to"] == "admin@example.com"
|
assert sent[0]["to"] == "admin@example.com"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# step_scrape tests
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def test_step_scrape_idempotent(db, weekly_run_scraped):
|
||||||
|
original_ts = weekly_run_scraped.scraped_at
|
||||||
|
from app.services.orchestrator.steps import step_scrape
|
||||||
|
step_scrape(weekly_run_scraped, db)
|
||||||
|
assert weekly_run_scraped.scraped_at == original_ts
|
||||||
|
|
||||||
|
|
||||||
|
def test_step_scrape_success(db, weekly_run, monkeypatch):
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"app.services.orchestrator.steps.ScraperService.run_scrape",
|
||||||
|
lambda self, **kw: {"status": "success", "items_scraped": 42},
|
||||||
|
)
|
||||||
|
from app.services.orchestrator.steps import step_scrape
|
||||||
|
step_scrape(weekly_run, db)
|
||||||
|
assert weekly_run.scraped_at is not None
|
||||||
|
assert weekly_run.used_stale_data is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_step_scrape_both_fail_marks_stale(db, weekly_run, monkeypatch):
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"app.services.orchestrator.steps.ScraperService.run_scrape",
|
||||||
|
lambda self, **kw: {"status": "failed", "error": "timeout"},
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"app.services.orchestrator.steps.send_admin_alert",
|
||||||
|
lambda subject, body: None,
|
||||||
|
)
|
||||||
|
from app.services.orchestrator.steps import step_scrape
|
||||||
|
step_scrape(weekly_run, db)
|
||||||
|
assert weekly_run.used_stale_data is True
|
||||||
|
assert weekly_run.scraped_at is not None
|
||||||
|
|||||||
Reference in New Issue
Block a user