Skip to content
Open
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
94 changes: 93 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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/<major>/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
Expand Down
98 changes: 51 additions & 47 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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

Expand Down
6 changes: 6 additions & 0 deletions docs/VM-setup.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand Down
Loading