From f56029bb423faf405aa51e66e18f70c1583d63d1 Mon Sep 17 00:00:00 2001 From: Michael Musa Date: Sun, 30 Aug 2026 18:45:15 +0000 Subject: [PATCH] perf: add filter-and-sort composite index for delivery list reads MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit DeliveryRepository.list filters deliveries by status (and optionally sender/recipient/driver address) but orders by createdAtChain desc, a shape the four single-column indexes could not serve from one index โ€” Postgres filtered the matched rows then sorted them in a second pass on every read. The indexer is the table's only writer and growth is unbounded, so this degrades monotonically as deliveries accumulate. Back the query with deliveries(status, created_at_chain desc) so the planner resolves the predicate and the order from a single index, and drop the single-column deliveries(status) index it fully supersedes (it is covered as the composite's leading column) to avoid duplicate write/maintenance cost. sender_address/recipient_address/driver_address indexes stay: they are filter-only columns with no covering composite. Verified against a Dockerized Postgres with ~60k seeded deliveries via EXPLAIN ANALYZE: the filter resolves through deliveries_status_created_at_chain_idx, and the paginated shape (LIMIT/cursor) becomes a sort-free ordered index scan. The unbounded no-take query still sorts all matches, which is inherent to missing pagination and tracked separately (GitHub #19); this change is not a regression there and unlocks the sort-free path once a take lands. Also documents the full read-path index rationale (notifications, audit_logs, deliveries, escrows, driver_profiles) in docs/DATABASE.md. Generated with Codebuff ๐Ÿค– Co-Authored-By: Codebuff --- docs/DATABASE.md | 29 +++++++++++++++++++ .../migration.sql | 5 ++++ prisma/schema.prisma | 9 +++++- 3 files changed, 42 insertions(+), 1 deletion(-) create mode 100644 prisma/migrations/20260830183747_delivery_filter_sort_index/migration.sql diff --git a/docs/DATABASE.md b/docs/DATABASE.md index 41e999e..394080a 100644 --- a/docs/DATABASE.md +++ b/docs/DATABASE.md @@ -29,6 +29,35 @@ PostgreSQL via Prisma. Full source of truth: [`prisma/schema.prisma`](../prisma/ `blockchain_checkpoints` (one row per `(contractName, network)`) and `blockchain_events` (append-only raw event log, unique on `(contractName, network, rpcEventId)` โ€” the Soroban RPC's own globally-unique event id) are the durability layer described in `ARCHITECTURE.md` ยง6. `blockchain_events` is intentionally kept even after a module has processed an event โ€” it's the replay/audit source if a module's read-model logic needs to be rebuilt. +## Read-Path Indexes + +Several ordered list / aggregation reads filter on one column and sort on +another (or group by one column). Each of those reads is backed by a +composite index matching its exact filter-and-sort (or group-by) shape, so +the planner resolves the predicate and the order from a single index instead +of sorting the matched rows in a second pass: + +| Query shape (module) | Composite index | +|---|---| +| `notifications.listByUserId` โ€” `WHERE user_id = ? AND status = ? โ€ฆ ORDER BY created_at DESC` (`notifications`) | `notifications(user_id, created_at desc)` | +| `auditLogs.list` โ€” `ORDER BY created_at DESC` (unbounded append-only log) (`admin`) | `audit_logs(created_at desc)` | +| `DeliveryRepository.list` โ€” `WHERE status = ? โ€ฆ ORDER BY created_at_chain DESC` (`deliveries`) | `deliveries(status, created_at_chain desc)` | +| `analytics.getGmvByToken` โ€” `GROUP BY token` for `status = 'RELEASED'` (`analytics`) | `escrows(status, token)` | +| `analytics.getDriverTierCounts` โ€” `GROUP BY tier` (`analytics`) | `driver_profiles(tier)` | + +See `prisma/schema.prisma` for the authoritative definitions (each carries a +header comment naming the exact read it backs) and `API_REFERENCE.md` ยง +/notifications + ยง/analytics for the endpoint-level detail. When a new +composite's leading column fully covers an existing single-column index +(i.e. every predicate that could use the single-column index is served by +that composite as a prefix), the single-column index is dropped in the same +migration rather than kept โ€” that's what removed the former `deliveries_status +_idx`, which was fully covered by `deliveries(status, created_at_chain desc)` +and would otherwise carry duplicate write/maintenance cost for no read benefit. +`deliveries`'s `sender_address`, `recipient_address`, and `driver_address` +indexes are kept because they are filter-only columns with no composite +covering them. + ## Migrations ```bash diff --git a/prisma/migrations/20260830183747_delivery_filter_sort_index/migration.sql b/prisma/migrations/20260830183747_delivery_filter_sort_index/migration.sql new file mode 100644 index 0000000..9a1f79a --- /dev/null +++ b/prisma/migrations/20260830183747_delivery_filter_sort_index/migration.sql @@ -0,0 +1,5 @@ +-- DropIndex +DROP INDEX "deliveries_status_idx"; + +-- CreateIndex +CREATE INDEX "deliveries_status_created_at_chain_idx" ON "deliveries"("status", "created_at_chain" DESC); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index c737cd3..f26d6de 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -189,10 +189,17 @@ model Delivery { escrow Escrow? dispute Dispute? + /// Backs `deliveryRepository.list`'s filter-and-sort shape + /// (`where: { โ€ฆ, status? }`, `orderBy: { createdAtChain: 'desc' }` in + /// `src/modules/deliveries/infrastructure/prisma-delivery-repository.ts`) + /// so the planner can satisfy the predicate and the sort from one index + /// instead of sorting matched rows. `status` alone is covered by this + /// composite's leading column, so the former single-column + /// `@@index([status])` was dropped rather than kept for both. @@index([senderAddress]) @@index([recipientAddress]) @@index([driverAddress]) - @@index([status]) + @@index([status, createdAtChain(sort: Desc)]) @@map("deliveries") }