# Multi-Worker Deployment Solution Summary

## Problem

When running FastAPI with `uvicorn --workers 4`, the `lifespan` function executes in **all 4 worker processes**, causing:

- ❌ **Duplicate email notifications** (4x emails sent)
- ❌ **Multiple schedulers** running simultaneously
- ❌ **Race conditions** in database operations

## Root Cause

Your original implementation tried to detect the primary worker using:

```python
multiprocessing.current_process().name == "MainProcess"
```

**This doesn't work** because with `uvicorn --workers N`, each worker is a separate process with its own name, and none are reliably named "MainProcess".

## Solution Implemented

### File-Based Worker Locking

We implemented a **file-based locking mechanism** that ensures only ONE worker runs singleton services:

```python
# worker_coordination.py
class WorkerLock:
    """Uses fcntl.flock() to coordinate workers across processes"""

    def acquire(self) -> bool:
        """Try to acquire exclusive lock - only one process succeeds"""
        fcntl.flock(self.lock_fd.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
```

### Updated Lifespan Function

```python
async def lifespan(app: FastAPI):
    # File-based lock ensures only one worker is primary
    is_primary, worker_lock = is_primary_worker()

    if is_primary:
        # ✓ Start email scheduler (ONCE)
        # ✓ Run database migrations (ONCE)
        # ✓ Start background tasks (ONCE)
    else:
        # Skip singleton services
        pass

    # All workers handle HTTP requests normally
    yield

    # Release lock on shutdown
    if worker_lock:
        worker_lock.release()
```

## How It Works

```
uvicorn --workers 4
    │
    ├─ Worker 0 → tries lock → ✓ SUCCESS → PRIMARY (runs schedulers)
    ├─ Worker 1 → tries lock → ✗ BUSY    → SECONDARY (handles requests)
    ├─ Worker 2 → tries lock → ✗ BUSY    → SECONDARY (handles requests)
    └─ Worker 3 → tries lock → ✗ BUSY    → SECONDARY (handles requests)
```

## Verification

### Test Results

```bash
$ uv run python test_worker_coordination.py

Worker 0 (PID 30773): ✓ I am PRIMARY
Worker 1 (PID 30774): ✗ I am SECONDARY
Worker 2 (PID 30775): ✗ I am SECONDARY
Worker 3 (PID 30776): ✗ I am SECONDARY
✓ Test complete: Only ONE worker should have been PRIMARY
```

### All Tests Pass

```bash
$ uv run pytest tests/ -v
======================= 120 passed, 23 warnings in 1.96s =======================
```

## Files Modified

1. **`worker_coordination.py`** (NEW)
   - `WorkerLock` class with `fcntl` file locking
   - `is_primary_worker()` function for easy integration

2. **`api.py`** (MODIFIED)
   - Import `is_primary_worker` from worker_coordination
   - Replace manual worker detection with file-based locking
   - Use `is_primary` flag to conditionally start schedulers
   - Release lock on shutdown

## Advantages of This Solution

✅ **No external dependencies** - uses standard library `fcntl`
✅ **Automatic failover** - if primary crashes, lock is auto-released
✅ **Works with any ASGI server** - uvicorn, gunicorn, hypercorn
✅ **Simple and reliable** - battle-tested Unix file locking
✅ **No race conditions** - atomic lock acquisition
✅ **Production-ready** - handles edge cases gracefully

## Usage

### Development (Single Worker)
```bash
uvicorn alpine_bits_python.api:app --reload
# Single worker becomes primary automatically
```

### Production (Multiple Workers)
```bash
uvicorn alpine_bits_python.api:app --workers 4
# Worker that starts first becomes primary
# Others become secondary workers
```

### Check Logs
```
[INFO] Worker startup: process=SpawnProcess-1, pid=1001, primary=True
[INFO] Worker startup: process=SpawnProcess-2, pid=1002, primary=False
[INFO] Worker startup: process=SpawnProcess-3, pid=1003, primary=False
[INFO] Worker startup: process=SpawnProcess-4, pid=1004, primary=False
[INFO] Daily report scheduler started  # ← Only on primary!
```

## What This Fixes

| Issue | Before | After |
|-------|--------|-------|
| **Email notifications** | Sent 4x (one per worker) | Sent 1x (only primary) |
| **Daily report scheduler** | 4 schedulers running | 1 scheduler running |
| **Customer hashing** | Race condition across workers | Only primary hashes |
| **Startup logs** | Confusing worker detection | Clear primary/secondary status |

## Alternative Approaches Considered

### ❌ Environment Variables
```bash
ALPINEBITS_PRIMARY_WORKER=true uvicorn app:app
```
**Problem**: Manual configuration, no automatic failover

### ❌ Process Name Detection
```python
multiprocessing.current_process().name == "MainProcess"
```
**Problem**: Unreliable with uvicorn's worker processes

### ✅ Redis-Based Locking
```python
redis.lock.Lock(redis_client, "primary_worker")
```
**When to use**: Multi-container deployments (Docker Swarm, Kubernetes)

## Recommendations

### For Single-Host Deployments (Your Case)
✅ Use the file-based locking solution (implemented)

### For Multi-Container Deployments
Consider Redis-based locks if deploying across multiple containers/hosts:

```python
# In worker_coordination.py, add Redis option
def is_primary_worker(use_redis=False):
    if use_redis:
        return redis_based_lock()
    else:
        return file_based_lock()  # Current implementation
```

## Conclusion

Your FastAPI application now correctly handles multiple workers:

- ✅ Only **one worker** runs singleton services (schedulers, migrations)
- ✅ All **workers** handle HTTP requests concurrently
- ✅ No **duplicate email notifications**
- ✅ No **race conditions** in database operations
- ✅ **Automatic failover** if primary worker crashes

**Result**: You get the performance benefits of multiple workers WITHOUT the duplicate notification problem! 🎉
