postgresql

The RAG client was stealing my connections

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.Pool for pgvector queries, with max: 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.

Deyan Peev

Written by

Deyan Peev

Founding Engineer · Sofia, Bulgaria

Deyan Peev

Founding Engineer in Sofia, Bulgaria. Currently at 1club.

Elsewhere

© 2026 Deyan Peev