Skip to content

Commit 0af4b17

Browse files
zachdunnclaude
andauthored
db: composite index on releases (published_at, id) (#762)
db: composite index on releases (published_at, id) for cursor walks (#756) GraphQL latestReleases and REST /v1/orgs/:slug/releases both order by (published_at DESC, id DESC) and tiebreak the cursor on id when published_at is equal. The existing idx_releases_published only covers published_at alone, so ties (common — batch ingest stamps dozens of rows with one poll-cycle timestamp) force a temp B-tree filesort over the tied range. Add a hand-authored DESC composite index. Drizzle's index() helper doesn't emit direction, so the schema declaration is best-effort; the migration carries the real DDL. Verified on staging via EXPLAIN QUERY PLAN: - Before: SEARCH USING INDEX idx_releases_published (published_at<?) + USE TEMP B-TREE FOR LAST TERM OF ORDER BY - After: SEARCH USING COVERING INDEX idx_releases_published_id (published_at<?) Single-column idx_releases_published is left in place — many REST queries still filter/order on published_at alone and the optimizer may prefer the narrower index for those scans. Drop is a follow-up if EXPLAIN later shows it unused. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 69eaefb commit 0af4b17

2 files changed

Lines changed: 28 additions & 0 deletions

File tree

packages/core/src/schema.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -270,6 +270,11 @@ export const releases = sqliteTable(
270270
uniqueIndex("idx_releases_source_url").on(table.sourceId, table.url),
271271
index("idx_releases_source_published").on(table.sourceId, table.publishedAt),
272272
index("idx_releases_published").on(table.publishedAt),
273+
// Covers `(published_at DESC, id DESC)` cursor walks — GraphQL `latestReleases`
274+
// and REST `/v1/orgs/:slug/releases`. Drizzle's index() helper doesn't emit
275+
// direction modifiers, so the matching DESC index is hand-authored in the
276+
// migration file (20260506000000_releases_published_id_index.sql).
277+
index("idx_releases_published_id").on(table.publishedAt, table.id),
273278
index("idx_releases_source_suppressed_published").on(
274279
table.sourceId,
275280
table.suppressed,
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
-- #756: composite index for (published_at DESC, id DESC) cursor walks.
2+
--
3+
-- The GraphQL `latestReleases` resolver and REST `/v1/orgs/:slug/releases`
4+
-- both order by `(published_at DESC, id DESC)` and tiebreak the cursor on
5+
-- `id` when `published_at` is equal. The existing `idx_releases_published`
6+
-- only covers `published_at` alone, so ties on `published_at` (common —
7+
-- batch ingest stamps dozens of rows with a single poll-cycle timestamp)
8+
-- force a filesort over the tied range.
9+
--
10+
-- DESC ordering is explicit because SQLite can walk a DESC index forward
11+
-- to satisfy `ORDER BY ... DESC` without a reverse-scan plan, which keeps
12+
-- the cursor predicate (`(published_at, id) < (?, ?)`) on the optimizer's
13+
-- happy path. (An ASC index would also work — SQLite reverses scans
14+
-- transparently — but matching the ORDER BY direction is the conservative
15+
-- pick and avoids relying on the reverse-scan optimizer staying smart.)
16+
--
17+
-- The single-column `idx_releases_published` is intentionally NOT dropped
18+
-- here. Many REST endpoints filter or order on `published_at` alone, and
19+
-- although the composite's leading column can serve those queries, the
20+
-- single-column index is physically smaller and may be the optimizer's
21+
-- pick for narrow scans. Drop is a follow-up if/when EXPLAIN shows the
22+
-- single-column form is unused.
23+
CREATE INDEX idx_releases_published_id ON releases (published_at DESC, id DESC);

0 commit comments

Comments
 (0)