Skip to content

Commit ae37129

Browse files
authored
Merge pull request #64 from ClipABit/split-backend
CLI-45: Split backend into 3 apps
2 parents 7e58ac7 + d72620c commit ae37129

File tree

24 files changed

+1901
-633
lines changed

24 files changed

+1901
-633
lines changed

.github/workflows/cd.yml

Lines changed: 38 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,10 @@ concurrency:
1212
permissions:
1313
contents: write
1414

15+
# Note: We deploy the individual apps (server.py, search_app.py, processing_app.py)
16+
# for optimal cold start performance. The dev_combined.py app is ONLY for local
17+
# development and should never be deployed to staging/prod.
18+
1519
jobs:
1620
# ------------------------------------------------------------------
1721
# STAGING DEPLOYMENT (Runs on push to 'staging')
@@ -38,15 +42,29 @@ jobs:
3842
- name: Install dependencies
3943
run: uv sync --frozen
4044

41-
- name: Deploy to Modal (Staging)
45+
- name: Deploy Processing App (Staging)
46+
env:
47+
MODAL_TOKEN_ID: ${{ secrets.MODAL_TOKEN_ID }}
48+
MODAL_TOKEN_SECRET: ${{ secrets.MODAL_TOKEN_SECRET }}
49+
ENVIRONMENT: staging
50+
run: uv run modal deploy apps/processing_app.py
51+
52+
- name: Deploy Search App (Staging)
4253
env:
4354
MODAL_TOKEN_ID: ${{ secrets.MODAL_TOKEN_ID }}
4455
MODAL_TOKEN_SECRET: ${{ secrets.MODAL_TOKEN_SECRET }}
45-
ENVIRONMENT: staging # for OS
46-
run: uv run modal deploy main.py
56+
ENVIRONMENT: staging
57+
run: uv run modal deploy apps/search_app.py
58+
59+
- name: Deploy Server (Staging)
60+
env:
61+
MODAL_TOKEN_ID: ${{ secrets.MODAL_TOKEN_ID }}
62+
MODAL_TOKEN_SECRET: ${{ secrets.MODAL_TOKEN_SECRET }}
63+
ENVIRONMENT: staging
64+
run: uv run modal deploy apps/server.py
4765

4866
# ------------------------------------------------------------------
49-
# PRODUCTION DEPLOYMENT (Runs only when manually triggered)
67+
# PRODUCTION DEPLOYMENT (Runs on push to 'main')
5068
# ------------------------------------------------------------------
5169
deploy-prod:
5270
name: Deploy Production
@@ -69,9 +87,23 @@ jobs:
6987
- name: Install dependencies
7088
run: uv sync --frozen
7189

72-
- name: Deploy to Modal (Prod)
90+
- name: Deploy Processing App (Prod)
91+
env:
92+
MODAL_TOKEN_ID: ${{ secrets.MODAL_TOKEN_ID }}
93+
MODAL_TOKEN_SECRET: ${{ secrets.MODAL_TOKEN_SECRET }}
94+
ENVIRONMENT: prod
95+
run: uv run modal deploy apps/processing_app.py
96+
97+
- name: Deploy Search App (Prod)
98+
env:
99+
MODAL_TOKEN_ID: ${{ secrets.MODAL_TOKEN_ID }}
100+
MODAL_TOKEN_SECRET: ${{ secrets.MODAL_TOKEN_SECRET }}
101+
ENVIRONMENT: prod
102+
run: uv run modal deploy apps/search_app.py
103+
104+
- name: Deploy Server (Prod)
73105
env:
74106
MODAL_TOKEN_ID: ${{ secrets.MODAL_TOKEN_ID }}
75107
MODAL_TOKEN_SECRET: ${{ secrets.MODAL_TOKEN_SECRET }}
76108
ENVIRONMENT: prod
77-
run: uv run modal deploy main.py
109+
run: uv run modal deploy apps/server.py

backend/README.md

Lines changed: 80 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,31 @@
11
# ClipABit Backend
22

3-
Video processing backend that runs on Modal. Accepts video uploads via FastAPI and processes them in serverless containers.
3+
Video processing backend that runs on Modal. Built as a microservices architecture with three specialized apps for optimized cold start times.
4+
5+
## Architecture
6+
7+
The backend uses different architectures for development vs production:
8+
9+
### Production (staging/prod)
10+
11+
Split into three Modal apps for optimal cold start times:
12+
13+
| App | Purpose | Dependencies |
14+
|-----|---------|--------------|
15+
| **server** | API gateway, handles requests, lightweight operations | Minimal (FastAPI, boto3) |
16+
| **search** | Semantic search with CLIP text encoder | Medium (torch, transformers) |
17+
| **processing** | Video processing, embedding generation | Heavy (ffmpeg, opencv, torch, transformers) |
18+
19+
This architecture ensures that lightweight API calls (health, status, list) don't need to load heavy ML models.
20+
21+
### Local Development (dev)
22+
23+
Single combined Modal app (`dev_combined.py`) with all three services:
24+
25+
| App | Purpose |
26+
|-----|---------|
27+
| **dev-combined** | Server + Search + Processing in one app |
28+
This allows hot-reload on all services without cross-app lookup issues. Cold start time is acceptable for local development where iteration speed matters more than cold start performance.
429

530
## Quick Start
631

@@ -11,34 +36,58 @@ uv sync
1136
# 2. Authenticate with Modal (first time only - opens browser)
1237
uv run modal token new
1338

14-
# 3. Configure Modal secrets (dev and prod)
15-
modal secret create dev \
16-
ENVIRONMENT=dev \
17-
PINECONE_API_KEY=your_pinecone_api_key \
18-
R2_ACCOUNT_ID=your_r2_account_id \
19-
R2_ACCESS_KEY_ID=your_r2_access_key_id \
20-
R2_SECRET_ACCESS_KEY=your_r2_secret_access_key
21-
22-
modal secret create prod \
23-
ENVIRONMENT=prod \
24-
PINECONE_API_KEY=your_pinecone_api_key \
25-
R2_ACCOUNT_ID=your_r2_account_id \
26-
R2_ACCESS_KEY_ID=your_r2_access_key_id \
27-
R2_SECRET_ACCESS_KEY=your_r2_secret_access_key
28-
29-
# 4. Start dev server (hot-reloads on file changes, uses "dev" secret)
39+
# 3. Start dev server
3040
uv run dev
3141
```
3242

3343
Note: `uv run` automatically uses the virtual environment - no need to activate it manually.
3444

45+
## Development CLI
46+
47+
### Local Development (Combined App)
48+
49+
For local development, use the combined app that includes all services in one:
50+
51+
| Command | Description |
52+
|---------|-------------|
53+
| `uv run dev` | Run combined dev app (server + search + processing in one) |
54+
55+
This uses `apps/dev_combined.py` which combines all three services into a single Modal app. Benefits:
56+
- Hot-reload works on all services (server, search, processing)
57+
- No cross-app lookup issues
58+
- Easy to iterate on any part of the system
59+
60+
**Note:** Cold starts will be slower since all dependencies load together, but this is acceptable for local development where iteration speed matters more than cold start performance.
61+
62+
### Individual Apps (For Testing/Debugging)
63+
64+
You can also run individual apps if needed:
65+
66+
| Command | Description |
67+
|---------|-------------|
68+
| `uv run server` | Run just the API server |
69+
| `uv run search` | Run just the search app |
70+
| `uv run processing` | Run just the processing app |
71+
72+
73+
**Note:** Cross-app communication only works between deployed apps, not ephemeral serve apps. For full system testing, use `uv run dev` (combined app) or deploy the apps.
74+
3575
## How It Works
3676

37-
- `main.py` defines a Modal App with a `Server` class
38-
- `/upload` endpoint accepts video files and spawns background processing jobs
77+
### Production Architecture (staging/prod)
78+
79+
- `apps/server.py` - API gateway, delegates heavy work to other apps via `modal.Cls.from_name()`
80+
- `apps/search_app.py` - Handles semantic search queries with CLIP text encoder
81+
- `apps/processing_app.py` - Processes video uploads (chunking, embedding, storage)
82+
- Cross-app communication uses Modal's `Cls.from_name()` for lookups and `spawn()`/`remote()` for calls
3983
- Environment variables stored in Modal secrets (no .env files needed)
40-
- `uv run dev` automatically uses "dev" secret for development
41-
- Production deployment handled via CI/CD or direct Modal CLI
84+
85+
### Local Development Architecture (dev)
86+
87+
- `apps/dev_combined.py` - All three services in one app for easy iteration
88+
- Uses `api/fastapi_router.py` (configured for dev combined mode) which accepts worker class references instead of doing lookups
89+
- No cross-app lookups needed - services call each other directly within the same app
90+
- Hot-reload works on all services simultaneously
4291

4392
## Managing Dependencies
4493

@@ -56,3 +105,13 @@ uv run pytest # Run all tests
56105
uv run pytest -v # Verbose output
57106
uv run pytest --cov # With coverage
58107
```
108+
109+
Note: Some integration tests require `ffmpeg` to be installed locally.
110+
111+
## Deployment
112+
113+
Deployment is handled via GitHub Actions CI/CD. **Only the individual apps are deployed** (not dev_combined.py):
114+
115+
1. `processing_app.py` (heavy dependencies)
116+
2. `search_app.py` (medium dependencies)
117+
3. `server.py` (API gateway - depends on the other two)

backend/api/fastapi_router.py

Lines changed: 88 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -3,21 +3,83 @@
33
import logging
44
import time
55
import uuid
6-
from fastapi import APIRouter, Form, HTTPException, UploadFile
6+
7+
import modal
8+
from fastapi import APIRouter, File, Form, HTTPException, UploadFile
79

810
logger = logging.getLogger(__name__)
911

12+
1013
class FastAPIRouter:
11-
def __init__(self, server_instance, is_internal_env):
14+
def __init__(
15+
self,
16+
server_instance,
17+
is_internal_env: bool,
18+
environment: str = "dev",
19+
search_service_cls=None,
20+
processing_service_cls=None
21+
):
1222
"""
1323
Initializes the API routes, giving them access to the server instance
1424
for calling background tasks and accessing shared state.
25+
26+
Args:
27+
server_instance: The Modal server instance for accessing connectors and spawning local methods
28+
is_internal_env: Whether this is an internal (dev/staging) environment
29+
environment: Environment name (dev, staging, prod) for cross-app lookups
30+
search_service_cls: Optional SearchService class for dev combined mode (direct access)
31+
processing_service_cls: Optional ProcessingService class for dev combined mode (direct access)
1532
"""
1633
self.server_instance = server_instance
1734
self.is_internal_env = is_internal_env
35+
self.environment = environment
36+
self.search_service_cls = search_service_cls
37+
self.processing_service_cls = processing_service_cls
1838
self.router = APIRouter()
39+
40+
# Initialize UploadHandler with process_video spawn function
41+
from services.upload import UploadHandler
42+
self.upload_handler = UploadHandler(
43+
job_store=server_instance.job_store,
44+
process_video_spawn_fn=self._get_process_video_spawn_fn()
45+
)
46+
1947
self._register_routes()
2048

49+
def _get_process_video_spawn_fn(self):
50+
"""
51+
Create a spawn function that works in both dev combined and production modes.
52+
53+
Returns:
54+
Callable that spawns process_video_background
55+
"""
56+
def spawn_process_video(video_bytes: bytes, filename: str, job_id: str, namespace: str, parent_batch_id: str):
57+
try:
58+
if self.processing_service_cls:
59+
# Dev combined mode - direct access
60+
self.processing_service_cls().process_video_background.spawn(
61+
video_bytes, filename, job_id, namespace, parent_batch_id
62+
)
63+
logger.info(f"[Upload] Spawned processing job {job_id} (dev combined mode)")
64+
else:
65+
# Production mode - cross-app call
66+
from shared.config import get_modal_environment
67+
processing_app_name = f"{self.environment} processing"
68+
ProcessingService = modal.Cls.from_name(
69+
processing_app_name,
70+
"ProcessingService",
71+
environment_name=get_modal_environment()
72+
)
73+
ProcessingService().process_video_background.spawn(
74+
video_bytes, filename, job_id, namespace, parent_batch_id
75+
)
76+
logger.info(f"[Upload] Spawned processing job {job_id} to {processing_app_name}")
77+
except Exception as e:
78+
logger.error(f"[Upload] Failed to spawn processing job {job_id}: {e}")
79+
raise
80+
81+
return spawn_process_video
82+
2183
def _register_routes(self):
2284
"""Registers all the FastAPI routes."""
2385
self.router.add_api_route("/health", self.health, methods=["GET"])
@@ -59,40 +121,23 @@ async def status(self, job_id: str):
59121
}
60122
return job_data
61123

62-
async def upload(self, file: UploadFile, namespace: str = Form("")):
124+
async def upload(self, files: list[UploadFile] = File(default=[]), namespace: str = Form("")):
63125
"""
64126
Handle video file upload and start background processing.
127+
Supports both single and batch uploads.
65128
66129
Args:
67-
file (UploadFile): The uploaded video file.
130+
files (list[UploadFile]): List of uploaded video file(s). Client sends files with repeated 'files' field names, which FastAPI collects into a list.
68131
namespace (str, optional): Namespace for Pinecone and R2 storage (default: "")
69132
70133
Returns:
71-
dict: Contains job_id, filename, content_type, size_bytes, status, and message.
72-
"""
73-
contents = await file.read()
74-
file_size = len(contents)
75-
job_id = str(uuid.uuid4())
76-
77-
self.server_instance.job_store.create_job(job_id, {
78-
"job_id": job_id,
79-
"filename": file.filename,
80-
"status": "processing",
81-
"size_bytes": file_size,
82-
"content_type": file.content_type,
83-
"namespace": namespace
84-
})
134+
dict: For single upload: job_id, filename, etc.
135+
For batch upload: batch_job_id, total_videos, child_jobs, etc.
85136
86-
self.server_instance.process_video_background.spawn(contents, file.filename, job_id, namespace)
87-
88-
return {
89-
"job_id": job_id,
90-
"filename": file.filename,
91-
"content_type": file.content_type,
92-
"size_bytes": file_size,
93-
"status": "processing",
94-
"message": "Video uploaded successfully, processing in background"
95-
}
137+
Raises:
138+
HTTPException: 400 if validation fails, 500 if processing errors
139+
"""
140+
return await self.upload_handler.handle_upload(files, namespace)
96141

97142
async def search(self, query: str, namespace: str = "", top_k: int = 10):
98143
"""
@@ -113,14 +158,23 @@ async def search(self, query: str, namespace: str = "", top_k: int = 10):
113158
t_start = time.perf_counter()
114159
logger.info(f"[Search] Query: '{query}' | namespace='{namespace}' | top_k={top_k}")
115160

116-
results = self.server_instance.searcher.search(
117-
query=query,
118-
top_k=top_k,
119-
namespace=namespace
120-
)
161+
# Call search app
162+
if self.search_service_cls:
163+
# Dev combined mode - direct access to worker in same app
164+
results = self.search_service_cls().search.remote(query, namespace, top_k)
165+
else:
166+
# Production mode - cross-app call via from_name
167+
from shared.config import get_modal_environment
168+
search_app_name = f"{self.environment} search"
169+
SearchService = modal.Cls.from_name(
170+
search_app_name,
171+
"SearchService",
172+
environment_name=get_modal_environment()
173+
)
174+
results = SearchService().search.remote(query, namespace, top_k)
121175

122176
t_done = time.perf_counter()
123-
logger.info(f"[Search] Found {len(results)} chunk-level results in {t_done - t_start:.3f}s")
177+
logger.info(f"[Search] Found {len(results)} results in {t_done - t_start:.3f}s")
124178

125179
return {
126180
"query": query,

backend/apps/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
# Modal apps package
2+
# Contains separate Modal apps for Server, Search, and Processing

0 commit comments

Comments
 (0)