From 87e7aa5f91f2e37c7f05d04edf3544e493d7f0ec Mon Sep 17 00:00:00 2001 From: Erik Hjelm Fjeldheim Date: Wed, 4 Nov 2026 13:20:24 -0600 Subject: [PATCH 1/6] test: #21 run the PostgreSQL suites in CI The database-backed suites were skipped in CI because nothing set TEST_DATABASE_URL, so "27 skipped" was passing for the whole database layer. The new `integration` job creates a throwaway PostgreSQL cluster inside the runner's temp directory, socket-only, applies the migrations, runs the server suite and deletes the cluster. No shared test database, no secret to hand out, and the application's `crate` database is never in reach. `deploy` now requires the job, so a deploy cannot ignore the database tests. The cluster alone would still have left the failure mode from "skipped" to "silently empty", so `server/scripts/assert-postgres-tests.mjs` reads the JSON report and fails when any database-backed file has zero executed or any pending assertions. Verified against both reports: with the database it prints the four files and exits 0, without it fails on the three suites that skip. --- .github/workflows/ci.yml | 76 ++++++++++++++++++++- README.md | 17 ++++- docs/VM-setup.md | 6 ++ server/scripts/assert-postgres-tests.mjs | 85 ++++++++++++++++++++++++ 4 files changed, 181 insertions(+), 3 deletions(-) create mode 100644 server/scripts/assert-postgres-tests.mjs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index df76816..cf27047 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -76,9 +76,83 @@ jobs: exit 1 fi + # The database-backed suites are skipped when TEST_DATABASE_URL is unset, so + # until this job existed CI reported them as "27 skipped" and stayed green. + # The tests guard the things unit tests cannot see: SQL that only fails once + # PostgreSQL parses it, keyset paging across ties and NULLs, facet counts + # against the filters, and the MusicBrainz import preserving reviews. + # + # The cluster is created here and thrown away at the end. Deliberately not the + # application database and not a shared test database: no secret to hand out, + # no state to drift, and running the destructive seed is impossible by + # construction. It listens on a unix socket only, so it cannot accept a TCP + # connection even while it exists. + integration: + name: PostgreSQL integration tests + runs-on: [self-hosted, linux, x64] + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Install pnpm + uses: pnpm/action-setup@v4 + with: + version: 10.33.0 + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: 24 + cache: pnpm + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Start a throwaway PostgreSQL cluster + run: | + set -euo pipefail + export PGDATA="${RUNNER_TEMP}/pgdata" + rm -rf "${PGDATA}" + initdb -D "${PGDATA}" --auth=trust --no-sync + # -k puts the socket in the data directory and `listen_addresses=''` + # keeps it off the network entirely. + pg_ctl -D "${PGDATA}" -l "${PGDATA}/server.log" \ + -o "-c listen_addresses='' -k ${PGDATA}" start + createdb -h "${PGDATA}" crate_test + echo "PGDATA=${PGDATA}" >> "${GITHUB_ENV}" + echo "TEST_DATABASE_URL=postgres:///crate_test?host=${PGDATA}" >> "${GITHUB_ENV}" + psql -h "${PGDATA}" -d crate_test -tAc 'select version()' + + - name: Apply migrations + run: DATABASE_URL="$TEST_DATABASE_URL" pnpm --filter @t19/server db:migrate + + - name: Run the server test suite + # The JSON report is only there for the next step; the default reporter + # still prints the normal output a reader expects. + run: | + pnpm --filter @t19/server exec vitest run \ + --reporter=default \ + --reporter=json \ + --outputFile="${RUNNER_TEMP}/vitest.json" + + # A green run is not evidence on its own: without a database every + # PostgreSQL suite skips and vitest still exits 0. See the script header. + - name: Fail if the PostgreSQL tests were skipped + run: node server/scripts/assert-postgres-tests.mjs "${RUNNER_TEMP}/vitest.json" + + - name: Remove the cluster + if: always() + run: | + if [ -f "${PGDATA}/postmaster.pid" ]; then + pg_ctl -D "${PGDATA}" -m immediate stop || true + fi + rm -rf "${PGDATA}" + deploy: name: Deploy to the group VM - needs: quality + # Both jobs have to pass. Deploying with the database layer untested is the + # same gap as deploying a server that was never built (#44). + needs: [quality, integration] if: github.ref == 'refs/heads/main' && github.event_name == 'push' # Pinned to the `project2` label on purpose. This job writes to # /var/www/html/project2 and restarts crate-api, both of which only exist diff --git a/README.md b/README.md index 71f69a2..9c15d35 100644 --- a/README.md +++ b/README.md @@ -83,8 +83,21 @@ CI also builds the project and checks the client bundle size. | `pnpm db:fetch:musicbrainz` | Rebuild the local MusicBrainz snapshot (network required) | | `pnpm db:seed:large` | Reset to a large synthetic catalog | -Tests cover frontend components and backend logic. Additional PostgreSQL tests cover search, sorting, rating updates and filters. These run when `TEST_DATABASE_URL` is configured and require a separate test database with migrations applied. The -[VM setup guide](docs/VM-setup.md) includes API smoke tests. +Tests cover frontend components and backend logic. The database-backed suites +(search, sorting, filtering and facet counts, and the MusicBrainz import) need a +real PostgreSQL server and are skipped unless `TEST_DATABASE_URL` is set. + +CI starts a temporary PostgreSQL cluster for them, applies the migrations, runs +the suites and fails if any of them was skipped instead of executed +(`server/scripts/assert-postgres-tests.mjs`). To run them locally, point +`TEST_DATABASE_URL` at a database you do not mind losing - the tests insert and +delete fixture rows: + +```bash +TEST_DATABASE_URL=postgres://t19:password@127.0.0.1:5432/crate_test pnpm --filter @t19/server test +``` + +The [VM setup guide](docs/VM-setup.md) includes API smoke tests. ## Database seed data diff --git a/docs/VM-setup.md b/docs/VM-setup.md index d560b7b..541dc63 100644 --- a/docs/VM-setup.md +++ b/docs/VM-setup.md @@ -41,6 +41,12 @@ migrations, imports the committed MusicBrainz catalog snapshot and restarts `crate-api.service`. The import is repeatable and does not reset the database or delete user data. Deployment does not run either destructive seed command. +The `integration` job runs the database-backed test suites. It starts a +temporary PostgreSQL cluster in the runner's temp directory, listening on a unix +socket only, applies the migrations to it, runs the tests and deletes it +afterwards. It never connects to the `crate` database, so the names, fixtures +and cleanup in those tests cannot affect the application data. + Apache serves the frontend at `/project2/` and forwards `/graphql` requests to the API on port 3001: diff --git a/server/scripts/assert-postgres-tests.mjs b/server/scripts/assert-postgres-tests.mjs new file mode 100644 index 0000000..ca9a08a --- /dev/null +++ b/server/scripts/assert-postgres-tests.mjs @@ -0,0 +1,85 @@ +// Fails when the PostgreSQL test files were skipped instead of executed. +// +// Every PostgreSQL suite is wrapped in `describe.skipIf(!process.env.TEST_DATABASE_URL)`, +// so a CI job that forgets the variable still exits 0 with the whole database +// layer missing. `pnpm test` would report "61 passed | 27 skipped" and nothing +// would be wrong by its own account. This script reads the JSON report from +// that run and requires every database-backed file to have executed, which is +// the difference between "the tests pass" and "the tests ran". +// +// Usage: node server/scripts/assert-postgres-tests.mjs + +import { readFileSync } from 'node:fs'; + +// Files that cannot run without a database. Adding a new one here is deliberate: +// the list is the guard, so it should grow with the suite. +const databaseBackedFiles = [ + 'src/db/musicBrainzCatalog.test.ts', + 'src/search/facets.postgres.test.ts', + 'src/search/search.postgres.test.ts', + 'src/search/sort.postgres.test.ts', +]; + +const reportPath = process.argv[2]; +if (!reportPath) { + console.error('::error::Usage: node assert-postgres-tests.mjs '); + process.exit(1); +} + +let report; +try { + report = JSON.parse(readFileSync(reportPath, 'utf8')); +} catch { + console.error(`::error::Could not read the vitest report at ${reportPath}`); + process.exit(1); +} + +// The reporter names files by absolute path. Keep only the part after `server/`. +function relativeName(name) { + const normalized = name.replaceAll('\\', '/'); + const marker = '/server/'; + const index = normalized.lastIndexOf(marker); + return index === -1 ? normalized : normalized.slice(index + marker.length); +} + +const resultsByFile = new Map( + (report.testResults ?? []).map((file) => [relativeName(file.name), file]), +); + +let failed = false; +for (const file of databaseBackedFiles) { + const result = resultsByFile.get(file); + + if (!result) { + console.error(`::error::${file} did not run at all`); + failed = true; + continue; + } + + // A skipped suite still produces a "passed" file containing only pending + // assertions, so count what actually ran rather than trusting the file + // status. A file with some tests running and some skipped also fails, which + // catches `describe.skipIf` on one block of a mixed file. + const assertions = result.assertionResults ?? []; + const executed = assertions.filter((assertion) => assertion.status === 'passed').length; + const pending = assertions.filter((assertion) => assertion.status === 'pending').length; + + if (result.status !== 'passed' || executed === 0 || pending > 0) { + console.error( + `::error::${file} did not fully run (${result.status}, ${executed} passed, ${pending} skipped) - is TEST_DATABASE_URL set?`, + ); + failed = true; + continue; + } + + console.log(`${file} ${executed} passed`); +} + +if (failed) { + console.error( + '::error::The PostgreSQL tests did not run. Point TEST_DATABASE_URL at a migrated test database.', + ); + process.exit(1); +} + +console.log(`All ${databaseBackedFiles.length} database-backed test files executed.`); From 3fd506752c0973b8222f3bb75195b935be192c5e Mon Sep 17 00:00:00 2001 From: Erik Hjelm Fjeldheim Date: Wed, 4 Nov 2026 13:35:28 -0600 Subject: [PATCH 2/6] fix: #21 find the PostgreSQL server binaries on the runner The first CI run failed with 'initdb: command not found' in 12 seconds: Debian puts the server binaries in /usr/lib/postgresql//bin and only the client on PATH, so PostgreSQL 18 being installed is not enough. Try PATH first, then the newest installed major version, and fail with a message that names the missing package if neither works. --- .github/workflows/ci.yml | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index cf27047..e36f14a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -111,6 +111,24 @@ jobs: - name: Start a throwaway PostgreSQL cluster run: | set -euo pipefail + # Debian installs the server binaries under /usr/lib/postgresql//bin + # and only puts the client on PATH, so `initdb` is not found even with + # PostgreSQL 18 installed. Use PATH when it works, otherwise the newest + # installed major version. + if ! command -v initdb >/dev/null 2>&1; then + pg_bindir='' + for dir in /usr/lib/postgresql/*/bin; do + if [ -x "${dir}/initdb" ]; then + pg_bindir="${dir}" + fi + done + if [ -z "${pg_bindir}" ]; then + echo "::error::initdb was not found. Install a PostgreSQL server package on the runner." + exit 1 + fi + export PATH="${pg_bindir}:${PATH}" + fi + initdb --version export PGDATA="${RUNNER_TEMP}/pgdata" rm -rf "${PGDATA}" initdb -D "${PGDATA}" --auth=trust --no-sync From bbeb34d74413a59de00d7558591604838a0c9005 Mon Sep 17 00:00:00 2001 From: Erik Hjelm Fjeldheim Date: Wed, 4 Nov 2026 13:27:49 -0600 Subject: [PATCH 3/6] docs: #22 measure scalability at 100k and 1M albums Documents the "udefinert stor datasamling" requirement with numbers instead of an assertion, and records the limits honestly. New: `paging-perf.sql` compares the keyset page with OFFSET at a configurable depth; `index-perf.sql` runs the selective search with and without the GIN index, dropping it inside a transaction that is rolled back so it is always restored; `benchmark.sh` runs both and refuses any database whose name does not end in `_perf`, because the index comparison takes an exclusive lock on album. Findings worth keeping: the broad full-text query is planned as a sequential scan even with the index, and that is correct; the GIN index is worth 5-6x on a selective search; cursor paging is depth-independent while OFFSET reaches 1.2 s at 500,000 rows, but only becomes O(page) with a composite sort index, which the schema does not have yet. That is measured, explained and filed as a follow-up rather than added in a documentation issue. --- README.md | 135 ++++++++++++++++++++++++++++----- server/scripts/benchmark.sh | 50 ++++++++++++ server/scripts/index-perf.sql | 35 +++++++++ server/scripts/paging-perf.sql | 39 ++++++++++ 4 files changed, 240 insertions(+), 19 deletions(-) create mode 100755 server/scripts/benchmark.sh create mode 100644 server/scripts/index-perf.sql create mode 100644 server/scripts/paging-perf.sql diff --git a/README.md b/README.md index 9c15d35..fd60449 100644 --- a/README.md +++ b/README.md @@ -148,33 +148,130 @@ reproducibility. See [MusicBrainz Data License](https://musicbrainz.org/doc/Abou and [MusicBrainz API](https://musicbrainz.org/doc/MusicBrainz_API). Album covers remain out of scope for this issue. -### Scalability check +### Scalability + +The catalog has to work on an undefined data volume, so the search path is +measured against synthetic catalogs that are much larger than the real one. All +numbers below are synthetic data from `db:seed:large`, loaded into separate +databases (`crate_100k_perf`, `crate_perf`) that no deployment touches. + +#### Method + +- PostgreSQL 18.3, Fedora, 15 GB RAM developer laptop, unix socket, warm cache. + Every query was run five times and the median is reported; paging and HTTP + were run three times because they take longer. Timings vary by roughly 10-30 % + between runs on this machine. +- 100,000 and 1,000,000 deterministic albums. Seeding took **10 s** and **95 s**. + The 100,000-album database is 41 MB with a 11 MB GIN index; the + 1,000,000-album database is 350 MB with a 75 MB GIN index (489 MB total). +- `ANALYZE` runs before measuring. Without it the first query after a seed is + planned against default estimates rather than the actual catalog. +- The scripts are committed and read-only: `server/scripts/benchmark.sh` runs all + of them. It refuses a database whose name does not end in `_perf`, because the + index comparison takes an exclusive lock on `album`. Seeding is a separate, + destructive step and is deliberately not part of the script. + +#### Full-text search and filters + +| Operation | Plan | 100,000 | 1,000,000 | +| ------------------------------------------------------------ | ----------------------------------------------------------- | ------: | --------: | +| Selective artist search, relevance order | Bitmap Index Scan on `album_search_vector_idx` + top-N sort | 2.6 ms | 11.7 ms | +| Broad search for `album`, which matches every row | Parallel Seq Scan + top-N sort | 16.3 ms | 83.7 ms | +| Two-genre AND plus two decades, title order | Parallel Seq Scan on `album_genre` + primary-key lookups | 20.9 ms | 87.6 ms | +| Complete broad `searchAlbums` request (facets, counts, page) | - | ~100 ms | ~580 ms | +| `searchAlbums` that matches 307,000 albums, with facets | - | - | ~256 ms | + +The broad query is planned as a sequential scan even with the GIN index, and +that is the right plan: it matches every album, so reading most of the index and +then most of the table costs more than reading the table once. The index earns +its space on selective queries, which is what a search box mostly produces. + +The last two rows are HTTP requests, not SQL. They are dominated by counting and +faceting the whole result set - `totalCount`, the genre and decade facets and the +reviewed count are all aggregate queries over the full match, not over the 20 +returned rows. That is the honest ceiling at one million albums. + +#### What the GIN index is worth + +`server/scripts/index-perf.sql` runs the same selective search with the index and +then again with it dropped inside a transaction that is rolled back, so the index +is always restored. + +| Dataset | With GIN | Without GIN | Difference | +| --------- | -------: | ----------: | ---------: | +| 100,000 | 2.6 ms | 15.9 ms | 6x | +| 1,000,000 | 11.7 ms | 62.3 ms | 5x | + +Without the index PostgreSQL falls back to a parallel sequential scan that reads +every row and discards all but the matches. The ratio is not larger because the +synthetic artist name repeats often; a rarer term gains more. + +#### Paging depth: cursor vs OFFSET + +`server/scripts/paging-perf.sql` compares the keyset page the API uses with the +`OFFSET` alternative, for the same rows and the same order (`TITLE_ASC`). + +| Rows already scrolled | 100,000 cursor | 100,000 OFFSET | 1,000,000 cursor | 1,000,000 OFFSET | +| --------------------- | -------------: | -------------: | ---------------: | ---------------: | +| 50,000 | 26 ms | 219 ms | 295 ms | 865 ms | +| 500,000 | - | - | 227 ms | 1167 ms | + +The cursor page does not grow with depth: page 25,000 at one million albums is +slightly faster than page 2,500, because the filter leaves fewer rows to sort. +`OFFSET` does grow: PostgreSQL produces and throws away every skipped row, and at +500,000 the sort spills 12 MB to disk. + +Cursor paging is still not free here, and the reason is worth stating plainly: +there is no index on the sort key, so the keyset query also scans and sorts the +remaining rows. Keyset is faster because it discards before sorting, not because +the scan is avoided. + +#### The index that paging wants + +Adding `(lower(title), id)` to the performance database shows what the missing +piece is. The index is 47 MB at one million albums. + +| Rows already scrolled | Cursor, indexed | OFFSET, indexed | +| --------------------- | --------------: | --------------: | +| 50,000 | 0.07 ms | 15 ms | +| 500,000 | 0.09 ms | 164 ms | + +With a matching composite index the cursor page is an `Index Scan` that reads 20 +rows regardless of depth, while `OFFSET` still walks and discards every skipped +entry. That is the shape the architecture decision describes. + +The index is not in the schema yet. The seven sort modes would need several +composite indexes, each one costs write time on seed and import, and the extra +47 MB per index is a bærekraft tradeoff that deserves its own measurement and +review. It is filed as a follow-up rather than slipped into this milestone, and +the numbers above are the argument for doing it. + +#### Reproducing -Use a separate test database, not the shared catalog. Apply migrations, run the large seed and start the API. The SQL checks and GraphQL request are committed in the repository. +```bash +# A separate database. Never the application's `crate` database. +createdb crate_perf +DATABASE_URL='postgres://t19:password@127.0.0.1:5432/crate_perf' \ + pnpm --filter @t19/server db:migrate +ALBUM_COUNT=1000000 DATABASE_URL='postgres://.../crate_perf' \ + pnpm db:seed:large +DATABASE_URL='postgres://.../crate_perf' server/scripts/benchmark.sh +DATABASE_URL='postgres://.../crate_perf' psql -v offset=500000 \ + -f server/scripts/paging-perf.sql +``` -The scripts read `server/.env`, but `psql` needs `DATABASE_URL` exported in your shell (for example, `export DATABASE_URL='postgres://...'` in Bash). +The HTTP timings use the committed request in `server/scripts/search-albums-perf.json`: ```bash -psql "$DATABASE_URL" -v ON_ERROR_STOP=1 -c "ANALYZE;" -psql "$DATABASE_URL" -v ON_ERROR_STOP=1 -f server/scripts/perf-check.sql curl -s -o /dev/null -w 'total %{time_total}s\n' \ -H 'content-type: application/json' \ --data @server/scripts/search-albums-perf.json \ http://localhost:3001/graphql ``` -Run the HTTP request once without `-o /dev/null` to check that GraphQL returns data without errors before measuring response time. On Windows, use `curl.exe` and `NUL` instead of `/dev/null`. - -In local testing on 16 October 2026, using PostgreSQL 16 and 100,000 generated albums, seeding completed in **8.42 seconds**. Representative timings were: - -| Operation | Execution time | -| ------------------------------------------------------------------ | -------------: | -| Full-text search for an artist, ordered by relevance | 2.90 ms | -| Broad full-text search for `album`, ordered by relevance | 41.36 ms | -| Two-genre AND filter plus two decades, ordered by title | 19.57 ms | -| Complete broad `searchAlbums` GraphQL query with facets and paging | 34.65 ms | - -The first three timings are SQL queries; the last is a GraphQL HTTP response. These results were measured locally, not on the VM, and may differ on other hardware. +These results were measured on a laptop, not on the VM. The VM runs the same +PostgreSQL major version, but the numbers are hardware-specific and should be +read as orders of magnitude and plan shapes rather than as VM guarantees. #### Prefix search (#55) @@ -188,11 +285,11 @@ Performance was tested locally with 100,000 synthetic albums using PostgreSQL 18 | `synthetic art` | 100,000 | ~53 ms | | `synthetic album 99999` | 1 | ~1.3 ms | -Broad prefixes can match many albums, increasing the work needed for ranking, counts and facets. All tested prefix queries used the existing GIN index. +Broad prefixes can match many albums, increasing the work needed for ranking, counts and facets. The 100,000-album runs above used the existing GIN index; at 1,000,000 albums a prefix that matches most of the catalog is planned as a sequential scan, for the same reason as the broad query in the table above. Measurements are based on two local runs with cached synthetic data and cover only page queries, not the full GraphQL response or energy consumption. -The read-only benchmark is available in `server/scripts/prefix-search-perf.sql`. PostgreSQL tests can be run by setting `TEST_DATABASE_URL` before running the server tests. +The read-only benchmark is available in `server/scripts/prefix-search-perf.sql`. ### Accessibility diff --git a/server/scripts/benchmark.sh b/server/scripts/benchmark.sh new file mode 100755 index 0000000..0adab8f --- /dev/null +++ b/server/scripts/benchmark.sh @@ -0,0 +1,50 @@ +#!/usr/bin/env bash +# Runs every scalability check against a synthetic performance database. +# +# DATABASE_URL='postgres://t19:...@127.0.0.1:5432/crate_perf' \ +# server/scripts/benchmark.sh +# +# The database must already be migrated and seeded, for example: +# +# ALBUM_COUNT=1000000 pnpm --filter @t19/server db:seed:large +# +# Seeding is deliberately not part of this script: `db:seed:large` truncates the +# catalog, and a benchmark runner that can wipe the catalog is a foot-gun. The +# name check below is about the index test, which takes an ACCESS EXCLUSIVE lock +# on album while it runs - fine on a throwaway database, not on the live one. +set -euo pipefail + +: "${DATABASE_URL:?DATABASE_URL must be set}" + +# Take the database name out of the URL, with or without a socket `host` query. +db_name="${DATABASE_URL%%\?*}" +db_name="${db_name##*/}" + +case "$db_name" in + *_perf) ;; + *) + echo "::error::Refusing to run: '$db_name' is not a *_perf database." >&2 + echo "Create a separate performance database and seed it, e.g. crate_perf." >&2 + exit 1 + ;; +esac + +script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +echo "Database: $db_name" +psql "$DATABASE_URL" -v ON_ERROR_STOP=1 -q \ + -c "SELECT version();" \ + -c "SELECT count(*) AS albums FROM album;" \ + -c "SELECT pg_size_pretty(pg_total_relation_size('album')) AS album_total, + pg_size_pretty(pg_relation_size('album_search_vector_idx')) AS gin_index, + pg_size_pretty(pg_database_size(current_database())) AS database;" + +# Planner statistics are what the plans below are read against; without this the +# first run after a seed measures a default estimate instead of the catalog. +psql "$DATABASE_URL" -v ON_ERROR_STOP=1 -q -c "ANALYZE;" + +psql "$DATABASE_URL" -v ON_ERROR_STOP=1 -f "$script_dir/perf-check.sql" +psql "$DATABASE_URL" -v ON_ERROR_STOP=1 -f "$script_dir/paging-perf.sql" +psql "$DATABASE_URL" -v ON_ERROR_STOP=1 -f "$script_dir/index-perf.sql" + +echo "Done. Timings are on the Execution Time line of each plan." diff --git a/server/scripts/index-perf.sql b/server/scripts/index-perf.sql new file mode 100644 index 0000000..9c29603 --- /dev/null +++ b/server/scripts/index-perf.sql @@ -0,0 +1,35 @@ +-- What the GIN index is worth: the same selective search with and without it. +-- +-- Read-only with respect to data. The DROP INDEX runs inside a transaction that +-- is rolled back at the end, so the index is restored even if the script fails +-- part-way through. Do not run it against the application database. +-- +-- psql "$DATABASE_URL" -v ON_ERROR_STOP=1 -f server/scripts/index-perf.sql + +SELECT count(*) AS albums FROM album; + +\echo 'With album_search_vector_idx:' +EXPLAIN (ANALYZE, BUFFERS) +SELECT album.id +FROM album +WHERE album.search_vector @@ websearch_to_tsquery('simple', 'Synthetic Artist 1') +ORDER BY + ts_rank(album.search_vector, websearch_to_tsquery('simple', 'Synthetic Artist 1')) DESC, + album.id ASC +LIMIT 20; + +BEGIN; + +DROP INDEX album_search_vector_idx; + +\echo 'Without album_search_vector_idx:' +EXPLAIN (ANALYZE, BUFFERS) +SELECT album.id +FROM album +WHERE album.search_vector @@ websearch_to_tsquery('simple', 'Synthetic Artist 1') +ORDER BY + ts_rank(album.search_vector, websearch_to_tsquery('simple', 'Synthetic Artist 1')) DESC, + album.id ASC +LIMIT 20; + +ROLLBACK; diff --git a/server/scripts/paging-perf.sql b/server/scripts/paging-perf.sql new file mode 100644 index 0000000..1a37fce --- /dev/null +++ b/server/scripts/paging-perf.sql @@ -0,0 +1,39 @@ +-- Deep-page comparison: keyset cursor vs OFFSET, in TITLE_ASC order. +-- +-- Read-only. Run against a large synthetic database: +-- psql "$DATABASE_URL" -v ON_ERROR_STOP=1 -f server/scripts/paging-perf.sql +-- Override the depth (rows already scrolled past) with: +-- psql "$DATABASE_URL" -v offset=500000 -f server/scripts/paging-perf.sql +-- +-- Both queries return the same 20 albums. The cursor a client would hold at +-- this depth is the sort key of the row at :offset, which is what the first +-- query selects, exactly as the API's cursor encodes it. + +\if :{?offset} +\else + \set offset 50000 +\endif + +SELECT count(*) AS albums FROM album; + +SELECT lower(title) AS title_key, id AS id_key +FROM album +ORDER BY lower(title), id +OFFSET :offset +LIMIT 1 \gset + +\echo 'Keyset page after' :offset 'rows:' +EXPLAIN (ANALYZE, BUFFERS) +SELECT id +FROM album +WHERE (lower(title), id) > (:'title_key', :id_key) +ORDER BY lower(title), id +LIMIT 20; + +\echo 'OFFSET' :offset 'page:' +EXPLAIN (ANALYZE, BUFFERS) +SELECT id +FROM album +ORDER BY lower(title), id +OFFSET :offset +LIMIT 20; From aaefc8af53ffde6e91edb3ce2d27a889ee988257 Mon Sep 17 00:00:00 2001 From: Erik Hjelm Fjeldheim Date: Thu, 5 Nov 2026 19:16:23 -0600 Subject: [PATCH 4/6] perf: #22 page the paging benchmark from the same cursor row Two problems with paging-perf.sql, both from review by rachelks on #73. The cursor and OFFSET pages were one row apart. The anchor row was fetched with OFFSET :offset LIMIT 1, which is the row after the last one already seen, so the keyset query returned rows offset+2..offset+21 while the OFFSET query returned offset+1..offset+20. The anchor is now OFFSET :offset - 1, so both start at the same row. The script now proves it instead of asserting it: it materialises both pages and reports cursor_rows, offset_rows and shared_rows. A run at 500,000 rows prints 20 / 20 / 20, so the timings compare the same work. The keyset predicate is documented as the row comparison `(a, b) > (x, y)` that the API uses, with the reason it matters: PostgreSQL applies it as an index condition on a matching composite index, while the equivalent OR form is only a filter. That OR form was in crateForUser when this was written; #77 changed the API to the row comparison, so the benchmark and the API now run the same shape. offset is documented as "rows already scrolled past", which must be at least 1 because the first page has no cursor to page after. The script says so and stops with ON_ERROR_STOP instead of measuring a mislabelled page. --- server/scripts/paging-perf.sql | 44 ++++++++++++++++++++++++++++++---- 1 file changed, 40 insertions(+), 4 deletions(-) diff --git a/server/scripts/paging-perf.sql b/server/scripts/paging-perf.sql index 1a37fce..f043a57 100644 --- a/server/scripts/paging-perf.sql +++ b/server/scripts/paging-perf.sql @@ -5,23 +5,59 @@ -- Override the depth (rows already scrolled past) with: -- psql "$DATABASE_URL" -v offset=500000 -f server/scripts/paging-perf.sql -- --- Both queries return the same 20 albums. The cursor a client would hold at --- this depth is the sort key of the row at :offset, which is what the first --- query selects, exactly as the API's cursor encodes it. +-- :offset is the number of rows the client has already seen, so the cursor it +-- holds is the last of those. Both queries below return the rows after it, and +-- the script checks that they return the same 20 rows before timing them. +-- +-- The keyset predicate is the row comparison the API uses (server/src/ +-- resolvers.ts, crateForUser): PostgreSQL applies `(a, b) > (x, y)` as an index +-- condition on a matching composite index, while the equivalent OR form is only +-- a filter. + +\set ON_ERROR_STOP on \if :{?offset} \else \set offset 50000 \endif +-- offset 0 means nothing has been scrolled yet, so there is no cursor to page +-- after. Refuse rather than measure the first page under a depth label. +SELECT (:offset >= 1) AS has_cursor \gset +\if :has_cursor +\else + \echo 'offset must be at least 1: the first page has no cursor to page after.' + SELECT 1 / 0; +\endif + SELECT count(*) AS albums FROM album; SELECT lower(title) AS title_key, id AS id_key FROM album ORDER BY lower(title), id -OFFSET :offset +OFFSET :offset - 1 LIMIT 1 \gset +\echo 'Same page from both queries?' +WITH cursor_page AS MATERIALIZED ( + SELECT id + FROM album + WHERE (lower(title), id) > (:'title_key', :id_key) + ORDER BY lower(title), id + LIMIT 20 +), +offset_page AS MATERIALIZED ( + SELECT id + FROM album + ORDER BY lower(title), id + OFFSET :offset + LIMIT 20 +) +SELECT + (SELECT count(*) FROM cursor_page) AS cursor_rows, + (SELECT count(*) FROM offset_page) AS offset_rows, + (SELECT count(*) FROM cursor_page JOIN offset_page USING (id)) AS shared_rows; + \echo 'Keyset page after' :offset 'rows:' EXPLAIN (ANALYZE, BUFFERS) SELECT id From 14fa014b59578fa2710f078841703fdf0d891060 Mon Sep 17 00:00:00 2001 From: Erik Hjelm Fjeldheim Date: Thu, 5 Nov 2026 19:16:23 -0600 Subject: [PATCH 5/6] perf: #22 reproduce the indexed paging experiment, verify the database Review by rachelks on #73 asked how the (lower(title), id) numbers were produced, and whether the database name check could be bypassed. paging-index-perf.sql is the missing experiment: it creates the index inside a transaction, runs ANALYZE, measures the keyset page and the OFFSET page at the same depth as paging-perf.sql, and rolls the transaction back so the schema is unchanged. Measured at one million albums: 47 MB, cursor 0.11 ms against 180.09 ms for OFFSET at 500,000 rows, with the plan showing `Index Cond: (ROW(lower(title), id) > ROW(...))` and `Index Searches: 1`. benchmark.sh now runs it, and checks the real connection in addition to the name in the URL. libpq lets `?dbname=`, a service file or PGDATABASE point somewhere else, and a mistake there runs CREATE INDEX or DROP INDEX on the wrong database. It asks the server for current_database() and refuses anything not ending in _perf. Verified with `postgres:///crate_perf?host=...&dbname=crate_test`: the URL check passes, the server check fails, exit 1. --- server/scripts/benchmark.sh | 40 +++++++++++------ server/scripts/paging-index-perf.sql | 64 ++++++++++++++++++++++++++++ 2 files changed, 92 insertions(+), 12 deletions(-) create mode 100644 server/scripts/paging-index-perf.sql diff --git a/server/scripts/benchmark.sh b/server/scripts/benchmark.sh index 0adab8f..d170c66 100755 --- a/server/scripts/benchmark.sh +++ b/server/scripts/benchmark.sh @@ -10,28 +10,43 @@ # # Seeding is deliberately not part of this script: `db:seed:large` truncates the # catalog, and a benchmark runner that can wipe the catalog is a foot-gun. The -# name check below is about the index test, which takes an ACCESS EXCLUSIVE lock -# on album while it runs - fine on a throwaway database, not on the live one. +# database name checks below are about the index experiments, which take an +# ACCESS EXCLUSIVE lock on album while they run - fine on a throwaway database, +# not on the live one. set -euo pipefail : "${DATABASE_URL:?DATABASE_URL must be set}" -# Take the database name out of the URL, with or without a socket `host` query. -db_name="${DATABASE_URL%%\?*}" -db_name="${db_name##*/}" +refuse() { + echo "::error::$1" >&2 + echo "Create a separate performance database and seed it, e.g. crate_perf." >&2 + exit 1 +} -case "$db_name" in +# Cheap check first, so a URL pointing at the application database is rejected +# before connecting at all. Handles both a TCP URL and a socket `host` query. +url_db="${DATABASE_URL%%\?*}" +url_db="${url_db##*/}" +case "$url_db" in *_perf) ;; - *) - echo "::error::Refusing to run: '$db_name' is not a *_perf database." >&2 - echo "Create a separate performance database and seed it, e.g. crate_perf." >&2 - exit 1 - ;; + *) refuse "'$url_db' is not a *_perf database." ;; esac +# The URL is not proof of what we reached: libpq allows a `dbname` query +# parameter, a service file or PGDATABASE to override the path, and a mistake +# here drops an index on the wrong database. Ask the server what it opened. +connected_db="$(psql "$DATABASE_URL" -X -tAc 'select current_database()')" +case "$connected_db" in + *_perf) ;; + *) refuse "connected to '$connected_db', which is not a *_perf database." ;; +esac +if [ "$connected_db" != "$url_db" ]; then + echo "::warning::URL names '$url_db' but the connection reached '$connected_db'." >&2 +fi + script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -echo "Database: $db_name" +echo "Database: $connected_db" psql "$DATABASE_URL" -v ON_ERROR_STOP=1 -q \ -c "SELECT version();" \ -c "SELECT count(*) AS albums FROM album;" \ @@ -45,6 +60,7 @@ psql "$DATABASE_URL" -v ON_ERROR_STOP=1 -q -c "ANALYZE;" psql "$DATABASE_URL" -v ON_ERROR_STOP=1 -f "$script_dir/perf-check.sql" psql "$DATABASE_URL" -v ON_ERROR_STOP=1 -f "$script_dir/paging-perf.sql" +psql "$DATABASE_URL" -v ON_ERROR_STOP=1 -f "$script_dir/paging-index-perf.sql" psql "$DATABASE_URL" -v ON_ERROR_STOP=1 -f "$script_dir/index-perf.sql" echo "Done. Timings are on the Execution Time line of each plan." diff --git a/server/scripts/paging-index-perf.sql b/server/scripts/paging-index-perf.sql new file mode 100644 index 0000000..b87625d --- /dev/null +++ b/server/scripts/paging-index-perf.sql @@ -0,0 +1,64 @@ +-- What a composite (lower(title), id) index is worth for keyset paging. +-- +-- Read-only with respect to data. The index is created inside a transaction +-- that is rolled back at the end, so it never survives the run and the schema +-- is unchanged even if the script fails part-way through. Do not run it against +-- the application database: CREATE INDEX takes an ACCESS EXCLUSIVE lock on +-- album for the duration. +-- +-- psql "$DATABASE_URL" -v ON_ERROR_STOP=1 -f server/scripts/paging-index-perf.sql +-- psql "$DATABASE_URL" -v offset=500000 -f server/scripts/paging-index-perf.sql +-- +-- The depth and the keyset predicate are the same as paging-perf.sql, so the +-- numbers are directly comparable with the unindexed run. + +\set ON_ERROR_STOP on + +\if :{?offset} +\else + \set offset 500000 +\endif + +SELECT (:offset >= 1) AS has_cursor \gset +\if :has_cursor +\else + \echo 'offset must be at least 1: the first page has no cursor to page after.' + SELECT 1 / 0; +\endif + +SELECT count(*) AS albums FROM album; + +BEGIN; + +-- The index paging wants: the exact sort key of TITLE_ASC, with id as the +-- tie-break, so the row comparison is an index condition rather than a filter. +CREATE INDEX album_title_lower_id_perf_idx ON album (lower(title), id); + +-- Without fresh statistics the planner may not trust a brand-new index. +ANALYZE album; + +SELECT lower(title) AS title_key, id AS id_key +FROM album +ORDER BY lower(title), id +OFFSET :offset - 1 +LIMIT 1 \gset + +SELECT pg_size_pretty(pg_relation_size('album_title_lower_id_perf_idx')) AS index_size; + +\echo 'Keyset page after' :offset 'rows, with the index:' +EXPLAIN (ANALYZE, BUFFERS) +SELECT id +FROM album +WHERE (lower(title), id) > (:'title_key', :id_key) +ORDER BY lower(title), id +LIMIT 20; + +\echo 'OFFSET' :offset 'page, with the index:' +EXPLAIN (ANALYZE, BUFFERS) +SELECT id +FROM album +ORDER BY lower(title), id +OFFSET :offset +LIMIT 20; + +ROLLBACK; From 4724fd4f361f472322d08a7f95fd9a0404374568 Mon Sep 17 00:00:00 2001 From: Erik Hjelm Fjeldheim Date: Thu, 5 Nov 2026 19:16:23 -0600 Subject: [PATCH 6/6] docs: #22 move the measurements to docs/scalability.md Review by rachelks on #73: the scalability section buried the conclusions in several screens of tables and plans. README keeps the headline table, five one-line conclusions and the pointer. The method, the full tables, the plans, the prefix-search numbers and the reproduction steps move to docs/scalability.md, which is linked from the README and from the scripts. The reproduction section is corrected as part of the move: the psql commands now pass "$DATABASE_URL" explicitly (psql does not read it on its own, which made the old snippet connect to the default database), and it shows how to build and start the API against the performance database for the HTTP numbers. --- README.md | 174 ++++++++----------------------------- docs/scalability.md | 203 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 237 insertions(+), 140 deletions(-) create mode 100644 docs/scalability.md diff --git a/README.md b/README.md index fd60449..bee3c0c 100644 --- a/README.md +++ b/README.md @@ -150,146 +150,40 @@ remain out of scope for this issue. ### Scalability -The catalog has to work on an undefined data volume, so the search path is -measured against synthetic catalogs that are much larger than the real one. All -numbers below are synthetic data from `db:seed:large`, loaded into separate -databases (`crate_100k_perf`, `crate_perf`) that no deployment touches. - -#### Method - -- PostgreSQL 18.3, Fedora, 15 GB RAM developer laptop, unix socket, warm cache. - Every query was run five times and the median is reported; paging and HTTP - were run three times because they take longer. Timings vary by roughly 10-30 % - between runs on this machine. -- 100,000 and 1,000,000 deterministic albums. Seeding took **10 s** and **95 s**. - The 100,000-album database is 41 MB with a 11 MB GIN index; the - 1,000,000-album database is 350 MB with a 75 MB GIN index (489 MB total). -- `ANALYZE` runs before measuring. Without it the first query after a seed is - planned against default estimates rather than the actual catalog. -- The scripts are committed and read-only: `server/scripts/benchmark.sh` runs all - of them. It refuses a database whose name does not end in `_perf`, because the - index comparison takes an exclusive lock on `album`. Seeding is a separate, - destructive step and is deliberately not part of the script. - -#### Full-text search and filters - -| Operation | Plan | 100,000 | 1,000,000 | -| ------------------------------------------------------------ | ----------------------------------------------------------- | ------: | --------: | -| Selective artist search, relevance order | Bitmap Index Scan on `album_search_vector_idx` + top-N sort | 2.6 ms | 11.7 ms | -| Broad search for `album`, which matches every row | Parallel Seq Scan + top-N sort | 16.3 ms | 83.7 ms | -| Two-genre AND plus two decades, title order | Parallel Seq Scan on `album_genre` + primary-key lookups | 20.9 ms | 87.6 ms | -| Complete broad `searchAlbums` request (facets, counts, page) | - | ~100 ms | ~580 ms | -| `searchAlbums` that matches 307,000 albums, with facets | - | - | ~256 ms | - -The broad query is planned as a sequential scan even with the GIN index, and -that is the right plan: it matches every album, so reading most of the index and -then most of the table costs more than reading the table once. The index earns -its space on selective queries, which is what a search box mostly produces. - -The last two rows are HTTP requests, not SQL. They are dominated by counting and -faceting the whole result set - `totalCount`, the genre and decade facets and the -reviewed count are all aggregate queries over the full match, not over the 20 -returned rows. That is the honest ceiling at one million albums. - -#### What the GIN index is worth - -`server/scripts/index-perf.sql` runs the same selective search with the index and -then again with it dropped inside a transaction that is rolled back, so the index -is always restored. - -| Dataset | With GIN | Without GIN | Difference | -| --------- | -------: | ----------: | ---------: | -| 100,000 | 2.6 ms | 15.9 ms | 6x | -| 1,000,000 | 11.7 ms | 62.3 ms | 5x | - -Without the index PostgreSQL falls back to a parallel sequential scan that reads -every row and discards all but the matches. The ratio is not larger because the -synthetic artist name repeats often; a rarer term gains more. - -#### Paging depth: cursor vs OFFSET - -`server/scripts/paging-perf.sql` compares the keyset page the API uses with the -`OFFSET` alternative, for the same rows and the same order (`TITLE_ASC`). - -| Rows already scrolled | 100,000 cursor | 100,000 OFFSET | 1,000,000 cursor | 1,000,000 OFFSET | -| --------------------- | -------------: | -------------: | ---------------: | ---------------: | -| 50,000 | 26 ms | 219 ms | 295 ms | 865 ms | -| 500,000 | - | - | 227 ms | 1167 ms | - -The cursor page does not grow with depth: page 25,000 at one million albums is -slightly faster than page 2,500, because the filter leaves fewer rows to sort. -`OFFSET` does grow: PostgreSQL produces and throws away every skipped row, and at -500,000 the sort spills 12 MB to disk. - -Cursor paging is still not free here, and the reason is worth stating plainly: -there is no index on the sort key, so the keyset query also scans and sorts the -remaining rows. Keyset is faster because it discards before sorting, not because -the scan is avoided. - -#### The index that paging wants - -Adding `(lower(title), id)` to the performance database shows what the missing -piece is. The index is 47 MB at one million albums. - -| Rows already scrolled | Cursor, indexed | OFFSET, indexed | -| --------------------- | --------------: | --------------: | -| 50,000 | 0.07 ms | 15 ms | -| 500,000 | 0.09 ms | 164 ms | - -With a matching composite index the cursor page is an `Index Scan` that reads 20 -rows regardless of depth, while `OFFSET` still walks and discards every skipped -entry. That is the shape the architecture decision describes. - -The index is not in the schema yet. The seven sort modes would need several -composite indexes, each one costs write time on seed and import, and the extra -47 MB per index is a bærekraft tradeoff that deserves its own measurement and -review. It is filed as a follow-up rather than slipped into this milestone, and -the numbers above are the argument for doing it. - -#### Reproducing - -```bash -# A separate database. Never the application's `crate` database. -createdb crate_perf -DATABASE_URL='postgres://t19:password@127.0.0.1:5432/crate_perf' \ - pnpm --filter @t19/server db:migrate -ALBUM_COUNT=1000000 DATABASE_URL='postgres://.../crate_perf' \ - pnpm db:seed:large -DATABASE_URL='postgres://.../crate_perf' server/scripts/benchmark.sh -DATABASE_URL='postgres://.../crate_perf' psql -v offset=500000 \ - -f server/scripts/paging-perf.sql -``` - -The HTTP timings use the committed request in `server/scripts/search-albums-perf.json`: - -```bash -curl -s -o /dev/null -w 'total %{time_total}s\n' \ - -H 'content-type: application/json' \ - --data @server/scripts/search-albums-perf.json \ - http://localhost:3001/graphql -``` - -These results were measured on a laptop, not on the VM. The VM runs the same -PostgreSQL major version, but the numbers are hardware-specific and should be -read as orders of magnitude and plan shapes rather than as VM guarantees. - -#### Prefix search (#55) - -Prefix search allows partial words to match album titles and artists, e.g. `radio` matches Radiohead. It uses the existing PostgreSQL GIN index and treats each search term as a prefix, requiring all terms to match. - -Performance was tested locally with 100,000 synthetic albums using PostgreSQL 18.6: - -| Search | Matches | Query time | -| ----------------------- | ------: | ---------: | -| `synth` | 100,000 | ~35 ms | -| `synthetic art` | 100,000 | ~53 ms | -| `synthetic album 99999` | 1 | ~1.3 ms | - -Broad prefixes can match many albums, increasing the work needed for ranking, counts and facets. The 100,000-album runs above used the existing GIN index; at 1,000,000 albums a prefix that matches most of the catalog is planned as a sequential scan, for the same reason as the broad query in the table above. - -Measurements are based on two local runs with cached synthetic data and cover only page queries, not the full GraphQL response or energy consumption. - -The read-only benchmark is available in `server/scripts/prefix-search-perf.sql`. +The brief puts no ceiling on catalog size, so the search path is measured against +synthetic catalogs much larger than the real one (100,000 and 1,000,000 albums), +in separate performance databases that no deployment touches. Headline numbers at +one million albums: + +| Operation | 100,000 | 1,000,000 | +| --------------------------------------------------- | ------: | --------: | +| Selective artist search, relevance order | 2.6 ms | 11.7 ms | +| Broad search that matches every row | 16.3 ms | 83.7 ms | +| Complete broad `searchAlbums` request (with facets) | ~100 ms | ~580 ms | +| Page 500,000 rows in, cursor | - | 227 ms | +| Page 500,000 rows in, `OFFSET` | - | 1167 ms | +| Page 500,000 rows in, cursor with a matching index | - | 0.09 ms | + +What that says: + +- Sorting, filtering and faceting stay in SQL, so the cost of one page does not + depend on how many pages came before it. +- Cursor paging does not grow with depth; `OFFSET` does, because PostgreSQL + builds and discards every skipped row. +- Without an index on the sort key the cursor page still scans and sorts the + tail, so keyset wins by discarding before sorting rather than by avoiding the + scan. With a composite `(lower(title), id)` index - 47 MB at one million + albums - it reads 20 rows at any depth: 0.09 ms against 164 ms for `OFFSET`. +- The GIN index is worth 5-6x on selective search, and is correctly bypassed for + a query that matches most of the catalog. +- The composite index is not in the schema yet. Seven sort modes would need + several of them, each costs write time and 47 MB, so it is + [#74](https://git.ntnu.no/IT2810-H26/T19-Project-2/issues/74) rather than part + of this milestone. + +The method, the full tables, the query plans and the reproduction steps are in +[docs/scalability.md](docs/scalability.md). The scripts are read-only and +committed: `server/scripts/benchmark.sh` runs all of them. ### Accessibility diff --git a/docs/scalability.md b/docs/scalability.md new file mode 100644 index 0000000..746bf9f --- /dev/null +++ b/docs/scalability.md @@ -0,0 +1,203 @@ +# Scalability + +The brief puts no ceiling on catalog size, so the search path is measured +against synthetic catalogs that are much larger than the real one. Every number +here comes from `db:seed:large`, loaded into separate databases +(`crate_100k_perf`, `crate_perf`) that no deployment touches. + +The summary and the conclusions are in the [README](../README.md#scalability). +This document holds the method, the full tables and the plans behind them. + +## Method + +- PostgreSQL 18.3, Fedora, 15 GB RAM developer laptop, unix socket, warm cache. + Every query was run five times and the median is reported; paging and HTTP + were run three times because they take longer. Timings vary by roughly 10-30 % + between runs on this machine. +- 100,000 and 1,000,000 deterministic albums. Seeding took **10 s** and **95 s**. + The 100,000-album database is 41 MB with a 11 MB GIN index; the + 1,000,000-album database is 350 MB with a 75 MB GIN index (489 MB total). +- `ANALYZE` runs before measuring. Without it the first query after a seed is + planned against default estimates rather than the actual catalog. +- The scripts are committed and read-only: `server/scripts/benchmark.sh` runs all + of them. It checks the name in the URL _and_ asks the server for + `current_database()`, because a `dbname` parameter or a service file can point + the connection somewhere else; both must end in `_perf`. The check matters + because the index experiments take an exclusive lock on `album`. Seeding is a + separate, destructive step and is deliberately not part of the script. +- These results were measured on a laptop, not on the VM. The VM runs the same + PostgreSQL major version, but the numbers are hardware-specific and should be + read as orders of magnitude and plan shapes rather than as VM guarantees. + +## Full-text search and filters + +| Operation | Plan | 100,000 | 1,000,000 | +| ------------------------------------------------------------ | ----------------------------------------------------------- | ------: | --------: | +| Selective artist search, relevance order | Bitmap Index Scan on `album_search_vector_idx` + top-N sort | 2.6 ms | 11.7 ms | +| Broad search for `album`, which matches every row | Parallel Seq Scan + top-N sort | 16.3 ms | 83.7 ms | +| Two-genre AND plus two decades, title order | Parallel Seq Scan on `album_genre` + primary-key lookups | 20.9 ms | 87.6 ms | +| Complete broad `searchAlbums` request (facets, counts, page) | - | ~100 ms | ~580 ms | +| `searchAlbums` that matches 307,000 albums, with facets | - | - | ~256 ms | + +The broad query is planned as a sequential scan even with the GIN index, and +that is the right plan: it matches every album, so reading most of the index and +then most of the table costs more than reading the table once. The index earns +its space on selective queries, which is what a search box mostly produces. + +The last two rows are HTTP requests, not SQL. They are dominated by counting and +faceting the whole result set - `totalCount`, the genre and decade facets and the +reviewed count are all aggregate queries over the full match, not over the 20 +returned rows. That is the honest ceiling at one million albums. + +## What the GIN index is worth + +`server/scripts/index-perf.sql` runs the same selective search with the index and +then again with it dropped inside a transaction that is rolled back, so the index +is always restored. + +| Dataset | With GIN | Without GIN | Difference | +| --------- | -------: | ----------: | ---------: | +| 100,000 | 2.6 ms | 15.9 ms | 6x | +| 1,000,000 | 11.7 ms | 62.3 ms | 5x | + +Without the index PostgreSQL falls back to a parallel sequential scan that reads +every row and discards all but the matches. The ratio is not larger because the +synthetic artist name repeats often; a rarer term gains more. + +## Paging depth: cursor vs OFFSET + +`server/scripts/paging-perf.sql` compares the keyset page the API uses with the +`OFFSET` alternative, for the same rows and the same order (`TITLE_ASC`). + +The keyset predicate is the row comparison + +```sql +WHERE (lower(title), id) > (:title_key, :id_key) +``` + +which is the same form as `crateForUser` in `server/src/resolvers.ts`. It matters +for the plan: PostgreSQL applies a row comparison as an index condition on a +matching composite index, while the logically equivalent +`a > x OR (a = x AND b > y)` is only a filter. The script therefore benchmarks +the shape the API actually runs. + +The script also checks that both queries return the same 20 rows before timing +them, so the comparison is between pages at the same depth rather than two pages +one row apart: + +``` + cursor_rows | offset_rows | shared_rows +-------------+-------------+------------- + 20 | 20 | 20 +``` + +| Rows already scrolled | 100,000 cursor | 100,000 OFFSET | 1,000,000 cursor | 1,000,000 OFFSET | +| --------------------- | -------------: | -------------: | ---------------: | ---------------: | +| 50,000 | 26 ms | 219 ms | 295 ms | 865 ms | +| 500,000 | - | - | 227 ms | 1167 ms | + +The cursor page does not grow with depth: page 25,000 at one million albums is +slightly faster than page 2,500, because the filter leaves fewer rows to sort. +`OFFSET` does grow: PostgreSQL produces and throws away every skipped row, and at +500,000 the sort spills 12 MB to disk. + +Cursor paging is still not free here, and the reason is worth stating plainly: +there is no index on the sort key, so the keyset query also scans and sorts the +remaining rows. Keyset is faster because it discards before sorting, not because +the scan is avoided. + +## The index that paging wants + +`server/scripts/paging-index-perf.sql` creates the missing index inside a +transaction, measures, and rolls the transaction back, so the schema is +unchanged afterwards: + +```sql +CREATE INDEX album_title_lower_id_perf_idx ON album (lower(title), id); +``` + +At one million albums the index is **47 MB**. With it, the keyset page becomes an +index condition and reads 20 rows regardless of depth: + +``` + Limit (cost=0.42..4.24 rows=20) (actual time=0.070..0.092 rows=20 loops=1) + -> Index Scan using album_title_lower_id_perf_idx on album + Index Cond: (ROW(lower(title), id) > ROW('synthetic album 549998'::text, 549998)) +``` + +`OFFSET` also uses the index, but it still has to walk and discard every skipped +entry (500,020 of them at this depth), which is where the time goes: + +``` + Limit (actual time=180.073..180.083 rows=20 loops=1) + -> Index Scan using album_title_lower_id_perf_idx on album (actual time=0.027..169.267 rows=500020 loops=1) +``` + +| Rows already scrolled | Cursor, indexed | OFFSET, indexed | +| --------------------- | --------------: | --------------: | +| 50,000 | 0.07 ms | 15 ms | +| 500,000 | 0.09 ms | 164 ms | + +That is the shape the architecture decision describes. The index is not in the +schema yet: the seven sort modes would need several composite indexes, each one +costs write time on seed and import, and the extra 47 MB per index is a +bærekraft tradeoff that deserves its own measurement and review. It is filed as a +follow-up rather than slipped into this milestone, and the numbers above are the +argument for doing it. + +## Prefix search (#55) + +Prefix search allows partial words to match album titles and artists, e.g. +`radio` matches Radiohead. It uses the existing PostgreSQL GIN index and treats +each search term as a prefix, requiring all terms to match. + +Performance was tested locally with 100,000 synthetic albums using PostgreSQL +18.6: + +| Search | Matches | Query time | +| ----------------------- | ------: | ---------: | +| `synth` | 100,000 | ~35 ms | +| `synthetic art` | 100,000 | ~53 ms | +| `synthetic album 99999` | 1 | ~1.3 ms | + +Broad prefixes can match many albums, increasing the work needed for ranking, +counts and facets. The 100,000-album runs above used the existing GIN index; at +1,000,000 albums a prefix that matches most of the catalog is planned as a +sequential scan, for the same reason as the broad query in the table above. + +Measurements are based on two local runs with cached synthetic data and cover +only page queries, not the full GraphQL response or energy consumption. + +The read-only benchmark is available in +`server/scripts/prefix-search-perf.sql`. + +## Reproducing + +```bash +# A separate database. Never the application's `crate` database. +createdb crate_perf +export DATABASE_URL='postgres://t19:password@127.0.0.1:5432/crate_perf' + +pnpm --filter @t19/server db:migrate +ALBUM_COUNT=1000000 pnpm db:seed:large +server/scripts/benchmark.sh + +# A deeper page, and the same depth with the composite index. +psql "$DATABASE_URL" -v offset=500000 -f server/scripts/paging-perf.sql +psql "$DATABASE_URL" -v offset=500000 -f server/scripts/paging-index-perf.sql +``` + +`psql` does not read `DATABASE_URL` by itself; it is passed explicitly above. +The same variable is what the API reads, so the HTTP numbers come from a server +built against the performance database: + +```bash +pnpm --filter @t19/server build +PORT=3001 node server/dist/index.js & +curl -s -o /dev/null -w 'total %{time_total}s\n' \ + -H 'content-type: application/json' \ + --data @server/scripts/search-albums-perf.json \ + http://localhost:3001/graphql +``` + +Stop the server and the database when you are done. They are throwaway.