diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index df76816..e36f14a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -76,9 +76,101 @@ 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 + # 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 + # -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..bee3c0c 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 @@ -135,51 +148,42 @@ 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 - -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. - -The scripts read `server/.env`, but `psql` needs `DATABASE_URL` exported in your shell (for example, `export DATABASE_URL='postgres://...'` in Bash). - -```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. - -#### 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. All tested prefix queries used the existing GIN index. - -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. +### Scalability + +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/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/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. 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.`); diff --git a/server/scripts/benchmark.sh b/server/scripts/benchmark.sh new file mode 100755 index 0000000..d170c66 --- /dev/null +++ b/server/scripts/benchmark.sh @@ -0,0 +1,66 @@ +#!/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 +# 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}" + +refuse() { + echo "::error::$1" >&2 + echo "Create a separate performance database and seed it, e.g. crate_perf." >&2 + exit 1 +} + +# 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) ;; + *) 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: $connected_db" +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/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/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-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; diff --git a/server/scripts/paging-perf.sql b/server/scripts/paging-perf.sql new file mode 100644 index 0000000..f043a57 --- /dev/null +++ b/server/scripts/paging-perf.sql @@ -0,0 +1,75 @@ +-- 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 +-- +-- :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 - 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 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;