Databases: SQLite, Postgres, and Per-User Schema Design
Learning Objectives
Explain the relational model and decide when to use SQLite versus Postgres for a gateway backend.
Design normalized tables for users, channels, conversations, and messages with correct keys and indexes.
Access databases asynchronously with aiosqlite and asyncpg connection pools without blocking the event loop.
Apply indexing, migrations, parameterized queries, and per-user data isolation patterns to prevent corruption and cross-user leakage.
Pre-Quiz: Relational Basics
A developer tries to insert a messages row whose conversation_id refers to a conversation that was never created, and the database rejects the insert. Which mechanism is doing this?
A primary key guaranteeing the message ID is unique
A foreign key enforcing referential integrity against the conversations table
An index speeding up the lookup of the conversation
A transaction rolling back the insert atomically
Your gateway runs as a single process on one machine, is overwhelmingly read-heavy, and you want tests to run against a real database in milliseconds with zero setup. Which choice best fits, and why?
Postgres, because it uses MVCC so readers never block writers
SQLite, because it is an in-process single-file library needing no server
Postgres, because SQLite cannot provide ACID transactions
SQLite, because it supports unlimited simultaneous writers per file
Recording a message inserts into messages and updates last_message_at on the conversation. The process crashes between the two writes. Which ACID property ensures the database is not left with a "last message" timestamp for a message that was never stored?
Durability
Isolation
Atomicity
Consistency of data types
1. Relational Basics
Key Points
A relational database stores data as rows in typed tables; a primary key uniquely identifies each row, and a foreign key enforces referential integrity so records can never point at data that does not exist.
SQLite is an embedded single-file library that runs inside your process ("competes with fopen()"); it allows unlimited readers but only one writer at a time.
Postgres is a separate client/server process using MVCC, so readers never block writers and many clients can write concurrently.
Both are ACID (Atomic, Consistent, Isolated, Durable); wrap any multi-statement write in a transaction so a crash cannot leave data half-applied.
Pragmatic path: "SQLite until you have a reason to switch," then a planned one-time migration to Postgres for production write concurrency.
The relational model organizes data into tables. Each row (a tuple) is one record — one user, one message — and each column is a typed field shared by every row. Two kinds of keys give tables their power. A primary key uniquely identifies each row (no two users share a user_id). A foreign key is a column that must match a primary key in another table, enforcing referential integrity: the database refuses to insert a message pointing at a conversation that does not exist. Relationships come in three shapes: one-to-one, one-to-many (a conversation has many messages), and many-to-many (users and conversations), resolved with a linking table.
SQLite and Postgres both speak SQL and provide ACID transactions, but sit at opposite ends of the deployment spectrum. SQLite is an embedded library that reads and writes a single file inside your own process; the project frames it as competing "with fopen()," not with a database server. Postgres is a separate server process you connect to over a socket or network. Concurrency is the decisive difference: SQLite supports unlimited readers but only one writer at a time (WAL mode lets readers proceed alongside that writer), while Postgres uses MVCC (Multi-Version Concurrency Control) so readers and writers never block each other and hundreds of clients can write simultaneously.
Dimension
SQLite
PostgreSQL
Architecture
Embedded library, single file in-process
Separate client/server process over socket/network
A transaction is a group of operations treated as one indivisible unit, guaranteed to be ACID. Atomicity: all succeed or none do. Consistency: constraints always hold. Isolation: concurrent transactions do not see each other's uncommitted changes. Durability: committed data survives a crash. Atomicity is why we wrap "insert message + bump last_message_at" in one transaction — a crash between the two writes can never leave the timestamp lying about a message that was not stored. Isolation levels (default READ COMMITTED, stricter SERIALIZABLE) trade consistency against throughput; for a chat gateway the defaults are almost always correct.
Visual animation — coming soon
Post-Quiz: Relational Basics
A developer tries to insert a messages row whose conversation_id refers to a conversation that was never created, and the database rejects the insert. Which mechanism is doing this?
A primary key guaranteeing the message ID is unique
A foreign key enforcing referential integrity against the conversations table
An index speeding up the lookup of the conversation
A transaction rolling back the insert atomically
Your gateway runs as a single process on one machine, is overwhelmingly read-heavy, and you want tests to run against a real database in milliseconds with zero setup. Which choice best fits, and why?
Postgres, because it uses MVCC so readers never block writers
SQLite, because it is an in-process single-file library needing no server
Postgres, because SQLite cannot provide ACID transactions
SQLite, because it supports unlimited simultaneous writers per file
Recording a message inserts into messages and updates last_message_at on the conversation. The process crashes between the two writes. Which ACID property ensures the database is not left with a "last message" timestamp for a message that was never stored?
Durability
Isolation
Atomicity
Consistency of data types
Pre-Quiz: Schema Design for a Gateway
One human uses both Slack and Discord, and the gateway must remember them as a single person across both. What design makes this work?
Store the Slack and Discord usernames directly in every message row
A channels table with UNIQUE (platform, platform_user_id) mapping each identity to one internal user_id
A separate users table per platform, joined at query time
A composite index on messages(conversation_id, sent_at)
Why does the schema store only sender_id on each message and join to users for the name, rather than storing sender_username inline in every message row?
Because storing text is not allowed in a normalized column
Because a rename would otherwise force updating the name in thousands of rows — an update anomaly
Because foreign keys forbid any text columns on child tables
Because joins are always faster than reading a stored column
The dominant read query is "fetch the most recent N messages in a conversation, paginated." Which single addition best serves it, and what SQLite-specific caveat must you also remember for foreign keys?
An index on messages(content); no SQLite caveat applies
A composite index on messages(conversation_id, sent_at); and PRAGMA foreign_keys = ON must be set per connection in SQLite
A denormalized last_message_at column; SQLite enforces foreign keys automatically
A primary key on message_id alone; foreign keys need no configuration
2. Schema Design for a Gateway
Key Points
Five tables model the whole gateway: users, channels, conversations, a conversation_participants junction, and messages.
A type column plus the participants junction represents both DMs and groups; a role column on messages ('user'/'assistant'/'system') aligns storage with LLM chat formats.
Normalize to 3NF: store IDs, not duplicated names, to avoid update anomalies. The one sanctioned denormalization is last_message_at for fast inbox sorting.
Use ON DELETE CASCADE to prevent orphans — but remember PRAGMA foreign_keys = ON in SQLite (off by default; Postgres enforces unconditionally).
A composite index on messages(conversation_id, sent_at) plus foreign-key indexes serve the dominant paginated-retrieval query.
A chat backend maps cleanly onto the relational model. The gateway uses five related tables. A users table holds one row per account, independent of platform. A channels table records each platform integration plus the platform-specific identity, so one human on both Slack and Discord resolves to a single user_id. A conversations table holds one row per thread with a type column and a denormalized last_message_at. A conversation_participants junction resolves the many-to-many between users and conversations. A messages table holds one row per turn, carrying a role column that aligns with LLM chat-completion formats.
Figure 11.1: Entity-Relationship diagram of the gateway schema. Read the crow's-foot ||--o{ as "has many."
erDiagram
users {
bigserial user_id PK
text display_name
text email UK
text status
timestamptz created_at
}
channels {
bigserial channel_id PK
bigint user_id FK
text platform
text platform_user_id
timestamptz created_at
}
conversations {
bigserial conversation_id PK
text type
text title
timestamptz created_at
timestamptz last_message_at
}
conversation_participants {
bigint conversation_id PK,FK
bigint user_id PK,FK
timestamptz joined_at
timestamptz last_read_at
}
messages {
bigserial message_id PK
bigint conversation_id FK
bigint sender_id FK
text role
text content
timestamptz sent_at
}
users ||--o{ channels : "has platform identity"
users ||--o{ conversation_participants : "is member of"
conversations ||--o{ conversation_participants : "has member"
conversations ||--o{ messages : "contains"
users ||--o{ messages : "sends (sender_id)"
Here is the worked schema (Postgres syntax; BIGSERIAL becomes INTEGER PRIMARY KEY and TIMESTAMPTZ becomes TEXT in SQLite):
CREATE TABLE users (
user_id BIGSERIAL PRIMARY KEY,
display_name TEXT NOT NULL,
email TEXT UNIQUE,
status TEXT NOT NULL DEFAULT 'active',
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE channels (
channel_id BIGSERIAL PRIMARY KEY,
user_id BIGINT NOT NULL REFERENCES users(user_id) ON DELETE CASCADE,
platform TEXT NOT NULL, -- 'slack' | 'discord' | 'webex'
platform_user_id TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE (platform, platform_user_id) -- one identity maps to one user
);
CREATE TABLE conversations (
conversation_id BIGSERIAL PRIMARY KEY,
type TEXT NOT NULL DEFAULT 'direct', -- 'direct' | 'group'
title TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
last_message_at TIMESTAMPTZ -- denormalized for inbox sort
);
CREATE TABLE conversation_participants (
conversation_id BIGINT NOT NULL REFERENCES conversations(conversation_id) ON DELETE CASCADE,
user_id BIGINT NOT NULL REFERENCES users(user_id) ON DELETE CASCADE,
joined_at TIMESTAMPTZ NOT NULL DEFAULT now(),
last_read_at TIMESTAMPTZ,
PRIMARY KEY (conversation_id, user_id) -- composite PK: no duplicate members
);
CREATE TABLE messages (
message_id BIGSERIAL PRIMARY KEY,
conversation_id BIGINT NOT NULL REFERENCES conversations(conversation_id) ON DELETE CASCADE,
sender_id BIGINT REFERENCES users(user_id), -- NULL for assistant/system
role TEXT NOT NULL, -- 'user'|'assistant'|'system'
content TEXT NOT NULL,
sent_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
Normalization stores each fact exactly once. Third Normal Form (3NF) — the practical target — means non-key columns depend only on the primary key. Do not store sender_username inline in every message; a rename would force updating thousands of rows (an update anomaly). Store only sender_id and join. The one sanctioned denormalization is last_message_at, kept in sync on each insert so the inbox sorts without scanning messages.
ON DELETE CASCADE removes participant rows and messages when a conversation is deleted, preventing orphans. Critical SQLite caveat: foreign key enforcement is off by default and must be enabled per connection with PRAGMA foreign_keys = ON;. Postgres enforces unconditionally. An index lets the database find rows without scanning the whole table (like a book's back-of-index). The dominant query — recent N messages per conversation, paginated — is served by a composite index on messages(conversation_id, sent_at). Also index foreign-key columns, because Postgres does not auto-index the referencing side.
CREATE INDEX idx_messages_conv_time ON messages (conversation_id, sent_at);
CREATE INDEX idx_messages_sender ON messages (sender_id);
CREATE INDEX idx_participants_user ON conversation_participants (user_id);
Linking accounts: when an event arrives, ingress looks up (platform, platform_user_id) in channels. A hit yields the internal user_id; a miss creates a new users row and a channels row in one transaction. The UNIQUE (platform, platform_user_id) constraint guarantees each platform identity resolves to exactly one internal user, while multiple channels rows can point at the same user_id — that is account linking. From then on, every message and conversation attaches to the stable internal ID, not the volatile platform ID.
Post-Quiz: Schema Design for a Gateway
One human uses both Slack and Discord, and the gateway must remember them as a single person across both. What design makes this work?
Store the Slack and Discord usernames directly in every message row
A channels table with UNIQUE (platform, platform_user_id) mapping each identity to one internal user_id
A separate users table per platform, joined at query time
A composite index on messages(conversation_id, sent_at)
Why does the schema store only sender_id on each message and join to users for the name, rather than storing sender_username inline in every message row?
Because storing text is not allowed in a normalized column
Because a rename would otherwise force updating the name in thousands of rows — an update anomaly
Because foreign keys forbid any text columns on child tables
Because joins are always faster than reading a stored column
The dominant read query is "fetch the most recent N messages in a conversation, paginated." Which single addition best serves it, and what SQLite-specific caveat must you also remember for foreign keys?
An index on messages(content); no SQLite caveat applies
A composite index on messages(conversation_id, sent_at); and PRAGMA foreign_keys = ON must be set per connection in SQLite
A denormalized last_message_at column; SQLite enforces foreign keys automatically
A primary key on message_id alone; foreign keys need no configuration
Pre-Quiz: Per-User Data Design
In shared-schema multi-tenancy, what is the fundamental weakness that Postgres Row-Level Security (RLS) is designed to eliminate?
Tables grow too large to index efficiently
Isolation lives in application code, so a forgotten WHERE user_id leaks one user's data to another
Migrations must be applied across thousands of separate schemas
Foreign keys cannot span multiple tenants
A handler scopes its query using a user_id taken from a field in the incoming request body. Why is this a serious isolation flaw even if RLS and a data-access layer are also present?
Request bodies cannot contain integers
An attacker can supply any user_id, defeating every isolation mechanism above the database
RLS overrides the request body automatically, so it is merely redundant
The user_id must be a string, not an integer, to be safe
On a live production Postgres gateway, why prefer an additive, backward-compatible migration (add a nullable column, backfill, then enforce) over a single destructive change?
Additive changes cannot fail, so no timeouts are needed
Destructive changes can take locks that block writes and freeze the application; additive steps avoid that
Nullable columns are always faster to query than non-nullable ones
Backward-compatible migrations do not need to be versioned
3. Per-User Data Design
Key Points
Shared-schema multi-tenancy stores all tenants' rows in one set of tables, filtered by user_id — simplest and most scalable, but isolation lives in application code.
Schema-per-tenant gives strong isolation but bloats catalogs and makes migrations slow across thousands of schemas.
Postgres Row-Level Security (RLS) moves the tenant filter into the database engine, eliminating the "forgotten WHERE user_id" leak.
Always scope by an authenticated identity from ingress — never derive the tenant ID from untrusted client input.
Migrations are versioned scripts; on production Postgres guard them with lock_timeout/statement_timeout and prefer additive, backward-compatible changes.
Multi-tenancy serves many independent users from one application while keeping each tenant's data separate. The shared-schema approach stores every tenant's rows in the same tables, distinguished by a tenant_id (here, effectively user_id) column; every query filters WHERE user_id = $1. It is the simplest to operate and scales to enormous tenant counts, but isolation lives in application code — forget the filter and you leak. The schema-per-tenant approach gives strong logical isolation but bloats system catalogs and makes migrations slow across thousands of schemas.
The modern default is shared schema hardened with Postgres Row-Level Security (RLS): a policy attached to a table so the engine itself appends the tenant filter to every query. RLS eliminates the "forgotten WHERE tenant_id" bug at the database level — even a query that omits the filter returns only the current tenant's rows.
Figure 11.2: Shared-schema row isolation — every query scoped to the authenticated user.
flowchart TD
req["Inbound request (authenticated user_id from ingress)"] --> setctx["Set app.current_user_id at start of request"]
setctx --> query["Query: SELECT ... FROM messages"]
query --> rls{"RLS policy: row's conversation includes current user?"}
rls -->|"yes"| ownrows["Return rows for this user's conversations"]
rls -->|"no"| blocked["Rows filtered out (other tenants invisible)"]
subgraph shared["Shared messages table (all tenants)"]
rowA["Rows: user 101's conversations"]
rowB["Rows: user 202's conversations"]
rowC["Rows: user 303's conversations"]
end
ownrows -.->|"reads only"| rowA
blocked -.->|"never returned"| rowB
blocked -.->|"never returned"| rowC
ALTER TABLE messages ENABLE ROW LEVEL SECURITY;
CREATE POLICY messages_isolation ON messages
USING (
conversation_id IN (
SELECT conversation_id FROM conversation_participants
WHERE user_id = current_setting('app.current_user_id')::bigint
)
);
Two disciplines defend against cross-user leakage — the most dangerous class of bug because it is silent, a privacy breach, and can persist undetected. First, make the tenant filter unavoidable: funnel all access through a thin data-access layer whose functions always take a user_id and always include it in the WHERE clause, backed by RLS as the final line of defense. Second, never trust the client's claimed identity: the user_id must come from the authenticated identity established at ingress (Chapter 5), not from a request-body field. A user who can pass an arbitrary user_id has defeated every isolation mechanism above it.
A schema is never finished. A migration is a versioned, ordered script that applies one change so every environment reaches the same state deterministically. Tools like Alembic record which migrations ran and apply pending ones in order. Migrations also execute the SQLite-to-Postgres graduation without surprises. Production migrations carry an operational hazard: locking. Adding a foreign key on Postgres takes a lock that can block writes while it validates rows. Set lock_timeout and statement_timeout so a migration fails fast rather than freezing the app, and prefer additive, backward-compatible changes (add a nullable column, backfill, then enforce) over destructive ones.
Visual animation — coming soon
Post-Quiz: Per-User Data Design
In shared-schema multi-tenancy, what is the fundamental weakness that Postgres Row-Level Security (RLS) is designed to eliminate?
Tables grow too large to index efficiently
Isolation lives in application code, so a forgotten WHERE user_id leaks one user's data to another
Migrations must be applied across thousands of separate schemas
Foreign keys cannot span multiple tenants
A handler scopes its query using a user_id taken from a field in the incoming request body. Why is this a serious isolation flaw even if RLS and a data-access layer are also present?
Request bodies cannot contain integers
An attacker can supply any user_id, defeating every isolation mechanism above the database
RLS overrides the request body automatically, so it is merely redundant
The user_id must be a string, not an integer, to be safe
On a live production Postgres gateway, why prefer an additive, backward-compatible migration (add a nullable column, backfill, then enforce) over a single destructive change?
Additive changes cannot fail, so no timeouts are needed
Destructive changes can take locks that block writes and freeze the application; additive steps avoid that
Nullable columns are always faster to query than non-nullable ones
Backward-compatible migrations do not need to be versioned
Pre-Quiz: Async Database Access
Why is using a native async driver like asyncpg a correctness requirement, not just a performance nicety, in the asyncio gateway?
Async drivers are the only ones that support parameterized queries
A synchronous driver waiting on a socket blocks the event loop, freezing every other coroutine
Only async drivers can talk to Postgres over the network
Synchronous drivers cannot open more than one connection
Why create a single process-wide connection pool at startup with a bounded max_size, rather than opening a fresh connection per request?
Pools disable transactions, which speeds up writes
Reusing warm connections avoids costly per-request setup, and max_size bounds the load placed on the database
A per-request connection cannot run parameterized queries
Pools are required for the database to enforce foreign keys
A chat gateway feeds arbitrary user text straight into queries. Which practice is the definitive defense against SQL injection, and why does it work?
Escaping quotes in the user text before formatting it into the SQL string
Passing values as bound parameters ($1, ?) so the database treats them as data, never as SQL
Wrapping the query in a transaction so a malicious statement rolls back
Running the query inside a connection pool, which sanitizes input automatically
You launch thousands of DB tasks with asyncio.gather and latency balloons as they all pile up on pool.acquire(). What is the recommended fix?
Raise max_size to match the number of tasks so none ever wait
Bound the fan-out with an asyncio.Semaphore so you never spawn more concurrent tasks than the pool can serve
Switch to a synchronous driver to serialize the work
Remove per-query timeouts so slow queries can finish
4. Async Database Access
Key Points
A single blocking call stalls every coroutine; async DB access is a correctness requirement. Use asyncpg (Postgres) and aiosqlite (SQLite/dev).
Create one process-wide connection pool at startup; max_size bounds the load your gateway places on the database. Never create a pool per request.
Borrow with async with pool.acquire() (auto-returns on error) and wrap multi-statement writes in async with conn.transaction() for atomicity.
Bound fan-out with an asyncio.Semaphore and set explicit per-query asyncio.timeout(...) to keep the loop healthy under load.
Parameterized queries ($1 in asyncpg, ? in aiosqlite) are the definitive defense against SQL injection — never string-format user input into SQL.
The cardinal asyncio rule applies with full force: a single blocking call stalls every concurrent coroutine. A synchronous driver waiting on a socket blocks the loop, so no other webhook is acknowledged and no worker progresses — the gateway freezes. asyncpg solves this for Postgres: written for the event loop, it performs genuinely non-blocking I/O, implements the Postgres binary wire protocol directly, and uses prepared statements by default. For SQLite in dev and tests, aiosqlite wraps calls in a background thread to present an async interface.
Pooling is essential. Opening a new Postgres connection is expensive (TCP handshake, TLS, auth, backend startup); reusing pooled connections can cut short-query latency by 10–100x. A connection pool maintains established connections that coroutines borrow and return; asyncpg ships pooling built in. Create a single, process-wide pool at startup — never per request. min_size keeps connections warm; max_sizebounds the load your app can place on the database so a stampede of coroutines cannot open unlimited connections.
Figure 11.3: A coroutine borrowing and returning a pooled connection.
sequenceDiagram
participant Coro as Coroutine
participant Pool as asyncpg pool (max_size bound)
participant Conn as Pooled connection
participant PG as PostgreSQL server
Coro->>Pool: async with pool.acquire()
alt Idle connection available
Pool-->>Coro: Hand out warm connection
else All connections busy
Pool-->>Coro: Wait until one is returned
end
Coro->>Conn: await conn.fetch(...) / execute(...)
Conn->>PG: Send query (binary wire protocol)
PG-->>Conn: Rows / result
Conn-->>Coro: Awaited result
Coro->>Pool: Exit context — auto-return connection
Note over Pool,Conn: Connection reset and kept warm for reuse (no reconnect)
import asyncpg
pool: asyncpg.Pool | None = None
async def init_db() -> None:
"""Call once at application startup (e.g., a FastAPI lifespan handler)."""
global pool
pool = await asyncpg.create_pool(
dsn="postgresql://user:pass@host/gateway",
min_size=4, # connections kept warm
max_size=20, # hard cap on concurrency to the DB
statement_cache_size=512, # speeds repeated queries
)
Borrow a connection with an async context manager (guaranteed return even on error) and wrap multi-statement writes in a transaction. Here the schema and async pool tie together — recording a message and bumping the conversation atomically:
async def store_message(conversation_id: int, sender_id: int,
role: str, content: str) -> int:
async with pool.acquire() as conn: # borrow, auto-return
async with conn.transaction(): # atomic: both or neither
message_id = await conn.fetchval(
"""
INSERT INTO messages (conversation_id, sender_id, role, content)
VALUES ($1, $2, $3, $4)
RETURNING message_id
""",
conversation_id, sender_id, role, content,
)
await conn.execute(
"UPDATE conversations SET last_message_at = now() "
"WHERE conversation_id = $1",
conversation_id,
)
return message_id
Several patterns keep the loop healthy. Bound fan-out: guard many concurrent DB tasks with an asyncio.Semaphore so you never spawn thousands of coroutines contending for max_size connections — otherwise they queue on acquire() and latency balloons. Set explicit timeouts with asyncio.timeout(...) so a slow query cannot pin a connection forever. And never mix a blocking sync driver into the async path; offload blocking code with run_in_executor.
Look again at every example: each user value is passed as a separate argument ($1, $2), not glued into the SQL string. This is a parameterized query — the database receives the template and the values separately, so a value can never be interpreted as SQL. SQL injection is the attack this defeats: build a query with f"... WHERE content = '{user_input}'" and an attacker sending '; DROP TABLE messages; -- injects their own SQL. Because a chat gateway feeds arbitrary user text into queries, this is the expected threat, not a theoretical one. asyncpg uses numbered placeholders ($1); aiosqlite uses ?; both bind safely. The rule is absolute: never interpolate user input into SQL, always bind it as a parameter.
Post-Quiz: Async Database Access
Why is using a native async driver like asyncpg a correctness requirement, not just a performance nicety, in the asyncio gateway?
Async drivers are the only ones that support parameterized queries
A synchronous driver waiting on a socket blocks the event loop, freezing every other coroutine
Only async drivers can talk to Postgres over the network
Synchronous drivers cannot open more than one connection
Why create a single process-wide connection pool at startup with a bounded max_size, rather than opening a fresh connection per request?
Pools disable transactions, which speeds up writes
Reusing warm connections avoids costly per-request setup, and max_size bounds the load placed on the database
A per-request connection cannot run parameterized queries
Pools are required for the database to enforce foreign keys
A chat gateway feeds arbitrary user text straight into queries. Which practice is the definitive defense against SQL injection, and why does it work?
Escaping quotes in the user text before formatting it into the SQL string
Passing values as bound parameters ($1, ?) so the database treats them as data, never as SQL
Wrapping the query in a transaction so a malicious statement rolls back
Running the query inside a connection pool, which sanitizes input automatically
You launch thousands of DB tasks with asyncio.gather and latency balloons as they all pile up on pool.acquire(). What is the recommended fix?
Raise max_size to match the number of tasks so none ever wait
Bound the fan-out with an asyncio.Semaphore so you never spawn more concurrent tasks than the pool can serve
Switch to a synchronous driver to serialize the work
Remove per-query timeouts so slow queries can finish