Files
triggerdotdev--trigger.dev/internal-packages/database
Eric Allam 4c58091973 perf(database): index ProjectAlert.channelId so alert-channel deletes stop seq-scanning (#4554)
## Why this change

Deleting a `ProjectAlertChannel` fires the FK cascade `DELETE FROM ONLY
"ProjectAlert" WHERE $1 = "channelId"`. That cascade is scan-shaped:
with no index on `channelId`, it reads the entire `ProjectAlert` table
to find the few child rows belonging to the deleted channel. The parent
`DELETE ProjectAlertChannel` does almost no work itself; its latency is
dominated by this cascade. `ProjectAlert` is append-heavy and grows over
time, so the scan cost only increases.

## Diagnosis

`ProjectAlert` had no index on `channelId` (only `pkey` + a `friendlyId`
unique). The cascade therefore did a full sequential scan of the whole
table. The sibling `ProjectAlertStorage` cascade on the same delete is
index-backed and stays fast, which isolates the missing index as the
cause.

## Change

Add `@@index([channelId])` on `ProjectAlert`, created with `CREATE INDEX
CONCURRENTLY IF NOT EXISTS` so `prisma migrate deploy` stays safe on a
live table.

## Benchmark (local, seeded)

Local Postgres seeded with 1,000,000 `ProjectAlert` rows across 50
channels (~20k rows per channel), `EXPLAIN (ANALYZE, BUFFERS)` on the
cascade delete:

| | before | after |
|---|---|---|
| plan | Seq Scan (1M rows) | Bitmap Index Scan |
| direct child delete | 740 ms | 22 ms |
| parent delete `ProjectAlert_channelId_fkey` trigger | 77.7 ms | 23.8
ms |

## Expected impact

The cascade drops from a full-table sequential scan to a targeted index
lookup. The win grows with the table: the more rows in `ProjectAlert`,
the more a scan costs and the more the index saves, so the benefit is
larger than the seeded numbers above.

## Risks

- One extra btree to maintain on every `ProjectAlert` insert; acceptable
for a single-column index on a high-insert table, and it should be
pre-created before the migration deploys (per the repo index rules).
- No behavior change: no rows orphaned, no ordering or result-set
change, read paths untouched.

## Follow-up

`ProjectAlert`'s other cascade FK columns (`projectId`, `environmentId`,
`workerDeploymentId`) are also unindexed, but their parents are
soft-deleted rather than physically removed, so those cascades do not
currently fire. Lower priority unless a hard-delete path is introduced.
2026-08-10 13:54:15 +01:00
..

@trigger.dev/database

This is the internal database package for the Trigger.dev project. It exports a generated prisma client that can be instantiated with a connection string.

How to switch branches when you've done migrations

Sometimes you've applied migrations and then want to switch branches without wiping out your local database.

To do this you can run the following command:

DB_VOLUME=database-data-alt pnpm run docker

This will switch to the alt volume for your local database. This database will be blank if you haven't switched to this volume before, so you'll need to follow the normal steps (in the Contributing guide) to get setup, e.g. apply migrations and seed.

To switch back to the original volume, run the following command:

pnpm run docker

How to add a new index on a large table

  1. Modify the Prisma.schema with a single index change (no other changes, just one index at a time)
  2. Create a Prisma migration using cd internal-packages/database && pnpm run db:migrate:dev:create
  3. Modify the SQL file: add IF NOT EXISTS to it and CONCURRENTLY:
CREATE INDEX CONCURRENTLY IF NOT EXISTS "JobRun_eventId_idx" ON "JobRun" ("eventId");
  1. Dont apply the Prisma migration locally yet. This is a good opportunity to test the flow.
  2. Manually apply the index to your database, by running the index command.
  3. Then locally run pnpm run db:migrate:deploy

Before deploying

Run the index creation before deploying

CREATE INDEX CONCURRENTLY IF NOT EXISTS "JobRun_eventId_idx" ON "JobRun" ("eventId");

These commands are useful:

-- creates an index safely, this can take a long time (2 mins maybe)
CREATE INDEX CONCURRENTLY IF NOT EXISTS "JobRun_eventId_idx" ON "JobRun" ("eventId");
-- checks the status of an index
SELECT * FROM pg_stat_progress_create_index WHERE relid = '"JobRun"'::regclass;
-- checks if the index is there
SELECT * FROM pg_indexes WHERE tablename = 'JobRun' AND indexname = 'JobRun_eventId_idx';

Now, when you deploy and prisma runs the migration, it will skip the index creation because it already exists. If you don't do this, there will be pain.