Skip to content

Commit 7cad7d7

Browse files
committed
linter and tests
1 parent 11d4941 commit 7cad7d7

File tree

5 files changed

+56
-65
lines changed

5 files changed

+56
-65
lines changed

backend/python/app/__init__.py

Lines changed: 10 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
1+
import asyncio
12
from collections.abc import AsyncGenerator
2-
from contextlib import asynccontextmanager
3+
from contextlib import asynccontextmanager, suppress
34
from logging.config import dictConfig
4-
import asyncio
55

66
import firebase_admin
77
from fastapi import FastAPI
@@ -122,7 +122,7 @@ def initialize_firebase() -> None:
122122
async def lifespan(_app: FastAPI) -> AsyncGenerator[None, None]:
123123
"""Application lifespan management"""
124124
global job_worker
125-
125+
126126
# Startup
127127
configure_logging()
128128
initialize_firebase()
@@ -132,28 +132,24 @@ async def lifespan(_app: FastAPI) -> AsyncGenerator[None, None]:
132132
scheduler_service = get_scheduler_service()
133133
scheduler_service.start()
134134
init_jobs(scheduler_service)
135-
135+
136136
job_worker = JobWorker(
137-
poll_interval=5,
138-
job_timeout_minutes=30,
139-
enable_orphan_recovery=True
137+
poll_interval=5, job_timeout_minutes=30, enable_orphan_recovery=True
140138
)
141-
139+
142140
worker_task = asyncio.create_task(job_worker.start())
143141

144142
yield
145143

146144
scheduler_service.stop()
147-
145+
148146
if job_worker:
149147
job_worker.stop()
150-
148+
151149
worker_task.cancel()
152-
try:
153-
await worker_task
154-
except asyncio.CancelledError:
150+
with suppress(asyncio.CancelledError):
155151
# Expected during shutdown - task was cancelled gracefully
156-
pass
152+
await worker_task
157153

158154

159155
def create_app() -> FastAPI:

backend/python/app/services/implementations/job_service.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -55,13 +55,13 @@ async def update_progress(self, job_id: UUID, progress: ProgressEnum) -> None:
5555
return
5656
job.progress = progress
5757
job.updated_at = self.utc_now_naive()
58-
58+
5959
if progress == ProgressEnum.RUNNING and job.started_at is None:
6060
job.started_at = self.utc_now_naive()
61-
61+
6262
if progress in (ProgressEnum.COMPLETED, ProgressEnum.FAILED):
6363
job.finished_at = self.utc_now_naive()
64-
64+
6565
self.session.add(job)
6666
await self.session.commit()
6767
except Exception as error:
Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
"""
22
Background job workers for processing async tasks.
33
"""
4+
45
from app.workers.job_worker import JobWorker
56

67
__all__ = ["JobWorker"]
7-

backend/python/app/workers/job_worker.py

Lines changed: 40 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -7,17 +7,16 @@
77
- Handles orphaned jobs on startup (jobs stuck in RUNNING state)
88
- Survives restarts - jobs persist in database
99
"""
10+
1011
import asyncio
1112
import logging
12-
from datetime import datetime, timedelta
1313
from uuid import UUID
1414

15-
from sqlalchemy.ext.asyncio import AsyncSession
1615
from sqlmodel import select
1716

1817
from app.models import get_session
19-
from app.models.job import Job
2018
from app.models.enum import ProgressEnum
19+
from app.models.job import Job
2120
from app.services.implementations.job_service import JobService
2221

2322
logger = logging.getLogger(__name__)
@@ -26,24 +25,24 @@
2625
class JobWorker:
2726
"""
2827
Worker that processes jobs from database queue.
29-
28+
3029
Flow:
3130
1. Poll database for QUEUED jobs
3231
2. Mark job as RUNNING
3332
3. Execute route generation
3433
4. Mark job as COMPLETED or FAILED
3534
5. Repeat
3635
"""
37-
36+
3837
def __init__(
3938
self,
4039
poll_interval: int = 5,
4140
job_timeout_minutes: int = 30,
42-
enable_orphan_recovery: bool = True
41+
enable_orphan_recovery: bool = True,
4342
):
4443
"""
4544
Initialize the job worker.
46-
45+
4746
Args:
4847
poll_interval: Seconds to wait between checking for new jobs
4948
job_timeout_minutes: Max time a job can run before considered stuck
@@ -52,153 +51,149 @@ def __init__(
5251
self.poll_interval = poll_interval
5352
self.job_timeout_minutes = job_timeout_minutes
5453
self.enable_orphan_recovery = enable_orphan_recovery
55-
54+
5655
self.running = False
5756
self.logger = logging.getLogger(__name__)
58-
57+
5958
async def start(self) -> None:
6059
"""
6160
Start the worker.
6261
This is the main entry point that runs the worker loop.
6362
"""
6463
self.running = True
6564
self.logger.info("Job worker starting...")
66-
67-
# On startup, handle orphaned jobs (jobs stuck in RUNNING state)
65+
6866
if self.enable_orphan_recovery:
6967
await self.recover_orphaned_jobs()
70-
71-
# Start the main worker loop
68+
7269
await self.worker_loop()
73-
70+
7471
def stop(self) -> None:
7572
"""Stop the worker gracefully"""
7673
self.logger.info("Stopping job worker...")
7774
self.running = False
78-
75+
7976
async def worker_loop(self) -> None:
8077
"""
8178
Main worker loop - continuously polls database for QUEUED jobs.
8279
"""
8380
self.logger.info("Worker loop started - polling for QUEUED jobs")
84-
81+
8582
while self.running:
8683
try:
8784
await self.check_for_stuck_jobs()
88-
85+
8986
await self.process_next_job()
90-
87+
9188
except asyncio.CancelledError:
9289
self.logger.info("Worker loop cancelled")
9390
break
9491
except Exception as e:
9592
self.logger.exception(f"Error in worker loop: {e}")
9693
await asyncio.sleep(self.poll_interval)
97-
94+
9895
self.logger.info("Worker loop stopped")
99-
96+
10097
async def process_next_job(self) -> None:
10198
"""
10299
Find the next QUEUED job and process it.
103100
Uses SELECT FOR UPDATE SKIP LOCKED to prevent race conditions.
104101
"""
105102
job_id: UUID | None = None
106-
103+
107104
async for session in get_session():
108105
try:
109106
result = await session.execute(
110107
select(Job)
111108
.where(Job.progress == ProgressEnum.QUEUED)
112-
.order_by(Job.created_at)
109+
.where(Job.created_at.isnot(None)) # type: ignore[union-attr]
110+
.order_by(Job.created_at) # type: ignore[arg-type]
113111
.limit(1)
114112
.with_for_update(skip_locked=True)
115113
)
116114
job = result.scalar_one_or_none()
117-
115+
118116
if not job:
119117
self.logger.debug("No queued jobs found")
120118
await asyncio.sleep(self.poll_interval)
121119
return
122-
120+
123121
job_id = job.job_id
124122
self.logger.info(f"Found job {job_id}, processing...")
125-
123+
126124
except Exception as e:
127125
self.logger.exception(f"Error finding next job: {e}")
128126
await asyncio.sleep(self.poll_interval)
129127
return
130-
128+
131129
if job_id:
132130
await self.process_job(job_id)
133-
131+
134132
async def process_job(self, job_id: UUID) -> None:
135133
"""
136134
Process a single job.
137135
Flow: QUEUED → RUNNING → COMPLETED/FAILED
138136
"""
139137
async for session in get_session():
140138
job_service = JobService(logger=self.logger, session=session)
141-
139+
142140
try:
143141
self.logger.info(f"Starting job {job_id}")
144142
await job_service.update_progress(job_id, ProgressEnum.RUNNING)
145-
143+
146144
job = await job_service.get_job(job_id)
147145
if not job:
148146
self.logger.error(f"Job {job_id} not found, skipping")
149147
return
150-
148+
151149
self.logger.info(f"Generating routes for job {job_id}...")
152-
150+
153151
try:
154152
await asyncio.wait_for(
155-
self.generate_routes(job),
156-
timeout=self.job_timeout_minutes * 60
153+
self.generate_routes(job), timeout=self.job_timeout_minutes * 60
157154
)
158155
except asyncio.TimeoutError:
159156
raise Exception(
160157
f"Job timed out after {self.job_timeout_minutes} minutes"
161-
)
162-
158+
) from None
159+
163160
await job_service.update_progress(job_id, ProgressEnum.COMPLETED)
164161
self.logger.info(f"Job {job_id} completed successfully")
165-
162+
166163
except Exception as e:
167164
self.logger.exception(f"Job {job_id} failed: {e}")
168-
165+
169166
try:
170167
await job_service.update_progress(job_id, ProgressEnum.FAILED)
171168
except Exception as update_error:
172169
self.logger.exception(
173170
f"Failed to mark job {job_id} as FAILED: {update_error}"
174171
)
175-
172+
176173
async def generate_routes(self, job: Job) -> None:
177174
"""
178175
Execute the actual route generation algorithm.
179-
176+
180177
TODO: Replace this with your actual implementation.
181178
"""
182179
self.logger.info(f"Job {job.job_id}: Starting route generation...")
183-
180+
184181
await asyncio.sleep(10)
185-
182+
186183
# TODO: Implement actual route generation
187184

188-
189185
async def recover_orphaned_jobs(self) -> None:
190186
"""
191187
On startup, find jobs stuck in RUNNING state and reset them to QUEUED.
192188
This handles jobs that were being processed when the app crashed.
193-
189+
194190
Jobs persist in database, so when app restarts, we can resume processing.
195191
"""
196192
pass
197-
193+
198194
async def check_for_stuck_jobs(self) -> None:
199195
"""
200196
Periodically check for jobs that have been RUNNING too long.
201197
Mark them as FAILED if they exceed the timeout.
202198
"""
203199
pass
204-

backend/python/migrations/versions/c967b946e4f9_add_queued_to_progress_enum.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,12 +16,12 @@
1616
depends_on = None
1717

1818

19-
def upgrade():
19+
def upgrade() -> None:
2020
try:
2121
op.execute("ALTER TYPE progressenum ADD VALUE 'QUEUED'")
2222
except Exception:
2323
pass
2424

2525

26-
def downgrade():
26+
def downgrade() -> None:
2727
pass

0 commit comments

Comments
 (0)