Module 8: Final Project — TaskFlow API

Audit log with triggers + optimistic locking

This capsule adds two features that raise TaskFlow to production-ready: an automatic audit log via PostgreSQL triggers (any change to tasks is recorded in audit.task_log), and optimistic locking with an If-Match header (correct concurrency handling in PUT /tasks/{id}).

By the end, every change to a task is audited with no effort from the app, and two clients editing the same task simultaneously receive correct responses (one succeeds, the other gets 412 with info to resolve).


audit schema and audit.task_log table

alembic revision -m "add audit schema and task_log"
# alembic/versions/XXX_add_audit_schema.py
def upgrade() -> None:
    # Create the audit schema
    op.execute("CREATE SCHEMA IF NOT EXISTS audit")

    # audit.task_log table
    op.execute("""
        CREATE TABLE audit.task_log (
            id BIGSERIAL PRIMARY KEY,
            task_id UUID NOT NULL,
            tenant_id UUID NOT NULL,
            user_id UUID,                          -- who made the change
            action TEXT NOT NULL,                  -- 'INSERT' | 'UPDATE' | 'DELETE'
            old_data JSONB,                        -- previous state (UPDATE/DELETE)
            new_data JSONB,                        -- new state (INSERT/UPDATE)
            changed_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
        )
    """)

    # Index for common queries
    op.execute("""
        CREATE INDEX idx_task_log_task_changed
        ON audit.task_log(task_id, changed_at DESC)
    """)

    # Trigger function
    op.execute("""
        CREATE OR REPLACE FUNCTION audit.log_task_change()
        RETURNS TRIGGER AS $$
        DECLARE
            user_id_setting TEXT;
        BEGIN
            user_id_setting := current_setting('audit.user_id', TRUE);

            IF TG_OP = 'INSERT' THEN
                INSERT INTO audit.task_log (
                    task_id, tenant_id, user_id, action, new_data
                )
                VALUES (
                    NEW.id, NEW.tenant_id,
                    NULLIF(user_id_setting, '')::UUID,
                    'INSERT',
                    to_jsonb(NEW)
                );
                RETURN NEW;

            ELSIF TG_OP = 'UPDATE' THEN
                INSERT INTO audit.task_log (
                    task_id, tenant_id, user_id, action, old_data, new_data
                )
                VALUES (
                    NEW.id, NEW.tenant_id,
                    NULLIF(user_id_setting, '')::UUID,
                    'UPDATE',
                    to_jsonb(OLD),
                    to_jsonb(NEW)
                );
                RETURN NEW;

            ELSIF TG_OP = 'DELETE' THEN
                INSERT INTO audit.task_log (
                    task_id, tenant_id, user_id, action, old_data
                )
                VALUES (
                    OLD.id, OLD.tenant_id,
                    NULLIF(user_id_setting, '')::UUID,
                    'DELETE',
                    to_jsonb(OLD)
                );
                RETURN OLD;
            END IF;

            RETURN NULL;
        END;
        $$ LANGUAGE plpgsql;
    """)

    # Trigger on tasks
    op.execute("""
        CREATE TRIGGER trg_audit_tasks
        AFTER INSERT OR UPDATE OR DELETE ON tasks
        FOR EACH ROW EXECUTE FUNCTION audit.log_task_change();
    """)


def downgrade() -> None:
    op.execute("DROP TRIGGER IF EXISTS trg_audit_tasks ON tasks")
    op.execute("DROP FUNCTION IF EXISTS audit.log_task_change()")
    op.execute("DROP TABLE IF EXISTS audit.task_log")
    op.execute("DROP SCHEMA IF EXISTS audit")

current_setting('audit.user_id', TRUE) reads the user_id from the session (similar to app.tenant_id). The app must set it before each query.


Pass user_id to the queries

Update the dependency:

# app/deps.py
async def get_db_with_tenant(
    tenant_id: str = Depends(get_current_tenant_id),
    user_id: str = Depends(get_current_user_id),
) -> AsyncGenerator[AsyncSession, None]:
    async with SessionLocal() as session:
        await session.execute(
            text("SET LOCAL app.tenant_id = :tid"),
            {"tid": tenant_id}
        )
        await session.execute(
            text("SET LOCAL audit.user_id = :uid"),
            {"uid": user_id}
        )
        yield session


async def get_current_user_id(
    authorization: str = Header(None, alias="Authorization"),
) -> str:
    """Extract user_id from the JWT."""
    if not authorization or not authorization.startswith("Bearer "):
        raise HTTPException(401, "Missing or invalid Authorization header")

    token = authorization.removeprefix("Bearer ")
    try:
        payload = jwt.decode(token, settings.jwt_secret, algorithms=[settings.jwt_algorithm])
        user_id = payload.get("user_id")
        if not user_id:
            # Fall back to sub if there's no explicit user_id
            return payload.get("sub", "")
        return user_id
    except jwt.PyJWTError:
        raise HTTPException(401, "Invalid token")

Update auth/login to include user_id in the JWT:

# app/routers/auth.py
@router.post("/auth/login")
async def login(request: LoginRequest):
    payload = {
        "sub": request.email,
        "user_id": str(uuid.uuid4()),  # Mock — in production it would come from the DB
        "tenant_id": request.tenant_id,
        "exp": datetime.now(timezone.utc) + timedelta(hours=24),
    }
    token = jwt.encode(payload, settings.jwt_secret, algorithm=settings.jwt_algorithm)
    return LoginResponse(access_token=token)

Endpoint to view a task's history

# app/routers/tasks.py
@router.get("/tasks/{task_id}/history")
async def get_task_history(
    task_id: uuid.UUID,
    limit: int = Query(50, ge=1, le=200),
    db: AsyncSession = Depends(get_db_with_tenant),
):
    """Audit log of a specific task."""
    # First verify the task exists (RLS applies, so only for this tenant)
    task = await db.scalar(select(Task).where(Task.id == task_id))
    if not task:
        raise HTTPException(404, "Task not found")

    # Query the audit log
    result = await db.execute(text("""
        SELECT
            id, action, old_data, new_data, user_id, changed_at
        FROM audit.task_log
        WHERE task_id = :task_id
          AND tenant_id::text = current_setting('app.tenant_id', TRUE)
        ORDER BY changed_at DESC
        LIMIT :limit
    """), {"task_id": str(task_id), "limit": limit})

    rows = result.mappings().all()
    return {"history": [dict(r) for r in rows]}

Note the manual WHERE tenant_id::text = current_setting('app.tenant_id', TRUE) on audit.task_log. RLS doesn't apply to the audit schema by default — you'd have to enable it explicitly. For demo simplicity, we do the filter manually.


Optimistic locking on Task

Add a version column:

# app/models/task.py
class Task(Base):
    # ... other columns ...
    version: Mapped[int] = mapped_column(Integer, default=1, nullable=False)

    __mapper_args__ = {
        "version_id_col": "version",
    }

Migration:

alembic revision -m "add version column to tasks"
def upgrade() -> None:
    op.add_column(
        'tasks',
        sa.Column('version', sa.Integer, nullable=False, server_default='1')
    )


def downgrade() -> None:
    op.drop_column('tasks', 'version')

PUT /tasks/{id} endpoint with If-Match

# app/routers/tasks.py
from sqlalchemy.orm.exc import StaleDataError


class TaskUpdate(BaseModel):
    title: Optional[str] = None
    status: Optional[str] = None


@router.put("/tasks/{task_id}", response_model=TaskResponse)
async def update_task(
    task_id: uuid.UUID,
    data: TaskUpdate,
    response: Response,
    db: AsyncSession = Depends(get_db_with_tenant),
    if_match: Optional[str] = Header(None, alias="If-Match"),
):
    if not if_match:
        raise HTTPException(
            status.HTTP_428_PRECONDITION_REQUIRED,
            detail="If-Match header required for updates"
        )

    try:
        requested_version = int(if_match.strip('"'))
    except ValueError:
        raise HTTPException(400, "Invalid If-Match header format")

    # Fetch task (RLS filters by tenant)
    task = await db.scalar(
        select(Task).where(
            Task.id == task_id,
            Task.deleted_at.is_(None),
        )
    )
    if not task:
        raise HTTPException(404, "Task not found")

    # Pre-check
    if task.version != requested_version:
        raise HTTPException(
            status.HTTP_412_PRECONDITION_FAILED,
            detail={
                "error": "version_mismatch",
                "current_etag": f'"{task.version}"',
                "your_etag": if_match,
                "current_state": {
                    "id": str(task.id),
                    "title": task.title,
                    "status": task.status,
                }
            }
        )

    # Apply changes
    changes = data.model_dump(exclude_unset=True)
    for field, value in changes.items():
        setattr(task, field, value)

    try:
        await db.commit()
    except StaleDataError:
        await db.rollback()
        # Re-fetch for current state
        current_task = await db.scalar(
            select(Task).where(Task.id == task_id, Task.deleted_at.is_(None))
        )
        raise HTTPException(
            status.HTTP_412_PRECONDITION_FAILED,
            detail={
                "error": "version_mismatch_race",
                "current_etag": f'"{current_task.version}"',
                "current_state": {
                    "id": str(current_task.id),
                    "title": current_task.title,
                    "status": current_task.status,
                }
            }
        )

    response.headers["ETag"] = f'"{task.version}"'

    return TaskResponse(
        id=task.id, project_id=task.project_id, title=task.title,
        status=task.status, created_at=task.created_at, updated_at=task.updated_at,
    )

And GET /tasks/{id} must return an ETag:

@router.get("/tasks/{task_id}", response_model=TaskResponse)
async def get_task(
    task_id: uuid.UUID,
    response: Response,
    db: AsyncSession = Depends(get_db_with_tenant),
    if_none_match: Optional[str] = Header(None, alias="If-None-Match"),
):
    task = await db.scalar(
        select(Task).where(Task.id == task_id, Task.deleted_at.is_(None))
    )
    if not task:
        raise HTTPException(404, "Task not found")

    current_etag = f'"{task.version}"'

    if if_none_match == current_etag:
        response.status_code = status.HTTP_304_NOT_MODIFIED
        response.headers["ETag"] = current_etag
        return None

    response.headers["ETag"] = current_etag
    return TaskResponse(
        id=task.id, project_id=task.project_id, title=task.title,
        status=task.status, created_at=task.created_at, updated_at=task.updated_at,
    )

Tests

# tests/test_audit_log.py
@pytest.mark.asyncio
async def test_create_task_logs_to_audit(client, tenant_a_token, project_a_id, db):
    # Create task
    response = await client.post(
        "/tasks",
        json={"project_id": project_a_id, "title": "T", "status": "pending"},
        headers={"Authorization": f"Bearer {tenant_a_token}"}
    )
    task_id = response.json()["id"]

    # Verify the audit log
    history = await client.get(
        f"/tasks/{task_id}/history",
        headers={"Authorization": f"Bearer {tenant_a_token}"}
    )
    log = history.json()["history"]
    assert len(log) == 1
    assert log[0]["action"] == "INSERT"
    assert log[0]["new_data"]["title"] == "T"


@pytest.mark.asyncio
async def test_update_task_logs_to_audit(client, tenant_a_token, project_a_id):
    # Create
    create = await client.post("/tasks", json={...}, headers={...})
    task_id = create.json()["id"]
    initial_etag = create.headers.get("ETag")

    # Update
    await client.put(
        f"/tasks/{task_id}",
        json={"status": "completed"},
        headers={"Authorization": f"Bearer {tenant_a_token}", "If-Match": initial_etag}
    )

    # History should have 2 entries
    history = await client.get(f"/tasks/{task_id}/history", headers={...})
    log = history.json()["history"]
    assert len(log) == 2
    # Ordered DESC
    assert log[0]["action"] == "UPDATE"
    assert log[1]["action"] == "INSERT"
    # Diff captured
    assert log[0]["old_data"]["status"] == "pending"
    assert log[0]["new_data"]["status"] == "completed"


# tests/test_tasks_optimistic_lock.py
@pytest.mark.asyncio
async def test_concurrent_updates_second_returns_412(client, tenant_a_token, project_a_id):
    # Create
    create = await client.post("/tasks", json={...}, headers={...})
    task_id = create.json()["id"]
    etag = create.headers["ETag"]

    # Client A updates (success)
    response_a = await client.put(
        f"/tasks/{task_id}",
        json={"title": "By A"},
        headers={"Authorization": f"Bearer {tenant_a_token}", "If-Match": etag}
    )
    assert response_a.status_code == 200

    # Client B with the old etag (412)
    response_b = await client.put(
        f"/tasks/{task_id}",
        json={"title": "By B"},
        headers={"Authorization": f"Bearer {tenant_a_token}", "If-Match": etag}
    )
    assert response_b.status_code == 412
    assert response_b.json()["detail"]["error"] == "version_mismatch"


@pytest.mark.asyncio
async def test_update_without_if_match_returns_428(client, tenant_a_token, project_a_id):
    create = await client.post("/tasks", json={...}, headers={...})
    task_id = create.json()["id"]

    # No If-Match
    response = await client.put(
        f"/tasks/{task_id}",
        json={"title": "X"},
        headers={"Authorization": f"Bearer {tenant_a_token}"}
    )
    assert response.status_code == 428

Pitfalls and common mistakes

1. Trigger without IS DISTINCT FROM in the check.

If you want to avoid logging UPDATEs that don't change anything:

IF TG_OP = 'UPDATE' AND OLD IS DISTINCT FROM NEW THEN
    -- log
END IF;

Without this, every PUT (even if the values are the same) creates an audit entry.

2. current_setting('audit.user_id') without NULL handling.

If the app doesn't set the setting, NULLIF(...)::UUID can fail. The NULLIF(value, '') converts an empty string to NULL before the cast.

3. The audit schema without RLS.

If you want to filter by tenant in the audit log, RLS there too. For demo simplicity, we do the filter manually in the query.

4. A trigger that fails silently.

If the audit.log_task_change() trigger fails, the INSERT/UPDATE/DELETE on tasks also fails (because it's an AFTER trigger inside the transaction). Important to handle the trigger's errors gracefully.

5. to_jsonb(NEW) with large columns.

to_jsonb serializes the whole row. If you have large columns (TEXT, BLOB), the audit grows fast. Consider to_jsonb(NEW) - 'large_column' to exclude them.

6. Pre-check + post-check redundancy.

The pre-check (in Python code) and the post-check (SQLAlchemy's StaleDataError) cover different cases:

  • Pre-check: fast, before modifying.
  • Post-check: catches a race condition between the pre-check and the commit.

Both are necessary.

7. Response without an ETag header.

Without response.headers["ETag"], the client can't maintain the version state. ETag mandatory in GET and PUT responses.


Summary and next step

What you have now:

  • The audit schema with a task_log table populated by triggers.
  • A PostgreSQL trigger that captures INSERT/UPDATE/DELETE on tasks.
  • Context variables: app.tenant_id and audit.user_id.
  • A history endpoint: GET /tasks/{id}/history.
  • A version column with __mapper_args__.
  • PUT /tasks/{id} with If-Match → 412 / StaleDataError → 412.
  • Tests for the audit log and optimistic locking.

Commit:

git add .
git commit -m "feat: audit log via triggers + optimistic locking with If-Match"

In the next capsule we close out the patterns with two peak features: a bulk endpoint with COPY + ON CONFLICT and a zero-downtime migration executed live with wrk running. That's the module's binary criterion: 0 errors in 600s of traffic during the 3-phase deploy.


Resources

  1. PostgreSQL — Trigger Functions — reference.
  2. PostgreSQL — to_jsonb — serialization.
  3. SQLAlchemy 2.0 — Versioning Counter — reference.
  4. RFC 7232 — Conditional RequestsIf-Match.
  5. Audit triggers in PostgreSQL — patterns reference.

Capsule 05 of 08 — Module 8 — SQL Patterns for Production APIs Guide