Contract analysis is potentially slow because it depends on an LLM and because reports require PDF rendering.
The project therefore treats long-running work as background jobs.
flowchart LR
Client --> API[FastAPI]
API -->|queue| Redis[(Redis)]
Redis --> Worker[Celery Worker]
Worker --> LLM[OpenAI-compatible API]
LLM --> Worker
Worker --> DB[(PostgreSQL)]
Report generation follows a similar path:
API
|
| queue report task
v
Redis
|
v
Celery worker
|
+--> load analysis
|
+--> generate HTML
|
+--> WeasyPrint
|
+--> save PDF
|
+--> update PostgreSQL
The Celery application is configured with:
- JSON task serialization
- UTC time
- task-start tracking
- 30-minute hard task time limit
- 25-minute soft task time limit
- worker prefetch multiplier of 1
- worker concurrency of 4
- task retry delay
- three maximum retries
- separate task queues
- result expiration
The project routes work into named queues:
analysis
cleanup
Analysis queue:
- document analysis
- report PDF generation
Cleanup queue:
- expired refresh-token cleanup
- old report cleanup
- orphaned reference cleanup
This allows worker capacity to be dedicated to different types of work later.
The analysis task uses several reliability mechanisms:
acks_late=TrueThis reduces the chance that a task is permanently acknowledged before a worker has successfully completed it.
Tasks can retry failed work.
Retry configuration includes:
- three retries
- delayed retry
- exponential backoff
- maximum backoff
- jitter
Jitter reduces the chance that many failed workers retry at exactly the same time.
The document analysis task checks whether an analysis record already exists for the Celery task ID before inserting a duplicate analysis.
This is important because background work can be retried.
The Celery task itself is synchronous:
def analyze_legal_document_task(...):
...The analyzer service is asynchronous:
async def analyze(...):
...The worker bridges the two:
Celery sync task
|
v
asyncio.run(...)
|
v
AsyncOpenAI
The point is not to make Celery "async". The point is to keep the web process free while the worker executes the long-running job.
When an analysis is queued, the API returns a Celery task ID.
The client can use that identifier to query task status.
Conceptually:
queued
|
v
pending / started
|
+----> failed
|
v
completed
Once the Celery task completes successfully, the analysis is retrieved from PostgreSQL.
The database is treated as the durable record of completed analysis results rather than relying permanently on Celery result storage.
The PDF task:
- receives analysis data
- renders HTML
- converts HTML to PDF using WeasyPrint
- writes the PDF to report storage
- updates the analysis record
- returns task metadata
Report files use a deterministic name:
report_<analysis_id>.pdf
This makes regeneration idempotent from the file-storage perspective.
Celery Beat schedules periodic maintenance:
Daily:
cleanup expired refresh tokens
cleanup orphaned report references
Weekly:
cleanup old report files
The weekly report cleanup currently keeps reports for 30 days.
API and worker processes can scale independently.
If LLM analysis becomes the bottleneck:
1 API
|
v
Redis
|
+--> Worker 1
+--> Worker 2
+--> Worker 3
+--> Worker 4
If HTTP traffic becomes the bottleneck instead, the API tier can be scaled independently.
This separation is one of the main architectural reasons for introducing Celery into the project.