prathvi9607 commited on
Commit
62f312f
·
verified ·
1 Parent(s): 31e8a9f

Fix wake-up race: commit dispatch mark before publishing

Browse files
Files changed (3) hide show
  1. app.py +26 -4
  2. backend/app/api/routes/reviews.py +119 -115
  3. backend/app/worker/outbox.py +112 -93
app.py CHANGED
@@ -58,8 +58,21 @@ def _log(message: str) -> None:
58
  def start_redis() -> subprocess.Popen[bytes]:
59
  Path("/tmp/redis").mkdir(exist_ok=True)
60
  proc = subprocess.Popen(
61
- ["redis-server", "--bind", "127.0.0.1", "--protected-mode", "yes", "--port", "6379",
62
- "--dir", "/tmp/redis", "--save", "", "--appendonly", "no"]
 
 
 
 
 
 
 
 
 
 
 
 
 
63
  )
64
  for _ in range(30):
65
  ping = subprocess.run(["redis-cli", "-h", "127.0.0.1", "ping"], capture_output=True)
@@ -79,8 +92,17 @@ def run_migrations() -> None:
79
 
80
  def start_worker() -> subprocess.Popen[bytes]:
81
  return subprocess.Popen(
82
- [sys.executable, "-m", "celery", "-A", "app.worker.celery_app", "worker",
83
- "--pool=solo", "--concurrency=1", "--loglevel=info"]
 
 
 
 
 
 
 
 
 
84
  )
85
 
86
 
 
58
  def start_redis() -> subprocess.Popen[bytes]:
59
  Path("/tmp/redis").mkdir(exist_ok=True)
60
  proc = subprocess.Popen(
61
+ [
62
+ "redis-server",
63
+ "--bind",
64
+ "127.0.0.1",
65
+ "--protected-mode",
66
+ "yes",
67
+ "--port",
68
+ "6379",
69
+ "--dir",
70
+ "/tmp/redis",
71
+ "--save",
72
+ "",
73
+ "--appendonly",
74
+ "no",
75
+ ]
76
  )
77
  for _ in range(30):
78
  ping = subprocess.run(["redis-cli", "-h", "127.0.0.1", "ping"], capture_output=True)
 
92
 
93
  def start_worker() -> subprocess.Popen[bytes]:
94
  return subprocess.Popen(
95
+ [
96
+ sys.executable,
97
+ "-m",
98
+ "celery",
99
+ "-A",
100
+ "app.worker.celery_app",
101
+ "worker",
102
+ "--pool=solo",
103
+ "--concurrency=1",
104
+ "--loglevel=info",
105
+ ]
106
  )
107
 
108
 
backend/app/api/routes/reviews.py CHANGED
@@ -1,115 +1,119 @@
1
- """Review submission and polling routes (PRD §14.1–14.2)."""
2
-
3
- from __future__ import annotations
4
-
5
- import logging
6
- from datetime import UTC, datetime
7
-
8
- from fastapi import APIRouter, Depends, HTTPException, status
9
- from sqlalchemy.exc import IntegrityError
10
- from sqlalchemy.orm import Session
11
-
12
- from app.api.deps import get_db
13
- from app.models.job import Job, JobKind
14
- from app.models.product import Product
15
- from app.models.review import Review, ReviewStatus
16
- from app.schemas.review import ReviewCreate, ReviewResponse, ReviewTaskStatus
17
- from app.worker.outbox import send_wakeup
18
-
19
- logger = logging.getLogger(__name__)
20
- router = APIRouter(prefix="/api/reviews", tags=["reviews"])
21
-
22
- STATUS_MESSAGES: dict[str, str] = {
23
- ReviewStatus.PENDING.value: "Review is queued for processing",
24
- ReviewStatus.PROCESSING.value: "Review is being processed",
25
- ReviewStatus.DONE.value: "Review processing completed",
26
- ReviewStatus.FAILED.value: "Review processing failed",
27
- }
28
-
29
-
30
- def _get_review_or_404(db: Session, review_id: int) -> Review:
31
- review = db.get(Review, review_id)
32
- if review is None:
33
- raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Review not found")
34
- return review
35
-
36
-
37
- def _ensure_product_shell(db: Session, asin: str, title: str | None) -> None:
38
- """Create or update the product row without losing concurrent first reviews."""
39
- product = db.get(Product, asin)
40
- if product is None:
41
- try:
42
- # The savepoint lets a concurrent ASIN insert fail locally while
43
- # preserving the review/job transaction that follows it.
44
- with db.begin_nested():
45
- db.add(Product(asin=asin, title=title))
46
- db.flush()
47
- except IntegrityError:
48
- product = db.get(Product, asin)
49
- if product is None:
50
- raise
51
- if product is not None and title and not product.title:
52
- product.title = title
53
-
54
-
55
- @router.post("", response_model=ReviewTaskStatus, status_code=status.HTTP_202_ACCEPTED)
56
- def submit_review(payload: ReviewCreate, db: Session = Depends(get_db)) -> ReviewTaskStatus:
57
- """Persist a review and its processing job atomically; return for polling.
58
-
59
- The product shell is created first because ``reviews.product_asin`` is a
60
- foreign key (the PRD's schema made every first review of an ASIN fail).
61
- After commit a wake-up is sent to Redis; if Redis is down the job is still
62
- durable and the outbox dispatcher delivers it later, so this never 503s.
63
- """
64
- asin = payload.product_asin
65
- if asin:
66
- _ensure_product_shell(db, asin, payload.product_title)
67
-
68
- review = Review(
69
- text=payload.text,
70
- star_rating=payload.star_rating,
71
- helpful_votes=payload.helpful_votes,
72
- verified_purchase=payload.verified_purchase,
73
- product_title=payload.product_title,
74
- product_asin=asin,
75
- status=ReviewStatus.PENDING.value,
76
- )
77
- db.add(review)
78
- db.flush() # assigns review.id
79
- job = Job(kind=JobKind.REVIEW.value, review_id=review.id)
80
- db.add(job)
81
- db.commit()
82
-
83
- try:
84
- send_wakeup(job.id)
85
- job.last_dispatched_at = datetime.now(UTC)
86
- db.commit()
87
- except Exception:
88
- db.rollback()
89
- logger.warning("Broker unavailable for job %s; outbox will retry", job.id, exc_info=True)
90
-
91
- return ReviewTaskStatus(
92
- review_id=review.id,
93
- task_id=job.id,
94
- status=ReviewStatus.PENDING.value,
95
- message="Review queued for processing",
96
- )
97
-
98
-
99
- @router.get("/{review_id}", response_model=ReviewResponse)
100
- def get_review(review_id: int, db: Session = Depends(get_db)) -> Review:
101
- """Get a review and, when available, its pipeline output."""
102
- return _get_review_or_404(db, review_id)
103
-
104
-
105
- @router.get("/{review_id}/status", response_model=ReviewTaskStatus)
106
- def get_review_status(review_id: int, db: Session = Depends(get_db)) -> ReviewTaskStatus:
107
- """Lightweight lifecycle state for polling clients."""
108
- review = _get_review_or_404(db, review_id)
109
- job_id = db.query(Job.id).filter(Job.review_id == review.id).scalar()
110
- return ReviewTaskStatus(
111
- review_id=review.id,
112
- task_id=job_id,
113
- status=review.status,
114
- message=STATUS_MESSAGES.get(review.status, "Unknown status"),
115
- )
 
 
 
 
 
1
+ """Review submission and polling routes (PRD §14.1–14.2)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import contextlib
6
+ import logging
7
+ from datetime import UTC, datetime
8
+
9
+ from fastapi import APIRouter, Depends, HTTPException, status
10
+ from sqlalchemy.exc import IntegrityError
11
+ from sqlalchemy.orm import Session
12
+
13
+ from app.api.deps import get_db
14
+ from app.models.job import Job, JobKind
15
+ from app.models.product import Product
16
+ from app.models.review import Review, ReviewStatus
17
+ from app.schemas.review import ReviewCreate, ReviewResponse, ReviewTaskStatus
18
+ from app.worker.outbox import send_wakeup
19
+
20
+ logger = logging.getLogger(__name__)
21
+ router = APIRouter(prefix="/api/reviews", tags=["reviews"])
22
+
23
+ STATUS_MESSAGES: dict[str, str] = {
24
+ ReviewStatus.PENDING.value: "Review is queued for processing",
25
+ ReviewStatus.PROCESSING.value: "Review is being processed",
26
+ ReviewStatus.DONE.value: "Review processing completed",
27
+ ReviewStatus.FAILED.value: "Review processing failed",
28
+ }
29
+
30
+
31
+ def _get_review_or_404(db: Session, review_id: int) -> Review:
32
+ review = db.get(Review, review_id)
33
+ if review is None:
34
+ raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Review not found")
35
+ return review
36
+
37
+
38
+ def _ensure_product_shell(db: Session, asin: str, title: str | None) -> None:
39
+ """Create or update the product row without losing concurrent first reviews."""
40
+ product = db.get(Product, asin)
41
+ if product is None:
42
+ try:
43
+ # The savepoint lets a concurrent ASIN insert fail locally while
44
+ # preserving the review/job transaction that follows it.
45
+ with db.begin_nested():
46
+ db.add(Product(asin=asin, title=title))
47
+ db.flush()
48
+ except IntegrityError:
49
+ product = db.get(Product, asin)
50
+ if product is None:
51
+ raise
52
+ if product is not None and title and not product.title:
53
+ product.title = title
54
+
55
+
56
+ @router.post("", response_model=ReviewTaskStatus, status_code=status.HTTP_202_ACCEPTED)
57
+ def submit_review(payload: ReviewCreate, db: Session = Depends(get_db)) -> ReviewTaskStatus:
58
+ """Persist a review and its processing job atomically; return for polling.
59
+
60
+ The product shell is created first because ``reviews.product_asin`` is a
61
+ foreign key (the PRD's schema made every first review of an ASIN fail).
62
+ After commit a wake-up is sent to Redis; if Redis is down the job is still
63
+ durable and the outbox dispatcher delivers it later, so this never 503s.
64
+ """
65
+ asin = payload.product_asin
66
+ if asin:
67
+ _ensure_product_shell(db, asin, payload.product_title)
68
+
69
+ review = Review(
70
+ text=payload.text,
71
+ star_rating=payload.star_rating,
72
+ helpful_votes=payload.helpful_votes,
73
+ verified_purchase=payload.verified_purchase,
74
+ product_title=payload.product_title,
75
+ product_asin=asin,
76
+ status=ReviewStatus.PENDING.value,
77
+ )
78
+ db.add(review)
79
+ db.flush() # assigns review.id
80
+ # Mark the job dispatched in the same commit, then publish. Updating the row after
81
+ # publishing would hold its lock while the worker (skip_locked) receives the message,
82
+ # so the worker would skip the job and it would wait for the outbox re-send.
83
+ job = Job(kind=JobKind.REVIEW.value, review_id=review.id, last_dispatched_at=datetime.now(UTC))
84
+ db.add(job)
85
+ db.commit()
86
+
87
+ try:
88
+ send_wakeup(job.id)
89
+ except Exception:
90
+ logger.warning("Broker unavailable for job %s; outbox will retry", job.id, exc_info=True)
91
+ with contextlib.suppress(Exception):
92
+ job.last_dispatched_at = None # let the outbox re-send on its next poll
93
+ db.commit()
94
+
95
+ return ReviewTaskStatus(
96
+ review_id=review.id,
97
+ task_id=job.id,
98
+ status=ReviewStatus.PENDING.value,
99
+ message="Review queued for processing",
100
+ )
101
+
102
+
103
+ @router.get("/{review_id}", response_model=ReviewResponse)
104
+ def get_review(review_id: int, db: Session = Depends(get_db)) -> Review:
105
+ """Get a review and, when available, its pipeline output."""
106
+ return _get_review_or_404(db, review_id)
107
+
108
+
109
+ @router.get("/{review_id}/status", response_model=ReviewTaskStatus)
110
+ def get_review_status(review_id: int, db: Session = Depends(get_db)) -> ReviewTaskStatus:
111
+ """Lightweight lifecycle state for polling clients."""
112
+ review = _get_review_or_404(db, review_id)
113
+ job_id = db.query(Job.id).filter(Job.review_id == review.id).scalar()
114
+ return ReviewTaskStatus(
115
+ review_id=review.id,
116
+ task_id=job_id,
117
+ status=review.status,
118
+ message=STATUS_MESSAGES.get(review.status, "Unknown status"),
119
+ )
backend/app/worker/outbox.py CHANGED
@@ -1,93 +1,112 @@
1
- """Outbox dispatcher: turns unfinished `jobs` rows into Celery wake-ups.
2
-
3
- Runs as a daemon thread inside the worker (started on ``worker_ready`` in
4
- :mod:`app.worker.celery_app`), so a free-tier deployment needs no extra process.
5
- It can also run standalone::
6
-
7
- cd backend && python -m app.worker.outbox
8
-
9
- A job is (re)sent when it is unfinished, due (``available_at <= now``), and was
10
- never dispatched or was dispatched more than :data:`REDISPATCH_AFTER` ago — this
11
- recovers messages lost to a Redis outage or a crashed worker. Importing this
12
- module never loads ML models.
13
- """
14
-
15
- from __future__ import annotations
16
-
17
- import contextlib
18
- import logging
19
- import threading
20
- from datetime import UTC, datetime, timedelta
21
- from typing import Final
22
-
23
- from sqlalchemy import or_
24
-
25
- from app.database import SessionLocal
26
- from app.models.job import Job
27
-
28
- logger = logging.getLogger(__name__)
29
-
30
- POLL_INTERVAL_SECONDS: Final = 5.0
31
- REDISPATCH_AFTER: Final = timedelta(seconds=60)
32
- RUN_JOB_TASK: Final = "vera.run_job"
33
-
34
-
35
- def send_wakeup(job_id: str) -> None:
36
- """Publish one wake-up message. Raises if the broker is unreachable."""
37
- from app.worker.celery_app import celery_app # lazy: avoid import cycle
38
-
39
- celery_app.send_task(RUN_JOB_TASK, args=[job_id], retry=False)
40
-
41
-
42
- def dispatch_pending(limit: int = 100) -> int:
43
- """Send wake-ups for due jobs. Returns the number sent."""
44
- now = datetime.now(UTC)
45
- sent = 0
46
- with SessionLocal() as db:
47
- jobs = (
48
- db.query(Job)
49
- .filter(
50
- Job.finished_at.is_(None),
51
- Job.available_at <= now,
52
- or_(
53
- Job.last_dispatched_at.is_(None),
54
- Job.last_dispatched_at < now - REDISPATCH_AFTER,
55
- ),
56
- )
57
- .order_by(Job.available_at, Job.id)
58
- .with_for_update(skip_locked=True)
59
- .limit(limit)
60
- .all()
61
- )
62
- for job in jobs:
63
- send_wakeup(job.id)
64
- job.last_dispatched_at = now
65
- sent += 1
66
- db.commit()
67
- return sent
68
-
69
-
70
- def run_forever(stop: threading.Event | None = None) -> None:
71
- """Poll until ``stop`` is set. Errors are logged; the loop never dies."""
72
- stop = stop or threading.Event()
73
- while not stop.is_set():
74
- try:
75
- count = dispatch_pending()
76
- if count:
77
- logger.info("Outbox dispatched %d job(s)", count)
78
- except Exception:
79
- logger.exception("Outbox dispatch failed; will retry")
80
- stop.wait(POLL_INTERVAL_SECONDS)
81
-
82
-
83
- def start_background_dispatcher() -> threading.Event:
84
- """Start :func:`run_forever` in a daemon thread; set the returned event to stop it."""
85
- stop = threading.Event()
86
- threading.Thread(target=run_forever, args=(stop,), name="vera-outbox", daemon=True).start()
87
- return stop
88
-
89
-
90
- if __name__ == "__main__":
91
- logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
92
- with contextlib.suppress(KeyboardInterrupt):
93
- run_forever()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Outbox dispatcher: turns unfinished `jobs` rows into Celery wake-ups.
2
+
3
+ Runs as a daemon thread inside the worker (started on ``worker_ready`` in
4
+ :mod:`app.worker.celery_app`), so a free-tier deployment needs no extra process.
5
+ It can also run standalone::
6
+
7
+ cd backend && python -m app.worker.outbox
8
+
9
+ A job is (re)sent when it is unfinished, due (``available_at <= now``), and was
10
+ never dispatched or was dispatched more than :data:`REDISPATCH_AFTER` ago — this
11
+ recovers messages lost to a Redis outage or a crashed worker. Importing this
12
+ module never loads ML models.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import contextlib
18
+ import logging
19
+ import threading
20
+ from datetime import UTC, datetime, timedelta
21
+ from typing import Final
22
+
23
+ from sqlalchemy import or_
24
+
25
+ from app.database import SessionLocal
26
+ from app.models.job import Job
27
+
28
+ logger = logging.getLogger(__name__)
29
+
30
+ POLL_INTERVAL_SECONDS: Final = 5.0
31
+ REDISPATCH_AFTER: Final = timedelta(seconds=60)
32
+ RUN_JOB_TASK: Final = "vera.run_job"
33
+
34
+
35
+ def send_wakeup(job_id: str) -> None:
36
+ """Publish one wake-up message. Raises if the broker is unreachable."""
37
+ from app.worker.celery_app import celery_app # lazy: avoid import cycle
38
+
39
+ celery_app.send_task(RUN_JOB_TASK, args=[job_id], retry=False)
40
+
41
+
42
+ def dispatch_pending(limit: int = 100) -> int:
43
+ """Send wake-ups for due jobs. Returns the number sent.
44
+
45
+ Jobs are marked dispatched and committed BEFORE publishing, so no row lock is held when
46
+ the worker receives the message (the worker skips locked rows). Jobs whose publish fails
47
+ are un-marked so the next poll retries them.
48
+ """
49
+ now = datetime.now(UTC)
50
+ with SessionLocal() as db:
51
+ jobs = (
52
+ db.query(Job)
53
+ .filter(
54
+ Job.finished_at.is_(None),
55
+ Job.available_at <= now,
56
+ or_(
57
+ Job.last_dispatched_at.is_(None),
58
+ Job.last_dispatched_at < now - REDISPATCH_AFTER,
59
+ ),
60
+ )
61
+ .order_by(Job.available_at, Job.id)
62
+ .with_for_update(skip_locked=True)
63
+ .limit(limit)
64
+ .all()
65
+ )
66
+ job_ids = [job.id for job in jobs]
67
+ for job in jobs:
68
+ job.last_dispatched_at = now
69
+ db.commit()
70
+
71
+ sent = 0
72
+ failed: list[str] = []
73
+ for job_id in job_ids:
74
+ try:
75
+ send_wakeup(job_id)
76
+ sent += 1
77
+ except Exception:
78
+ failed.append(job_id)
79
+ if failed:
80
+ with SessionLocal() as db:
81
+ db.query(Job).filter(Job.id.in_(failed), Job.last_dispatched_at == now).update(
82
+ {Job.last_dispatched_at: None}, synchronize_session=False
83
+ )
84
+ db.commit()
85
+ raise RuntimeError(f"Broker unavailable; {len(failed)} wake-up(s) not sent")
86
+ return sent
87
+
88
+
89
+ def run_forever(stop: threading.Event | None = None) -> None:
90
+ """Poll until ``stop`` is set. Errors are logged; the loop never dies."""
91
+ stop = stop or threading.Event()
92
+ while not stop.is_set():
93
+ try:
94
+ count = dispatch_pending()
95
+ if count:
96
+ logger.info("Outbox dispatched %d job(s)", count)
97
+ except Exception:
98
+ logger.exception("Outbox dispatch failed; will retry")
99
+ stop.wait(POLL_INTERVAL_SECONDS)
100
+
101
+
102
+ def start_background_dispatcher() -> threading.Event:
103
+ """Start :func:`run_forever` in a daemon thread; set the returned event to stop it."""
104
+ stop = threading.Event()
105
+ threading.Thread(target=run_forever, args=(stop,), name="vera-outbox", daemon=True).start()
106
+ return stop
107
+
108
+
109
+ if __name__ == "__main__":
110
+ logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
111
+ with contextlib.suppress(KeyboardInterrupt):
112
+ run_forever()