Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,29 @@ DATABASE_URL=
# Separate test database (platform_test_<github-handle> — SPEC.md §6)
DATABASE_URL_TEST=

# The web process's database deadlines (#172). All optional: leaving one blank
# keeps the number in src/db/client.ts, sized for dev's one-core instance
# shared by dev and every open preview. Production's managed database (#24) is
# another machine with another connection cap and sets its own. Whole numbers
# only, at most 2147483647. The scripts (seed, import, backfill) ignore these
# and run without deadlines — a migration or an import cut off halfway is
# worse than a slow one.
# Connections this container may hold at once. At least 1.
DB_POOL_MAX=
# How long a request waits for a free connection before it is answered with
# an error. At least 1: without a bound it waits for ever, which is the bug
# this set of variables exists to keep fixed.
DB_CONNECT_TIMEOUT_MS=
# How long one statement may run before the server cuts it off. 0 does not
# mean "no bound" — it means the server's own setting stands, which is 0 on
# our PostgreSQL but need not be on a managed one.
DB_STATEMENT_TIMEOUT_MS=
# How long one statement may wait for a lock another transaction holds.
DB_LOCK_TIMEOUT_MS=
# How long a transaction may sit between statements before the server ends the
# session. It still holds every lock it has taken while it sits there.
DB_IDLE_TX_TIMEOUT_MS=

# SSH target of the dev instance for the tunnel, e.g. ubuntu@<instance-ip>
DEV_SSH_HOST=
# Hostname the dev deployment answers on (SPEC §8). Leave empty and
Expand Down
13 changes: 13 additions & 0 deletions SPEC.md
Original file line number Diff line number Diff line change
Expand Up @@ -337,6 +337,19 @@ export function ownerKey(
plain Docker image). The one line worth paying from day one is the managed database, and
not for performance: prod holds real accounts and photos, so someone else's backups and
patching is the product being bought. To settle with #24.
- **Every wait on the database has a deadline, and each environment sets its own** (#172):
the pool answers a caller it cannot give a connection to within five seconds instead of
queueing them for ever, and the server cuts off a statement that runs past ten seconds or
waits past three for a lock. The numbers live in `src/db/client.ts`, sized for dev's one
core shared by dev and every preview, and each is an environment variable (`DB_POOL_MAX`,
`DB_CONNECT_TIMEOUT_MS`, `DB_STATEMENT_TIMEOUT_MS`, `DB_LOCK_TIMEOUT_MS`,
`DB_IDLE_TX_TIMEOUT_MS`) because prod's managed database is another machine with another
cap. They travel in the connection, not in `DATABASE_URL`, so a migration or a script is
never cut off by a bound meant for a page — the scripts ignore those variables entirely.
The migration runner keeps one bound of its own, and it guards the site rather than the
migration: five seconds of waiting for a lock, because the old container is still serving
and a migration queueing for `ACCESS EXCLUSIVE` queues every reader of that table behind
itself.
- Outside prod (`APP_ENV` other than `production`): `X-Robots-Tag: noindex` (A7) — set on
every response by `src/proxy.ts`; the deployment provides `APP_ENV`.
- One time zone for the whole interface: `Europe/Warsaw` (next-intl `timeZone`; decision of
Expand Down
18 changes: 17 additions & 1 deletion deploy/migrate.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,23 @@ const folder = path.join(
const pending = readdirSync(folder).filter((name) => name.endsWith(".sql"));
console.log(`${pending.length} migration file(s) in the image`);

const pool = new Pool({ connectionString: url });
// #172 deliberately does not reach this file: the web deadlines
// (statement_timeout, lock_timeout) live in the pool src/db/client.ts opens,
// not in DATABASE_URL, precisely so a migration is never cut off half-applied
// by a bound meant for a page. The one bound worth having here is the
// opposite one — a database that cannot be reached should fail the deploy
// rather than hold it open for ever.
// The lock bound is the exception, and it guards the SITE rather than the
// migration: the old container is still serving while this runs, and a
// migration waiting for ACCESS EXCLUSIVE queues every reader of that table
// behind itself. Better to fail the deploy in five seconds — old container
// serving, old schema untouched — than to stall the site for as long as
// whatever holds the table.
const pool = new Pool({
connectionString: url,
connectionTimeoutMillis: 30_000,
lock_timeout: 5_000,
});
try {
// Drizzle records what it has applied in its own table and skips those, so
// this is safe to run on every deploy — including one that changes nothing.
Expand Down
4 changes: 3 additions & 1 deletion scripts/backfill-file-keys.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { eq, isNull } from "drizzle-orm";
import { alias } from "drizzle-orm/pg-core";
import { getDb } from "@/db/client";
import { getDb, runAsBatchJob } from "@/db/client";
import { files } from "@/db/schema";
import { requireEnv } from "@/lib/env";
import { contentKey, keyPrefix } from "@/lib/storage";
Expand Down Expand Up @@ -46,6 +46,8 @@ function assertLocalTarget(url: string, allowRemote: boolean): void {
}

async function main(): Promise<void> {
// #172: one pass over every file row, allowed to take as long as it takes.
runAsBatchJob();
loadDotEnv();
const url = requireEnv("DATABASE_URL");
assertLocalTarget(url, process.argv.includes("--allow-remote"));
Expand Down
5 changes: 4 additions & 1 deletion scripts/import-teryt.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { readFileSync } from "node:fs";
import { sql } from "drizzle-orm";
import { getDb, type Database } from "@/db/client";
import { getDb, runAsBatchJob, type Database } from "@/db/client";
import { places } from "@/db/schema";
import { requireEnv } from "@/lib/env";
import {
Expand Down Expand Up @@ -106,6 +106,9 @@ export async function importPlaces(
}

async function main(argv: readonly string[]): Promise<number> {
// #172: a hundred thousand places upserted in one statement is a batch job,
// not a web request.
runAsBatchJob();
loadDotEnv();
const target = new URL(requireEnv("DATABASE_URL"));
const database = `${target.host}${target.pathname}`;
Expand Down
5 changes: 4 additions & 1 deletion scripts/seed.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { getDb } from "@/db/client";
import { getDb, runAsBatchJob } from "@/db/client";
import { requireEnv } from "@/lib/env";
import { getStorage, isStorageConfigured, keyPrefix } from "@/lib/storage";
import {
Expand Down Expand Up @@ -68,6 +68,9 @@ function printSummary(summary: SeedSummary, password: string): void {
}

async function main(argv: readonly string[]): Promise<number> {
// #172: a seed writes fourteen accounts and their photos — long by the web
// deadlines' standards, and nobody is waiting on a page for it.
runAsBatchJob();
loadDotEnv();
// Sample accounts with one published password belong in dev and test
// databases only. The loopback check below is the guard; this is the belt.
Expand Down
Loading
Loading