An async data-processing pipeline with a web UI. Upload one or more JSON datasets, watch them move through background workers, and inspect the computed statistics β all without page refreshes.
Stack: FastAPI Β· Celery Β· Redis Β· PostgreSQL Β· Docker Compose Β· uv
Browser
β POST /api/datasets/upload (multipart, supports batch drag-and-drop)
β GET /api/tasks/ (poll every 2 s)
βΌ
FastAPI (api) ββββ Redis ββββ Celery Worker (worker)
β writes Task(PENDING) β PENDING β RUNNING β COMPLETED / FAILED
β saves file to /app/uploads β reads file, validates, processes
βΌ βΌ
PostgreSQL βββββββββββββββββββββββββββββ
tasks table: id, dataset_id, status, result, error, β¦
Both api and worker containers share the uploads_data Docker volume so the worker can always read the file the API saved.
Docker and Docker Compose are required. If not installed:
- Mac: Download Docker Desktop for Mac and install the
.dmg. Docker Compose is included. - Windows: Download Docker Desktop for Windows.
- Linux:
curl -fsSL https://get.docker.com | sh sudo usermod -aG docker $USER # re-login after this
Verify the installation:
docker --version
docker compose version# 1. Clone and enter the project
git clone https://github.com/ffy208/DataProcessingWebSystem.git
cd DataProcessingWebSystem
# 2. Copy environment file
cp .env.example .env # defaults work out-of-the-box
# 3. Start all services
docker compose up --build -d
# 4. Run database migrations
docker compose exec api uv run alembic upgrade head
# 5. Open the UI β paste this into your browser
# http://localhost:8000Services exposed to the host:
| Service | Port |
|---|---|
| FastAPI API + UI | 8000 |
| PostgreSQL | 5432 |
| Redis | 6379 |
By default one worker container runs with 4 threads (4 concurrent task slots). Scale up for higher throughput:
# Scale to 3 worker containers = 12 concurrent task slots
docker compose up --scale worker=3 -dConcurrency math: N workers Γ 4 threads = total slots
Example: 3 workers β 12 slots β 100 tasks Γ· 12 Γ 15 s β 125 s total
# Stop all containers but keep data (can restart without re-running migrations)
docker compose down
# Stop and delete all data volumes (full reset β migrations must be re-run)
docker compose down -vFastAPI's BackgroundTasks dies with the process. Celery tasks survive restarts, support retries, and can be distributed across multiple workers without code changes. task_acks_late=True combined with reject_on_worker_lost=True ensures a task is re-queued if the worker crashes mid-execution rather than silently dropped.
Multiple Celery workers write to the same database concurrently. SQLite's write lock would serialize all workers; PostgreSQL handles concurrent writes safely and provides real UUID column types and JSONB storage for the result.
FastAPI uses asyncpg for non-blocking database I/O. Celery workers run in forked OS processes and cannot use an async engine, so the worker uses a separate psycopg2-based sync session factory. The engine is a per-process singleton (pool_size=1, max_overflow=0) so that N worker processes create exactly N connections β preventing connection pool exhaustion under load.
The uploaded file is written to disk before process_dataset_task.delay() is called. This guarantees the file exists on the shared volume when the worker starts, even if the broker queues the task and delivers it seconds later.
WebSocket would require managing connection state, reconnect logic, and a separate protocol. Polling every 2 seconds is simpler, works through any proxy, and is more than sufficient for tasks that complete within 15β30 seconds.
uv resolves and locks the full dependency graph deterministically. uv sync --frozen in the Dockerfile ensures the container image always uses exactly the versions in uv.lock.
Upload a JSON file with this structure:
{
"dataset_id": "my_dataset_001",
"records": [
{
"id": "r1",
"timestamp": "2026-01-01T10:00:00Z",
"value": 42.5,
"category": "A"
}
]
}Required fields per record: id, timestamp, value (numeric, not NaN/Infinity), category.
Records missing any field, having a non-numeric value, or containing NaN/Infinity are counted as invalid_records and excluded from average_value.
Constraints: JSON file only, max 10 MB.
22 ready-to-upload test files are in tests/manual/:
| File | Expected Result |
|---|---|
valid_small.json |
COMPLETED, record_count=3, invalid=0 |
valid_large.json |
COMPLETED, record_count=30, 4 categories |
valid_floats.json |
COMPLETED, avgβ2001.45, 5 categories |
valid_negative_values.json |
COMPLETED, avg=-32.1 |
valid_zero_values.json |
COMPLETED, avg=0.0 |
valid_single_record.json |
COMPLETED, record_count=1 |
valid_many_categories.json |
COMPLETED, 9 distinct categories |
edge_empty_records.json |
COMPLETED, record_count=0, avg=0.0 |
edge_duplicate_ids.json |
COMPLETED, duplicate record IDs allowed |
mixed_half_invalid.json |
COMPLETED, invalid=5, avg from valid only |
mixed_nan_inf.json |
COMPLETED, NaN/Inf strings treated as invalid |
mixed_wrong_types.json |
COMPLETED, bool/null/array values are invalid |
all_invalid_records.json |
COMPLETED, invalid=record_count, avg=0.0 |
invalid_missing_dataset_id.json |
400 β no task created |
invalid_malformed_syntax.json |
400 β no task created |
| (+ 7 more invalid/edge cases) | 400 β no task created |
FastAPI generates an interactive API explorer automatically. Once the stack is running, open:
http://localhost:8000/docs
You can try every endpoint directly in the browser β upload a file, copy the returned task ID, then call GET /api/tasks/{task_id} to watch the status change. No curl or Postman needed.
A read-only OpenAPI schema is also available at http://localhost:8000/openapi.json.
Upload a JSON dataset. Returns immediately with a task ID; processing happens asynchronously.
Request: multipart/form-data, field name file.
Response 202:
{
"id": "550e8400-e29b-41d4-a716-446655440000",
"dataset_id": "my_dataset_001",
"filename": "my_dataset_001.json",
"status": "PENDING",
"result": null,
"error": null,
"created_at": "2026-04-22T10:00:00Z",
"updated_at": "2026-04-22T10:00:00Z"
}Error responses:
| Code | Cause |
|---|---|
| 400 | Malformed JSON, missingdataset_id, missing records key |
| 413 | File exceeds 10 MB |
| 422 | No file attached |
List all tasks, newest first.
Response 200: Array of task objects (same schema as above).
Get a single task by UUID.
Response 200 when status is COMPLETED:
{
"id": "550e8400-...",
"status": "COMPLETED",
"result": {
"dataset_id": "my_dataset_001",
"record_count": 10,
"invalid_records": 2,
"average_value": 32.05,
"category_summary": { "A": 4, "B": 3, "C": 3 }
}
}Response 200 when status is FAILED:
{
"status": "FAILED",
"error": "OSError: [Errno 2] No such file or directory: '...'"
}| Code | Cause |
|---|---|
| 404 | Task ID not found |
| 422 | ID is not a valid UUID |
See tests/HowToTest.md for the full guide. Quick reference:
# Unit tests β no external services needed
uv run pytest tests/test_processor.py tests/test_validator.py -v
# API integration tests β in-memory SQLite, no Docker needed
uv run pytest tests/test_api.py -v
# End-to-end integration tests β requires PostgreSQL
docker compose up postgres -d
uv run pytest tests/test_integration.py -v
# All 74 tests at once
uv run pytest -vIntegration tests are auto-skipped when PostgreSQL is unreachable.
| Suite | Tests | Strategy |
|---|---|---|
test_processor.py |
30 | Pure function unit tests β no I/O |
test_validator.py |
16 | Pure function unit tests β no I/O |
test_api.py |
16 | SQLite in-memory DB + mocked Celery |
test_integration.py |
12 | Real PostgreSQL + Celeryalways_eager |
| Total | 74 |
Simulates N users uploading M files concurrently against the live Docker stack:
docker compose up --build -d
docker compose exec api uv run alembic upgrade head
# Default: 5 users Γ 3 files = 15 concurrent requests
uv run python tests/load_test.py
# Stress test: 50 concurrent single-file uploads
uv run python tests/load_test.py --users 50 --files 1
# Scale workers before stress test
docker compose up --scale worker=3 -d
uv run python tests/load_test.py --users 50 --files 1The timeout is auto-calculated based on task count and worker concurrency. Pass --timeout <seconds> to override.
| Variable | Default | Description |
|---|---|---|
POSTGRES_HOST |
localhost |
PostgreSQL hostname |
POSTGRES_PORT |
5432 |
PostgreSQL port |
POSTGRES_DB |
dataprocessing |
Database name |
POSTGRES_USER |
app |
Database user |
POSTGRES_PASSWORD |
secret |
Database password |
REDIS_HOST |
localhost |
Redis hostname |
REDIS_PORT |
6379 |
Redis port |
UPLOAD_DIR |
uploads |
Directory for uploaded files |
In Docker Compose, POSTGRES_HOST=postgres and REDIS_HOST=redis are set via the environment: block in docker-compose.yml and override .env automatically.
app/
βββ api/routes/
β βββ datasets.py # POST /api/datasets/upload
β βββ tasks.py # GET /api/tasks/, GET /api/tasks/{id}
βββ core/
β βββ config.py # pydantic-settings, DB + Redis URLs
β βββ database.py # async SQLAlchemy engine + get_db()
β βββ celery_app.py # Celery instance with crash-safe config
βββ crud/task.py # create / get / list / update_status
βββ models/task.py # Task ORM model + TaskStatus enum
βββ schemas/task.py # Pydantic request/response models
βββ services/
β βββ validator.py # Structural JSON validation
β βββ processor.py # record_count / category_summary / average_value
βββ tasks/
β βββ process_dataset.py # Celery task, per-process DB singleton
βββ static/index.html # Single-page UI (Alpine.js + Tailwind CDN)
βββ main.py # FastAPI app factory + CORS + static files
alembic/
βββ versions/
β βββ 0001_create_tasks_table.py # Creates tasks table and task_status enum
βββ env.py
tests/
βββ fixtures/ # 4 JSON files used by test_integration.py
βββ manual/ # 22 sample JSON files for UI and load testing
βββ conftest.py # autouse fixture: redirects UPLOAD_DIR to tmp_path
βββ test_processor.py # 30 unit tests
βββ test_validator.py # 16 unit tests
βββ test_api.py # 16 API integration tests
βββ test_integration.py # 12 end-to-end tests
βββ load_test.py # Concurrent load test (httpx + asyncio)
βββ HowToTest.md # Full testing guide