Skip to content

Commit fa9f245

Browse files
authored
Add automatic match completion service with scheduled job (#90)
- Add MatchCompletionService to auto-complete matches 30 minutes after call starts - Integrate APScheduler to run completion job every 15 minutes - Mark completed matches as soft-deleted (deleted_at set) - Add apscheduler dependency to pyproject.toml - Job runs at :00, :15, :30, :45 every hour - Idempotent design - safe to run multiple times ## Notion ticket link <!-- Please replace with your ticket's URL --> [Ticket Name](https://www.notion.so/uwblueprintexecs/Task-Board-db95cd7b93f245f78ee85e3a8a6a316d) <!-- Give a quick summary of the implementation details, provide design justifications if necessary --> ## Implementation description * <!-- What should the reviewer do to verify your changes? Describe expected results and include screenshots when appropriate --> ## Steps to test 1. <!-- Draw attention to the substantial parts of your PR or anything you'd like a second opinion on --> ## What should reviewers focus on? * ## Checklist - [ ] My PR name is descriptive and in imperative tense - [ ] My commit messages are descriptive and in imperative tense. My commits are atomic and trivial commits are squashed or fixup'd into non-trivial commits - [ ] I have run the appropriate linter(s) - [ ] I have requested a review from the PL, as well as other devs who have background knowledge on this PR or who will be building on top of this PR
1 parent 4df6a78 commit fa9f245

File tree

4 files changed

+159
-1
lines changed

4 files changed

+159
-1
lines changed

backend/app/server.py

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
from contextlib import asynccontextmanager
33
from typing import Union
44

5+
from apscheduler.schedulers.background import BackgroundScheduler
56
from dotenv import load_dotenv
67
from fastapi import FastAPI
78
from fastapi.middleware.cors import CORSMiddleware
@@ -24,6 +25,7 @@
2425
user_data,
2526
volunteer_data,
2627
)
28+
from .services.implementations.match_completion_service import MatchCompletionService
2729
from .utilities.constants import LOGGER_NAME
2830
from .utilities.firebase_init import initialize_firebase
2931
from .utilities.ses.ses_init import ensure_ses_templates
@@ -55,7 +57,36 @@ async def lifespan(_: FastAPI):
5557
ensure_ses_templates()
5658
models.run_migrations()
5759
initialize_firebase()
60+
61+
# Initialize and start the background scheduler for match completion
62+
# IMPORTANT: This scheduler runs in-process. When using uvicorn with --reload or multiple
63+
# workers (--workers N), each process will spawn its own scheduler, causing duplicate job
64+
# execution. For production, either:
65+
# 1. Run with a single worker (recommended for this app): uvicorn app.server:app --workers 1
66+
# 2. Use a distributed task queue (Celery + Redis) for multi-worker deployments
67+
# 3. Use a distributed lock mechanism to ensure only one process runs the job
68+
# The auto-completion logic is idempotent, so duplicate runs are safe but wasteful.
69+
scheduler = BackgroundScheduler()
70+
match_completion_service = MatchCompletionService()
71+
72+
# Schedule match auto-completion job to run every 15 minutes at fixed times (:00, :15, :30, :45)
73+
scheduler.add_job(
74+
match_completion_service.auto_complete_matches,
75+
trigger="cron",
76+
minute="0,15,30,45", # Run at :00, :15, :30, :45 every hour
77+
id="auto_complete_matches",
78+
name="Auto-complete matches after scheduled calls",
79+
replace_existing=True,
80+
)
81+
82+
scheduler.start()
83+
log.info("Background scheduler started - match auto-completion job runs at :00, :15, :30, :45 every hour")
84+
5885
yield
86+
87+
# Shutdown scheduler gracefully
88+
log.info("Shutting down scheduler...")
89+
scheduler.shutdown()
5990
log.info("Shutting down...")
6091

6192

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
"""Service for automatically completing matches after their scheduled calls."""
2+
3+
import logging
4+
from datetime import datetime, timedelta, timezone
5+
6+
from sqlalchemy import select, update
7+
from sqlalchemy.orm import Session
8+
9+
from app.models.Match import Match
10+
from app.models.MatchStatus import MatchStatus
11+
from app.models.TimeBlock import TimeBlock
12+
from app.utilities.constants import LOGGER_NAME
13+
from app.utilities.db_utils import SessionLocal
14+
15+
16+
class MatchCompletionService:
17+
"""Service to automatically complete matches after their scheduled call time."""
18+
19+
def __init__(self):
20+
self.logger = logging.getLogger(LOGGER_NAME("match_completion_service"))
21+
22+
def auto_complete_matches(self) -> None:
23+
"""
24+
Find all confirmed matches where the scheduled call ended 30+ minutes ago
25+
and mark them as completed and soft-deleted.
26+
27+
Uses a set-based UPDATE query for efficiency and atomicity.
28+
This method is designed to be called periodically by the scheduler.
29+
It is idempotent - safe to run multiple times without side effects.
30+
"""
31+
db: Session = SessionLocal()
32+
33+
try:
34+
self.logger.info("Starting auto-completion job for matches")
35+
36+
# Calculate the cutoff time (current time - 30 minutes)
37+
now = datetime.now(timezone.utc)
38+
cutoff_time = now - timedelta(minutes=30)
39+
40+
# Get the "completed" and "confirmed" status IDs
41+
completed_status = db.query(MatchStatus).filter(MatchStatus.name == "completed").first()
42+
confirmed_status = db.query(MatchStatus).filter(MatchStatus.name == "confirmed").first()
43+
44+
if not completed_status or not confirmed_status:
45+
self.logger.error("Required match statuses not found in database")
46+
return
47+
48+
# Build subquery to select qualifying time blocks once
49+
timeblock_subquery = select(TimeBlock.id).where(TimeBlock.start_time < cutoff_time)
50+
51+
# Execute set-based UPDATE with returning clause so logging knows which rows changed
52+
stmt = (
53+
update(Match)
54+
.where(
55+
Match.deleted_at.is_(None),
56+
Match.match_status_id == confirmed_status.id,
57+
Match.chosen_time_block_id.isnot(None),
58+
Match.chosen_time_block_id.in_(timeblock_subquery),
59+
)
60+
.values(match_status_id=completed_status.id, deleted_at=now)
61+
.returning(Match.id, Match.participant_id, Match.volunteer_id)
62+
)
63+
64+
result = db.execute(stmt)
65+
db.commit()
66+
67+
completed_matches = result.fetchall()
68+
if not completed_matches:
69+
self.logger.info("No matches found that need auto-completion")
70+
return
71+
72+
# Log each completed match for audit trail
73+
for match in completed_matches:
74+
self.logger.info(
75+
f"Auto-completed match {match.id} "
76+
f"(participant: {match.participant_id}, volunteer: {match.volunteer_id})"
77+
)
78+
79+
self.logger.info(f"Successfully auto-completed {len(completed_matches)} match(es)")
80+
81+
except Exception as e:
82+
db.rollback()
83+
self.logger.error(f"Error in auto_complete_matches job: {str(e)}", exc_info=True)
84+
finally:
85+
db.close()

backend/pdm.lock

Lines changed: 42 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

backend/pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ dependencies = [
2020
"boto3>=1.35.71",
2121
"pytest-asyncio>=0.25.3",
2222
"psycopg2-binary>=2.9.10",
23+
"apscheduler>=3.10.4",
2324
]
2425
requires-python = "==3.12.*"
2526
readme = "README.md"

0 commit comments

Comments
 (0)