- Overview
- AsyncSession scoping
- RLS GUC binding via after_begin hook
- EDA worker concurrency
- DuckDB per-request isolation
- ObjectStore async patterns
- Transactional boundaries
- Race conditions and mitigations
flyquery is an async-first service built on FastAPI + SQLAlchemy async + asyncpg. Understanding the concurrency model matters when:
- Running multiple API replicas.
- Running multiple worker processes.
- Writing tests that validate RLS isolation.
- Extending the service with new stages or endpoints.
The key principles:
- One AsyncSession per request. Sessions are never shared across requests.
- RLS GUCs are transaction-scoped. They reset automatically when the connection returns to the pool.
- IngestWorker slots claim jobs with
SELECT … FOR UPDATE SKIP LOCKED. No two slots can process the same job. - DuckDB is in-process per-request. No shared connection; no shared ATTACH state.
- ObjectStore operations are async. Concurrent puts/gets on the same key are safe at the storage level; flyquery uses immutable keys (snapshot versioning) to avoid write conflicts.
flyquery uses SQLAlchemy's AsyncSession with async_scoped_session scoped
to the current asyncio Task (via the lock-step
web/conventions/db.py).
# web/conventions/db.py (simplified)
from sqlalchemy.ext.asyncio import AsyncSession, async_scoped_session, create_async_engine
import asyncio
engine = create_async_engine(settings.database_url, ...)
_session_factory = async_scoped_session(
sessionmaker(engine, class_=AsyncSession, expire_on_commit=False),
scopefunc=asyncio.current_task,
)
async def get_db() -> AsyncGenerator[AsyncSession, None]:
async with _session_factory() as session:
yield sessionThe AsyncSession is:
- Injected into controllers and services via FastAPI
Depends(get_db). - Scoped to the HTTP request's asyncio task.
- Closed (and the connection returned to the pool) when the request completes.
Do not store or pass AsyncSession across task boundaries. Background
tasks that spawn from a request handler should acquire their own session.
The TenantContextMiddleware extracts X-Tenant-Id and X-Workspace-Id
from each request. The actual Postgres GUCs (app.tenant_id,
app.workspace_id) are set via an SQLAlchemy after_begin event listener
on the session:
# web/conventions/db.py (after_begin hook, simplified)
from sqlalchemy import event
@event.listens_for(AsyncSession, "after_begin")
def set_tenant_gucs(session, transaction, connection):
tenant_id = get_current_tenant() # from context var
workspace_id = get_current_workspace()
session.execute(
text("SET LOCAL app.tenant_id = :t; SET LOCAL app.workspace_id = :w"),
{"t": tenant_id, "w": workspace_id},
)SET LOCAL scopes the GUC to the current transaction. When the transaction
commits or rolls back, the GUC resets automatically before the connection
returns to the pool. This prevents GUC leakage across requests.
Critical: SET LOCAL (not SET SESSION). SET SESSION would persist
the GUC until the connection is closed, which in a connection pool means
until the next request on that connection — a cross-tenant data leak.
See workers.md for the full operator-facing fleet doc. This section covers the concurrency primitives — semaphore + atomic claim + drain — that make it correct.
IngestWorker claims jobs via an atomic UPDATE … WHERE status='PENDING'
on flyquery_ingest_jobs.status inside a transaction
(ingest_job_repository._mark_running). The UPDATE returns the
claimed row's id; if zero rows came back (another worker or the
RetentionWorker beat us to it), the handler short-circuits. This
provides work-queue semantics across both processes (concurrent ingest
workers) AND cross-process recovery (the retention worker's stuck-job
reaper can republish a job onto the bus, and the next worker to pick
it up uses the same atomic claim to dedupe).
IngestWorker uses a four-piece pattern (semaphore + wait_for + Event
- inflight set) — see
workers.py:
| Primitive | Role |
|---|---|
asyncio.Semaphore(_CONCURRENCY) |
Bounds simultaneous handler tasks. |
asyncio.wait_for(handler, timeout=ingest_handler_timeout_s) |
Caps any one job's wall-clock. |
asyncio.Event (_shutdown) |
Cooperative SIGTERM signal. |
self._inflight: set[Task] |
Tracked so the drain step can wait on (or cancel) them. |
This pattern is verified by
tests/unit/test_ingest_worker_concurrency.py
(4 tests: semaphore cap enforcement under burst, timed-out handler
isolation, drain wait + drain cancel).
Each worker process runs FLYQUERY_INGEST_WORKER_CONCURRENCY asyncio
coroutines (default 4). Each coroutine is an independent asyncio task with
its own AsyncSession and Postgres connection.
With N worker pods each running C coroutines, the total concurrent job
capacity is N × C. The Postgres connection pool size should be at least
N × C + headroom_for_api connections. See
scale-and-performance.md § 5
for the horizontal-scale formula.
SIGTERM received
│
▼
Stop accepting new job claims (set _shutdown Event)
│
▼
_drain_inflight():
1. asyncio.wait_for(gather(*self._inflight), grace)
2. on TimeoutError: cancel() each task, then a second 5s
asyncio.wait_for to give cleanup a chance.
│
▼
Exit
Jobs that did not complete are left in RUNNING. The
RetentionWorker resets them to PENDING and republishes them onto
the bus after processing_lease_s (default 1800s); see
retention_worker.py:162.
This is the recovery primitive for crashed or terminated workers.
_drain_inflight uses asyncio.wait_for(...) for the post-cancel
cleanup window and logs a warning if the hard timeout elapses
(workers.py:163).
The regression test that covers the drain-with-cancel path is in
tests/unit/test_ingest_worker_concurrency.py.
Every query that hits the DuckDB executor (POST /query, POST /sql:execute)
creates a new in-process DuckDB connection:
import duckdb
async def execute(self, sql, *, attached_tables, row_cap, statement_timeout_ms, memory_limit):
conn = duckdb.connect(":memory:", read_only=False) # fresh per call
try:
conn.execute(f"SET memory_limit='{memory_limit}'")
conn.execute(f"SET threads=1")
for qname, parquet_key in attached_tables.items():
conn.execute(f"ATTACH '{parquet_key}' AS {qname} (READ_ONLY)")
# ... execute sql with LIMIT row_cap+1
finally:
conn.close()Properties of this design:
- No shared state. There is no persistent DuckDB database on disk. Each connection starts empty.
- No shared ATTACH. Tables are ATTACHed fresh per query. Two concurrent queries for the same dataset attach the same Parquet files independently.
- Read-only mode on ingested tables.
ATTACH ... (READ_ONLY)prevents writes to Parquet even if the AST firewall were bypassed. - Memory bounded.
SET memory_limitcaps each connection. With N concurrent queries at 4 GB each, API pods needN × 4 GB + OS overheadRAM.
Concurrency concern: DuckDB in-process with asyncio requires care. DuckDB operations are synchronous and CPU-bound. They block the asyncio event loop thread. flyquery runs DuckDB calls in a thread pool executor:
import asyncio
result = await asyncio.get_event_loop().run_in_executor(
_duckdb_thread_pool, _execute_sync, conn, sql
)This keeps the asyncio event loop responsive for incoming requests while DuckDB computes.
The ObjectStore port is fully async. Adapters wrap synchronous provider
SDKs (aiobotocore for S3; gcloud-aio-storage for GCS;
azure-storage-blob async for Azure) in native async clients.
Multiple concurrent uploads (PUT operations) to distinct keys are safe.
flyquery uses immutable, versioned keys:
tables/{table_id}/v{n}.parquet— thev{n}suffix is derived from the snapshot counter; two concurrent ingests for different tables write to different keys.files/{file_id}.{ext}— unique per upload.results/{query_id}.parquet— unique per query.
There is no overwrite scenario in normal operation. A re-upload increments
n and writes to a new key; it does not overwrite the previous snapshot.
Stage 2 (parse) streams the uploaded file from object storage to the FileReader without loading the entire file into memory:
async for chunk in object_store.get(file_key):
await reader.feed(chunk)The put operation in stage 2 also streams:
await object_store.put(parquet_key, parquet_stream_generator, content_type="application/octet-stream")This means a 2 GiB CSV file never occupies 2 GiB of RAM simultaneously during ingestion.
Understanding where transactions begin and end matters for correctness.
Most API handlers use a single transaction for the entire request:
async def create_dataset(body, db: AsyncSession):
dataset = Dataset(...)
db.add(dataset)
audit_event = AuditEvent(...)
db.add(audit_event)
await db.commit() # dataset + audit_event committed together
return datasetEach stage has its own transaction. The stage result is committed before the next stage begins. This means:
- A crash between stage 5 and 6 leaves stage 5 data in the database.
- Stage 6 checks for existing relations before re-inserting.
- All stages are idempotent (except stage 2, which re-reads the original blob).
Stage 10 is a single transaction that:
- Updates
flyquery_schema_snapshots.status = READY. - Updates
flyquery_tables.current_snapshot_id = new_snapshot_id. - Inserts the
SchemaUpdatedoutbox event.
These three writes commit atomically. Either all three commit or none does.
The query pipeline only reads current_snapshot_id; it never sees a table
pointing at a PARTIAL snapshot because the pointer only flips in this
atomic transaction.
RLS GUCs are SET LOCAL — they apply to the current transaction only.
Each transaction in IngestWorker re-sets the GUC at after_begin.
This is correct for background workers: the worker sets tenant_id and
workspace_id from the job record, not from a request header.
If two PARSE_AND_INGEST jobs for the same table_id run concurrently
(e.g., two rapid re-uploads):
- Both proceed through stages 1–2 independently.
- Stage 3 (reconcile + snapshot) inserts new snapshot rows. Snapshots are
uniquely keyed by
(table_id, taken_at, snapshot_hash). - Stage 10 updates
current_snapshot_id. The last writer wins. The earlier snapshot remains inPARTIALstatus and is cleaned up by a background PARTIAL-snapshot sweep after 24 hours.
Mitigation: In practice, the upload endpoint serialises at the HTTP level
(the re-upload path PUT /tables/{id}:upload checks for an active RUNNING
job and returns 409 Conflict).
If an operator annotates a column description via PUT /schema-objects/{id}
while a DESCRIBE_PASS job is running, the operator's annotation
(description_source=HUMAN) takes precedence. The DescribeAgent only
writes to columns where description IS NULL; it skips columns that already
have a description.
New embeddings written in stage 9 become visible to the retrieval pipeline only after the pgvector HNSW index is refreshed. The index refresh is a background operation inside Postgres; there may be a brief window (typically < 100 ms) where new embeddings are in the table but not yet in the HNSW index.
This is acceptable: the retrieval pipeline also has a BM25 fallback (via
content_tsv), which is available immediately after the row is committed.