---
tags: [sqlalchemy, async, database, models, queries, savepoint, pgvector]
modules: [src/arandu/write/, src/arandu/read/, src/arandu/background/]
applies_to: [models, services, handlers]
confidence: inferred
---
# Pattern: Async SQLAlchemy Data Access

<!-- vibeflow:auto:start -->
## What
All database operations use async SQLAlchemy with savepoint isolation. Models use `Mapped` type annotations. Vector similarity uses raw SQL with pgvector operators.

## Where
- `src/arandu/db.py` — Engine, session factory, Base class
- `src/arandu/models.py` — 10 ORM models
- All pipeline modules (write/read/background) that query the database

## The Pattern
1. **Session management**: `MemoryClient` creates `async_sessionmaker`. Pipeline functions receive `AsyncSession` as parameter.
2. **Transaction boundary**: `MemoryClient.write()` wraps pipeline in `async with self._session_factory() as session:` and calls `session.commit()`.
3. **Savepoint isolation**: Individual operations use `async with session.begin_nested():` to isolate failures.
4. **Model style**: SQLAlchemy 2.0 declarative with `Mapped[T]` and `mapped_column()`.
5. **Vector queries**: Raw SQL via `text()` with pgvector `<=>` operator for cosine distance.
6. **Query patterns**: `select(Model).where(...).order_by(...).limit(N)` with `result.scalars().all()`.

## Rules
- Pipeline functions do NOT commit — the caller manages the transaction.
- Use `session.begin_nested()` (savepoint) for operations that might fail independently.
- Use `session.flush()` after `session.add()` when you need the ID immediately.
- Use `defer(Model.embedding)` to exclude large columns from queries when not needed.
- Vector operations use raw `text()` SQL because SQLAlchemy ORM doesn't natively support pgvector operators.
- `user_id` is `Integer` in models but passed as `str` in pipeline functions (cast happens implicitly).

## Examples from this codebase
File: src/arandu/write/reconcile.py
```python
async def _fetch_entity_facts(session, user_id, entity_key):
    stmt = (
        select(MemoryFact)
        .where(
            MemoryFact.user_id == user_id,
            MemoryFact.entity_key == entity_key,
            MemoryFact.valid_to.is_(None),
        )
        .order_by(MemoryFact.valid_from.desc())
        .limit(50)
    )
    result = await session.execute(stmt)
    return list(result.scalars().all())
```

File: src/arandu/write/reconcile.py (pgvector query)
```python
query = text("""
    SELECT id,
           1 - (embedding_vec <=> CAST(:embedding AS vector)) as similarity
    FROM memory_facts
    WHERE user_id = :user_id AND entity_key = :entity_key
      AND valid_to IS NULL AND embedding_vec IS NOT NULL
    ORDER BY embedding_vec <=> CAST(:embedding AS vector)
    LIMIT 3
""")
async with session.begin_nested():
    result = await session.execute(query, {"user_id": ..., "embedding": str(embedding)})
```

File: src/arandu/write/upsert.py (savepoint per operation)
```python
for decision in decisions:
    try:
        if decision.action == "ADD":
            async with session.begin_nested():
                await _add_fact(session, ...)
            result.added += 1
    except Exception as e:
        logger.warning("execute_upsert: failed to execute %s: %s", decision.action, e)
```
<!-- vibeflow:auto:end -->
