postgresql
The RAG client was stealing my connections

We keep our embeddings in PostgreSQL. Not in a vector database next to it — in
the same instance, in pgvector columns on the same tables the application
reads, so a retrieval query can join to organisation and permissions without a
second round trip and there is one backup, one migration story, one thing to
be on call for. I still think that is right. It has one consequence that the
"just use Postgres" argument tends to skip, and it caught up with us last
weekend.
P2028
The symptom was P2028: unable to start a transaction in the given time,
arriving in bursts from the API under moderate load. Not from a slow query.
From Prisma failing to acquire a connection within its default two-second
wait before even beginning the transaction. The database was fine. The pool
was empty.
Our Postgres plan allows the role roughly twenty connections. I knew that, and I thought I had sized for it. Then I counted the clients.
Three clients, one role
The application had three separate pools pointed at the same DATABASE_URL,
written at three different times by people solving three different problems,
and none of them knew about the others:
- The main Prisma client, with a pool max of 15. Serving HTTP requests, and — this is the part that matters — also serving the twenty-odd background jobs that run inside the same web process.
- The analytics Prisma client, a second instance against a reporting connection string that happens to share the role, with a pool max of 10.
- The RAG service, a plain
pg.Poolforpgvectorqueries, withmax: 20.
Fifteen plus ten plus twenty against a limit of twenty. The retrieval pool on its own was sized to the entire role. Nothing had gone wrong until now only because pools grow lazily and the RAG path was not busy.
There is a wrinkle that made this worse than the arithmetic. We moved to
Prisma 7 this month, which means the pg driver adapter rather than the old
Rust query engine. The old engine honoured connection_limit and
connect_timeout as URL parameters; pg silently ignores them. Anyone who had
tuned the pool through the connection string had, since the upgrade, been
tuning nothing.
The fourth client I had not counted
The RAG service builds its vector store through LangChain's PGVectorStore,
and it was passing connection options rather than a pool:
const store = await PGVectorStore.initialize(embeddings, {
postgresConnectionOptions: { connectionString: process.env.DATABASE_URL },
tableName: `${table}_embeddings`,
// ...
})
Given options instead of a pool, PGVectorStore constructs its own pg.Pool.
And initialize was being called per lookup, per table. So on top of the
twenty-connection pool the service already owned and was not using for this,
each retrieval created a fresh pool, with pg's default max of ten, that
nobody closed. The stated budget was wrong, and the real one was unbounded.
The fix is the smallest diff in this whole episode:
const store = await PGVectorStore.initialize(embeddings, {
pool: this.pool, // reuse the one we configured, do not let the store make its own
tableName: `${table}_embeddings`,
// ...
})
One pool, sized on purpose, and the store borrows from it.
Writing the budget down
The rest was arithmetic, and then writing the arithmetic where the next person
will read it. The three pools now default to 9, 3 and 3 — fifteen in total,
leaving around five for the release-phase migration, a psql session, and
whoever has Prisma Studio open. Each is an environment variable, and each has
a comment pointing at the same paragraph in lib/prisma.ts that lays out the
sum and names the constraint: pg caps connections per pool; there is no
global limit; the role's limit is shared by every pool that points at this
URL. That sentence is the actual bug. The numbers are just its consequence.
Two smaller things rode along. The RAG pool had no ssl setting, and pg does
not enable TLS by default where the old engine did implicitly; managed Postgres
rejects the unencrypted attempt, so the pool now uses the same resolveSsl
helper the main client does. And the transaction maxWait went from two
seconds to eight, with a ten-second timeout on the transaction itself — both
comfortably inside the thirty-second router limit. Two seconds to acquire a
connection is fine when the pool is idle and hopeless when the jobs and a CMS
fan-out are competing for nine of them.
What this did not fix
Nine connections for HTTP and twenty background jobs is a budget, not a solution. The main pool is still the contended resource, and if P2028 comes back the answer is not to raise the number past the role limit — that just moves the error from Prisma to Postgres. The durable fixes are a connection pooler in front of the database, or moving the jobs out of the web process into a worker of their own. Both are on the list; neither was a weekend job.
The lesson I am keeping is narrower. Putting vectors in the application database is a good decision that comes with an obligation: retrieval is now one more client of a shared, finite resource, and it has to be given a number and held to it like any other. The vector store did not steal my connections out of malice. It took what it was allowed, and nobody had told it the limit.
Written by
Deyan Peev
Founding Engineer · Sofia, Bulgaria


