DEV Community

Daniel Pertu
Daniel Pertu

Posted on

Our newest commit changed zero files, and the 27 lines it was really about are all pooler workarounds

Here is the top of git log on Notifio right now:

9a1e94d Redeploy with new database
5e373da Add maintenance mode for database downtime
Enter fullscreen mode Exit fullscreen mode

The first one changed no files. git show --name-only --format="" 9a1e94d prints nothing at all. It is an empty commit, authored on purpose, because the only thing that needed to change lived in an environment variable on the hosting platform and the platform rebuilds on push.

The commit under it is the one that made that possible, and I have written about it already in Our maintenance switch is 14 lines, and the interesting part is the three API routes it refuses to turn off. Those two commits together are the whole story of moving a production database under a live product: build the switch, flip it, move the data, push an empty commit to pick up the new DATABASE_URL, flip the switch back.

What I want to write about here is the 27 line file that empty commit was in service of, because every single option in it is there because of something other than Postgres.

The whole database client

import { drizzle } from "drizzle-orm/postgres-js";
import postgres from "postgres";
import * as schema from "./schema";

declare global {
  // eslint-disable-next-line no-var
  var _pgClient: postgres.Sql | undefined;
}

// Reuse connection in dev hot-reload, create fresh in production
const client =
  global._pgClient ??
  postgres(process.env.DATABASE_URL!, {
    // Supabase/PgBouncer "Transaction" pooler mode (port 6543) does NOT support
    // prepared statements. postgres-js creates them automatically by default,
    // which causes "prepared statement already exists" errors. Disable them.
    prepare: false,
    // Keep the per-instance pool small for serverless; the pooler multiplexes.
    max: 1,
    idle_timeout: 30,
    connect_timeout: 10,
  });

if (process.env.NODE_ENV !== "production") {
  global._pgClient = client;
}

export const db = drizzle(client, { schema });
Enter fullscreen mode Exit fullscreen mode

That is the entire data layer, on top of a schema that is one table. Four options, and not one of them is a decision about the database. They are all decisions about the two things sitting between our code and the database.

prepare: false, and the error that makes no sense

postgres-js prepares statements automatically, which is the right default: you get the plan cached and the parameters sent separately. Against a pooler running in transaction mode, it is wrong in a way that produces a baffling error.

In transaction mode the pooler hands you a backend connection for the length of a transaction and then gives it to somebody else. A prepared statement, though, lives on the backend connection and is named. So your code prepares s_1 on some connection, the pooler reassigns that connection, and the next time your code goes to prepare s_1 it either lands on a connection that has never heard of it or, worse, one that already has it. The error you get is prepared statement "s_1" already exists, and the reason it is so confusing in a serverless environment is that it depends on which backend you happened to be handed, so it fires intermittently and never locally.

The fix is one line, and the cost is real: no plan caching, and parameters are interpolated by the driver rather than sent out of band. It is still parameterized, so this is not an injection question, but you do give up something. Given that the busiest query in the product is a single-row lookup by token against a unique index, the plan was never the interesting part.

max: 1, which looks like a mistake

A connection pool of one, on a web application, reads like a typo. It is correct here for the same reason the previous line is.

The function instances that serve this site are short-lived and there can be many of them at once. The pooler is the thing doing the real pooling, and it multiplexes a large number of client connections onto a small number of backends. If every instance opens a pool of ten, the instances fight each other for pooler slots and you hit the connection limit with almost no traffic. One connection per instance, and let the layer designed for it do the multiplexing.

idle_timeout: 30 exists for the same reason: an instance that has gone quiet should hand its connection back rather than sit on it. connect_timeout: 10 is the only one of the four that is not about the pooler. It is about not letting a request hang for the platform's full timeout when the database is unreachable, which matters because the desktop app on somebody's laptop is polling our licence endpoint and a fast failure is a retry while a slow one is a stall.

The global that is a development-only hack

const client = global._pgClient ?? postgres(...);

if (process.env.NODE_ENV !== "production") {
  global._pgClient = client;
}
Enter fullscreen mode Exit fullscreen mode

This pattern shows up in every Next.js project with a database and it is almost always copied without the second half being read. The assignment is guarded. In development it caches the client on the global object, because the dev server re-evaluates modules on every hot reload, and without this you accumulate a new Postgres client per save until you run out of connections and spend twenty minutes wondering what you broke.

In production the guard means the assignment never runs, so the ?? on the first line is always a miss and every instance builds its own client. That is what you want: nothing is sharing a module registry across instances anyway, and a global that only exists in one environment is easier to reason about than one that exists in both and behaves differently.

The declare global block with the eslint disable above it is the price of admission. var is required because let and const declarations in a global augmentation do not attach to globalThis, which is a TypeScript detail that has cost many people an afternoon.

Why this file is boring on purpose

The broader pattern here is the one I keep landing on across this project: the desktop app has six runtime dependencies and no build step, and the server has one table and 33 lines of schema. The product deliberately keeps almost nothing on our side. Your searches, your baselines and your history live in your own user data directory on your own machine, and the server knows one thing: whether an email address and a token correspond to a licence that is active.

That is why a database move could be done behind a flag in an afternoon, and why the client config is four options rather than a connection-management module. A product that stored your searches server side would not have had that option, and the maintenance window would not have been a window at all, because the thing being maintained would have been the product.

You can see which parts of the site actually need the database by looking at what the switch leaves up. The download page stays up, because its links are presigned object storage URLs. The contact form stays up, because it only sends mail. The pricing page is the one that changes, because a checkout needs a row to eventually write.

Top comments (0)