Module 8: Recursive CTEs + Final Project

Final project — phase 2: partitioning, advisory lock, recursive categories

You continue the integrating project. This capsule covers the 3 remaining components: partitioning of comments with a zero-downtime migration, recursive categories with a CTE, and an advisory lock to protect the refresh and re-indexing crons.

When you finish, all 6 components are implemented. Capsule 08 closes with the final documentation.


Component 4: partitioning of comments by month

Problem: the comments table has 1M+ rows and grows without stopping. "Recent comments" queries get slow. Solution: partitioning by month (module 4).

Zero-downtime migration

This migration requires zero-downtime because the comments table is active. Pattern (from guide #13 module 5 — expand-contract):

Step 1: create the new partitioned table (empty)

# alembic/versions/XXX_partition_comments_step1.py
def upgrade() -> None:
    # Create the new partitioned table
    op.execute("""
        CREATE TABLE comments_partitioned (
            id SERIAL,
            post_id INTEGER NOT NULL,
            user_id INTEGER NOT NULL,
            body TEXT NOT NULL,
            created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
            PRIMARY KEY (id, created_at)  -- composite due to the partitioning requirement
        ) PARTITION BY RANGE (created_at)
    """)

    # Create partitions for the last 12 months + 3 future months
    for i in range(-12, 4):
        # Monthly partitions
        # ... logic to generate partitions
        pass

    # Indexes on the main table (applied to partitions)
    op.execute("CREATE INDEX idx_comments_part_post ON comments_partitioned (post_id, created_at DESC)")

Step 2: copy data in batches

# scripts/migrate_comments.py
async def migrate_in_batches():
    BATCH = 10_000
    async with SessionLocal() as session:
        last_id = 0
        while True:
            result = await session.execute(text("""
                INSERT INTO comments_partitioned (id, post_id, user_id, body, created_at)
                SELECT id, post_id, user_id, body, created_at
                FROM comments
                WHERE id > :last_id
                ORDER BY id
                LIMIT :batch
                RETURNING id
            """), {"last_id": last_id, "batch": BATCH})

            rows = result.all()
            if not rows:
                break
            last_id = rows[-1][0]
            await session.commit()
            print(f"Migrated up to id {last_id}")

Step 3: deploy the app that writes to both tables

# app/services/comments.py
async def create_comment(session, data):
    # Double write during the transition
    comment = Comment(**data)
    session.add(comment)
    await session.flush()

    # Also to the partitioned table
    await session.execute(text("""
        INSERT INTO comments_partitioned (id, post_id, user_id, body, created_at)
        VALUES (:id, :post_id, :user_id, :body, :created_at)
    """), {**data, "id": comment.id})

    await session.commit()
    return comment

Step 4: switch reads to partitioned

# Reads now from partitioned
async def get_recent_comments(session, post_id):
    result = await session.execute(text("""
        SELECT * FROM comments_partitioned
        WHERE post_id = :pid
        ORDER BY created_at DESC LIMIT 50
    """), {"pid": post_id})
    return result.mappings().all()

Step 5: stop the dual-write, drop the old table

def upgrade() -> None:
    op.execute("DROP TABLE comments")
    op.execute("ALTER TABLE comments_partitioned RENAME TO comments")

This requires careful coordination. For details, guide #13 module 5.

Benchmark with partitioning

EXPLAIN ANALYZE
SELECT * FROM comments
WHERE post_id = 123
  AND created_at > NOW() - INTERVAL '7 days'
ORDER BY created_at DESC LIMIT 50;
Append (cost=...)
  -> Index Scan on comments_2026_05  (only partition scanned)
        Index Cond: (post_id = 123)

Partition pruning: only the current month is scanned. ~10x faster than the full table.


Component 5: recursive categories with a CTE

Case: show the category subtree with a post count per category.

Endpoint

@router.get("/categories/{cat_id}/tree")
async def get_category_tree(cat_id: int, db = Depends(get_db)):
    """Subtree of a category with post count."""
    result = await db.execute(text("""
        WITH RECURSIVE subtree AS (
            SELECT id, name, parent_id, 0 AS depth
            FROM categories WHERE id = :cat_id
            UNION ALL
            SELECT c.id, c.name, c.parent_id, s.depth + 1
            FROM categories c JOIN subtree s ON c.parent_id = s.id
        )
        SELECT
            s.id, s.name, s.depth,
            COUNT(p.id) AS post_count
        FROM subtree s
        LEFT JOIN posts p ON p.category_id = s.id
        GROUP BY s.id, s.name, s.depth
        ORDER BY s.depth, s.name
    """), {"cat_id": cat_id})

    return result.mappings().all()

Breadcrumb endpoint

@router.get("/categories/{cat_id}/breadcrumb")
async def get_breadcrumb(cat_id: int, db = Depends(get_db)):
    """Path from root to this category."""
    result = await db.execute(text("""
        WITH RECURSIVE ancestors AS (
            SELECT id, name, parent_id, 0 AS depth
            FROM categories WHERE id = :cat_id
            UNION ALL
            SELECT c.id, c.name, c.parent_id, a.depth + 1
            FROM categories c JOIN ancestors a ON c.id = a.parent_id
        )
        SELECT name FROM ancestors ORDER BY depth DESC
    """), {"cat_id": cat_id})

    return [row[0] for row in result]

Filter posts by category including subcategories

@router.get("/posts/by-category/{cat_id}")
async def posts_by_category_recursive(cat_id: int, db = Depends(get_db)):
    """Posts in a category OR its subcategories."""
    result = await db.execute(text("""
        WITH RECURSIVE category_tree AS (
            SELECT id FROM categories WHERE id = :cat_id
            UNION ALL
            SELECT c.id FROM categories c
            JOIN category_tree ct ON c.parent_id = ct.id
        )
        SELECT p.id, p.title, p.published_at
        FROM posts p
        WHERE p.category_id IN (SELECT id FROM category_tree)
        ORDER BY p.published_at DESC
        LIMIT 50
    """), {"cat_id": cat_id})

    return result.mappings().all()

Component 6: advisory lock on the crons

Problem: two concurrent crons can run at the same time: MV refresh or FTS re-indexing. We need to guarantee a single instance.

Refresh cron with an advisory lock

# app/tasks/refresh_top_posts.py
import asyncio
from sqlalchemy import text
from app.database import SessionLocal
from app.services.advisory_locks import advisory_xact_lock_ns, LockNamespace


REFRESH_LOCK = hash("cron:refresh_top_posts") & 0x7FFFFFFF


async def refresh_top_posts():
    async with SessionLocal() as session:
        async with session.begin():
            async with advisory_xact_lock_ns(
                session, LockNamespace.REFRESH_MV, REFRESH_LOCK
            ) as got_lock:
                if not got_lock:
                    print("Refresh already running, skipping")
                    return

                await session.execute(text("""
                    REFRESH MATERIALIZED VIEW CONCURRENTLY top_posts_weekly
                """))
                print(f"Refreshed top_posts_weekly")


if __name__ == "__main__":
    asyncio.run(refresh_top_posts())

Crontab:

*/30 * * * * cd /app && python -m app.tasks.refresh_top_posts

FTS re-indexing cron with an advisory lock + savepoints

# app/tasks/reindex_fts.py
async def reindex_posts_fts():
    async with SessionLocal() as session:
        async with session.begin():
            async with advisory_xact_lock_ns(
                session, LockNamespace.CRON, hash("reindex_fts") & 0x7FFFFFFF
            ) as got_lock:
                if not got_lock:
                    print("Reindex already running")
                    return

                # The search vector is generated, it doesn't normally need reindex
                # But if you want to re-process (e.g. the dictionary changed), refresh the GIN
                await session.execute(text("REINDEX INDEX CONCURRENTLY idx_posts_search_vector"))


if __name__ == "__main__":
    asyncio.run(reindex_posts_fts())

REINDEX INDEX CONCURRENTLY is the equivalent operation for refreshing a GIN — it doesn't lock writes during the rebuild.

Bulk import of comments with savepoints

@router.post("/comments/bulk")
async def bulk_import_comments(items: list[dict], db = Depends(get_db)):
    success = 0
    errors = []

    for i, item in enumerate(items):
        try:
            async with db.begin_nested():
                comment = Comment(**item)
                db.add(comment)
            success += 1
        except IntegrityError as e:
            errors.append({"index": i, "error": str(e)})

    await db.commit()
    return {"success": success, "errors": errors}

Invalid items don't roll back the valid ones — a savepoint per item.


Final state of the project

6 components implemented:

  1. ✅ JSONB metadata + GIN index (modules 1-2).
  2. ✅ Spanish FTS + pg_trgm fallback (module 3).
  3. ✅ Materialized view top_posts_weekly (module 5).
  4. ✅ Partitioning of comments by month (module 4 + #13 module 5 zero-downtime).
  5. ✅ Recursive categories with a CTE (module 8).
  6. ✅ Advisory lock on the crons + savepoints in bulk (module 6).

Plus module 7 extensions:

  • citext for users.email (case-insensitive).
  • pg_trgm for the fuzzy fallback in search.

Final benchmarks (before/after)

EndpointBaselineAfter RefactorImprovement
Search145ms (LIKE)8ms (FTS)18x
Popular posts850ms (joins)4ms (MV)212x
By tagN/A (new)5ms (JSONB GIN)new capability
Recent comments per post18ms4ms (partitioning)4.5x
Category treeN/A (new)12ms (recursive CTE)new capability
BreadcrumbN/A (new)6msnew capability
Cron refreshoverlapped runssafe (advisory lock)reliability ↑
Bulk importall-or-nothingpartial successUX ↑

Integration tests

# tests/test_refactor.py

@pytest.mark.asyncio
async def test_jsonb_metadata_search(client):
    """A JSONB containment query uses the GIN index."""
    response = await client.get("/posts/by-tag/python")
    assert response.status_code == 200
    posts = response.json()
    assert all("python" in p["metadata"]["tags"] for p in posts)


@pytest.mark.asyncio
async def test_fts_search_finds_relevant(client):
    """FTS finds relevant results with stemming."""
    response = await client.get("/search?q=programando")
    assert response.status_code == 200
    body = response.json()
    # Should find posts with 'programa', 'programar', 'programación' (Spanish stemming)
    assert len(body["results"]) > 0


@pytest.mark.asyncio
async def test_search_with_typo_returns_suggestions(client):
    """Typo in the search → suggestions with pg_trgm."""
    response = await client.get("/search?q=Phyton")  # typo
    body = response.json()
    if len(body["results"]) < 5:
        assert len(body["suggestions"]) > 0
        assert any("python" in s["title"].lower() for s in body["suggestions"])


@pytest.mark.asyncio
async def test_top_posts_uses_mv(client):
    """The popular endpoint is fast (MV)."""
    import time
    start = time.perf_counter()
    response = await client.get("/posts/popular")
    elapsed = (time.perf_counter() - start) * 1000

    assert response.status_code == 200
    assert elapsed < 50  # MV should be <50ms


@pytest.mark.asyncio
async def test_category_tree_recursive(client):
    """The recursive CTE returns the complete subtree."""
    response = await client.get("/categories/1/tree")
    body = response.json()
    assert len(body) > 1  # has descendants
    depths = [c["depth"] for c in body]
    assert max(depths) > 0  # has levels


@pytest.mark.asyncio
async def test_advisory_lock_prevents_double_run(monkeypatch):
    """Two concurrent crons — only one runs."""
    from app.tasks.refresh_top_posts import refresh_top_posts

    results = await asyncio.gather(
        refresh_top_posts(),
        refresh_top_posts(),
        return_exceptions=True,
    )
    # Only one does the work; the other skips
    # (verify via logs or internal flags)


@pytest.mark.asyncio
async def test_bulk_partial_success(client):
    """Bulk with an invalid item — the valid ones succeed."""
    payload = [
        {"post_id": 1, "user_id": 1, "body": "Valid"},
        {"post_id": 99999999, "user_id": 1, "body": "Invalid FK"},
        {"post_id": 1, "user_id": 1, "body": "Valid 2"},
    ]
    response = await client.post("/comments/bulk", json=payload)
    body = response.json()
    assert body["success"] == 2
    assert len(body["errors"]) == 1

Common traps and mistakes in phase 2

1. Partitioning without a zero-downtime plan.

Migrating to a partitioned table in production requires the expand-contract pattern. Without it, downtime up to hours.

2. Recursive CTE without an index on parent_id.

Sequential scans on every iteration. The index is mandatory.

3. Session-level advisory lock + PgBouncer transaction mode.

As we saw in module 6, do NOT mix them. Use transaction-level with PgBouncer.

4. Cron without try/finally for the unlock.

If the cron crashes while holding the lock, it stays held. A context manager (async with advisory_xact_lock) handles it automatically.

5. Tests without a real Postgres.

RLS, triggers, partitioning, CTEs can't be mocked. A real Postgres (testcontainers) is mandatory.

6. Forgetting to refresh the MV.

The cron has to run. If the MV isn't refreshed, the data stays stale indefinitely. Verify the cron is active.

7. FTS re-indexing without REINDEX CONCURRENTLY.

REINDEX INDEX (without CONCURRENTLY) locks writes. For production, always CONCURRENTLY.


Summary and next step

What you have now:

  • 6 components implemented.
  • Zero-downtime migration executed (partitioning).
  • Automated tests verifying each component.
  • Before/after benchmarks documented.
  • Crons protected with advisory locks.

Before moving on:

  • Verify all tests pass.
  • Verify the benchmarks are reproducible.
  • Push to the repo.

In the next capsule we close the guide with the final documentation: BENCHMARKS.md with the complete table, ARCHITECTURE.md with ADRs per decision, updated README. And a reflection on the close of the Data Layer sub-track.


Resources

  • Module 4 (partitioning) of this guide.
  • Module 5 (zero-downtime migrations) of #13.
  • Module 6 (advisory locks + savepoints) of this guide.
  • Capsules 02-05 of this module (recursive CTEs).

Capsule 07 of 08 — Module 8 — Advanced PostgreSQL for Backend Guide