feat(datastore): repository List and FindByName queries (S17 Phase 1 Step 5)
What
S17 Phase 1 Step 5: the datastore read layer for the repository management API.
Adds RepositoryStore.List (keyset pagination) and FindByName, drops the four
superseded 2-column sort indexes, and ships integration tests.
Plan: docs/plans/2026-06-22-s17-phase1-repository-crud.md (Step 5). Depends on
Step 4 (!668 (merged), merged).
Note
Stacked on !702 (merged). This MR targets the Step 7 branch
(jdrpereira/s17-phase1-repository-crud-step-7), not main, so the diff shows
only the Step 5 read layer on top of the shared repositories.go that Steps
6/7 introduce (merge order 6 → 7 → 5). When !702 (merged) merges, GitLab auto-retargets
this MR to main. Review the diff as-is.
Changes
List: keyset pagination overname/last_updated_at/artifacts_count/downloads_count/size_bytes(both directions), optionalformat/kindfilters,soft_deleted_at IS NULL, scoped bynamespace_id;hasMorevia a limit+1 probe with a non-positive-limit floor guard.FindByName: active row by(namespace_id, name), elseErrNotFound.- Drop migration for the four 2-column sort indexes the Step 4
(namespace_id, <col>, id)keyset indexes supersede; the deep-page EXPLAIN test gates that every sort stays index-backed after the drop. - Adds a
(namespace_id, format, name)index so the common format-filtered name listing stays index-only instead of filtering wrong-format rows.
last_updated_at sorts by COALESCE(last_updated_at, created_at)
A repository reads null last_updated_at until its first artifact update, so
"never updated" is the common case, not an edge. Two ways to rank those rows
when sorting by recency:
- NULLS LAST (the obvious one): a just-created repository sinks to the bottom
of "recently updated". That is the surprising UX, and it forces the keyset
bound to page across a NULLS region a row-value comparison cannot express
without a NULL-region special case (an
OR ... IS NULLthe planner can't fold into one ordered index range). COALESCE(last_updated_at, created_at)(chosen): a never-updated repository ranks by its creation time, so a fresh repo appears at the top of "recently updated" (the conventional repository-list behavior). The sort key is then never null, so its keyset bound is the same plain row-value form as the counter columns, index-ordered in one scan.
This was settled with the spec author. It is both the better UX and the simpler
implementation: it removes the entire NULL-region keyset machinery (a dedicated
bound function plus its tests) and the *time.Time cursor nil-overload. The
serialized last_updated_at field stays nullable; only the sort key coalesces.
Step 5 swaps the raw last_updated_at keyset index for an expression index
(namespace_id, COALESCE(last_updated_at, created_at) DESC, id DESC). The spec
(Resolution 7, List) and the plan are updated to record this.
Spec coverage
| # | Criterion | Tests |
|---|---|---|
| AC-7 | Detail returns row / not-found | FindByName hit/miss (404 mapping = Step 8) |
| AC-8 | List filters format/kind, sorts each col both ways | List_Filter, List_SortColumnsBothDirections |
| AC-9 | Keyset: next-signal, no dups/gaps, final page omits | List_KeysetBoundaryNoGapsOrDuplicates, List_HasMoreSignal |
| AC-12 | Cross-slug isolation | List_NamespaceIsolation, FindByName isolation |
| Res.7 | Every sort index-backed (no post-scan Sort) | List_DeepPageIsIndexBacked (all five columns incl. COALESCE) |
Keyset correctness over the trickier paths is pinned by List_KeysetTiedCounters
(row-value tiebreaker over tied counter values) and
List_KeysetLastUpdatedAtCoalesce (mixed updated/never-updated walk == single
page). Handler-owned items (param 400, opaque cursor, Link header, status
codes, limit cap) are Step 8.
Review notes
AI pre-review (/review-branch + pr-review-toolkit) caught, and fixed test-first,
a keyset data-loss bug: List sorted by last_updated_at silently dropped the
NULL-timestamp region once a page ended inside it. The spec author then chose the
COALESCE semantics above, which removes that region (and its bug class)
entirely. Also addressed: a counter-column tie-pagination coverage gap and a
non-positive-Limit panic guard.
Follow-ups (not in this MR)
- ADR-007's repository index list is amended to record this MR's index changes
(the
COALESCE(last_updated_at, created_at)keyset index replacing thelast_updated_at DESC NULLS LASTkeyset, and the new(namespace_id, format, name)filter index) via handbook MR gitlab-com/content-sites/handbook!20220 (merged). This MR is marked blocked by it (ADRs are synced from the handbook, not edited in this repo). - Promote
RepositoryFormat/RepositoryKindto namedint16types for compile-time mix-safety (cross-cutting; coordinate with the Step 6/7 write path).
Testing
Integration tests (testcontainers PostgreSQL) per the table above, plus the
counter-tie and COALESCE keyset walks, deep-page EXPLAIN (index-backed, no Sort,
all five columns), soft-delete exclusion, non-positive-limit, and the updated
Step 4 migration schema test (COALESCE expression index). golangci-lint clean;
migrations up/down round-trip and structure.sql regenerated.
Related to #171 (closed)
Database Review Evidence
Migrations
Note
Timings are from CI (db:migrate matrix, goose verbose) against an empty
database, in apply / rollback order per PG version. Production-scale
validation via Database Lab is not yet available. See
Database review evidence
for the matrix rationale and how to read the numbers.
| Migration | PG 16 | PG 17 | PG 18 |
|---|---|---|---|
20260622130000_drop_redundant_repository_sort_indexes.sql |
OK (73.95ms / 777.00ms) | OK (62.96ms / 183.16ms) | OK (92.36ms / 249.98ms) |
20260625120000_replace_last_updated_at_keyset_index_with_coalesce.sql |
OK (219.04ms / 208.06ms) | OK (63.63ms / 235.46ms) | OK (88.41ms / 104.75ms) |
20260625130000_add_repository_format_name_index.sql |
OK (198.07ms / 20.14ms) | OK (46.68ms / 20.85ms) | OK (64.69ms / 26.39ms) |
Migration notes:
- All three migrations are index-only and sub-second on an empty database; none approaches the 5-minute boot budget. They recurse to the 64 hash partitions of
repositories(blocking parent-level DDL, safe only because the tables are empty in dev with no production deployment, per the migration headers). - PG 16 is consistently ~3-4x slower than PG 17/18 on the index-build paths (coalesce
up219ms vs 64/88ms; formatup198ms vs 47/65ms; dropdown777ms vs 183/250ms) — PG 16 partition index-creation overhead across the 64 partitions, not a forward-path concern; the largest number is a 777ms rollback on an empty DB. No action. drop_redundant'sdown(777/183/250ms) is slower than itsup(~63-92ms) because the rollback recreates four partial indexes while the forward path only drops them. Expected asymmetry.
Queries
Note
Plans are from EXPLAIN (ANALYZE, BUFFERS) against an ephemeral PostgreSQL
17 container (matching GL_PG_CURR_VERSION from
.gitlab-ci-other-versions.yml), with 5000 repositories rows seeded in one
namespace (one hash partition) and the container torn down at the end of the
run. Numbers reflect moderate cardinality and do not capture production-scale
effects. See
Database review evidence
for seed sizing and methodology. Expand each row for the rendered SQL, bound
args, and raw plan.
| Method | Plan node | Index | Rows (plan / actual) | Cost | Time | Buffers (hit / read) | Partitions |
|---|---|---|---|---|---|---|---|
datastore.FindByName |
Index Scan | repositories_pNN_namespace_id_name_idx1 |
1 / 1 | 8.30 | 0.045ms | 3 / 0 | 1 |
datastore.List (sort last_updated_at DESC, cursor) |
Index Scan | repositories_pNN_namespace_id_coalesce_id_idx |
21 / 21 | 3.54 | 0.334ms | 3 / 0 | 1 |
datastore.List (sort name ASC, cursor) |
Index Scan | repositories_pNN_namespace_id_name_idx1 |
21 / 21 | 2.62 | 0.021ms | 4 / 0 | 1 |
datastore.List (sort artifacts_count DESC, cursor) |
Index Only Scan | repositories_pNN_namespace_id_artifacts_count_id_idx |
21 / 21 | 2.65 | 0.054ms | 23 / 0 | 1 |
datastore.List (sort name ASC, format filter, cursor) |
Index Scan | repositories_pNN_namespace_id_format_name_idx |
21 / 21 | 5.03 | 0.043ms | 4 / 0 | 1 |
Query notes:
- Every query prunes to a single partition via the
namespace_idliteral and is satisfied by an index with no post-scan Sort. Thelast_updated_atsort uses the newCOALESCE(last_updated_at, created_at)expression index (..._coalesce_id_idx), confirming the keyset sort stays index-ordered after the redesign. Listwith aformatfilter and the defaultnamesort uses the(namespace_id, format, name)index (added in this MR), soformatis an index key and the scan seeks the(format, name)range index-only — no post-indexFilter, no rows removed. The rarer counter/timestamp sorts keepformat/kindas a post-index Filter (no dedicated composite index).
datastore.List (sort last_updated_at DESC, cursor)
Summary: Plan matches intent. Index Scan over the COALESCE expression index
with the namespace_id literal pruning to one of 64 partitions; the row-value
keyset bound (COALESCE(last_updated_at, created_at), id) < ($2, $3) folds into
the index range, so no post-scan Sort. Actual rows match the LIMIT (21 / 21),
execution under 0.4ms at 5000 seeded rows. No anomalies.
Seed shape: namespaces=1, repositories=5000 (one partition; last_updated_at
NULL for every 5th row so COALESCE exercises both branches)
Rendered SQL:
SELECT ... FROM public.repositories
WHERE ((repositories.namespace_id = $1::uuid) AND (repositories.soft_deleted_at IS NULL))
AND ((COALESCE(repositories.last_updated_at, repositories.created_at), repositories.id) < ($2::timestamp with time zone, $3))
ORDER BY COALESCE(repositories.last_updated_at, repositories.created_at) DESC, repositories.id DESC
LIMIT $4;Bound args: [<namespace_id>, 2026-01-02 17:40:00+00, 2500, 21]
Plan (EXPLAIN (ANALYZE, BUFFERS)):
Limit (cost=0.28..3.54 rows=21 width=16) (actual time=0.299..0.303 rows=21 loops=1)
Buffers: shared hit=3
-> Index Scan using repositories_p16_namespace_id_coalesce_id_idx on repositories_p16 repositories (cost=0.28..258.94 rows=1667 width=16) (actual time=0.299..0.301 rows=21 loops=1)
Index Cond: ((namespace_id = '<ns>'::uuid) AND (ROW(COALESCE(last_updated_at, created_at), id) < ROW('2026-01-02 17:40:00+00'::timestamp with time zone, '2500'::bigint)))
Buffers: shared hit=3
Planning Time: 0.211 ms
Execution Time: 0.334 msdatastore.List (sort artifacts_count DESC, cursor)
Summary: Index Only Scan over the (namespace_id, artifacts_count, id) keyset
index, single partition, no post-scan Sort; the row-value bound folds into the
range. Heap Fetches: 21 (visibility checks) keeps buffers low. 21 / 21 rows,
under 0.06ms. No anomalies. The downloads_count and size_bytes sorts share
this shape on their respective keyset indexes.
Seed shape: namespaces=1, repositories=5000 (one partition)
Rendered SQL:
SELECT ... FROM public.repositories
WHERE ((repositories.namespace_id = $1::uuid) AND (repositories.soft_deleted_at IS NULL))
AND ((repositories.artifacts_count, repositories.id) < ($2, $3))
ORDER BY repositories.artifacts_count DESC, repositories.id DESC
LIMIT $4;Bound args: [<namespace_id>, 50, 2500, 21]
Plan (EXPLAIN (ANALYZE, BUFFERS)):
Limit (cost=0.28..2.65 rows=21 width=16) (actual time=0.021..0.045 rows=21 loops=1)
-> Index Only Scan using repositories_p16_namespace_id_artifacts_count_id_idx on repositories_p16 repositories (cost=0.28..293.47 rows=2600 width=16) (actual time=0.021..0.043 rows=21 loops=1)
Index Cond: ((namespace_id = '<ns>'::uuid) AND (ROW(artifacts_count, id) < ROW('50'::bigint, '2500'::bigint)))
Heap Fetches: 21
Buffers: shared hit=23
Planning Time: 0.152 ms
Execution Time: 0.054 msdatastore.List (sort name ASC, cursor) / FindByName / format filter
name sort uses the unique (namespace_id, name) index with a single-column
bound (name > $2), no id tiebreaker needed; Index Scan, 21 / 21, 0.021ms.
FindByName is a point lookup on the same unique index
(namespace_id = $1 AND name = $2), 1 / 1, 0.045ms.
format filter seeks the (namespace_id, format, name) index range, so
format is an index key, not a post-index Filter; Index Scan, 21 / 21,
0.043ms, no rows removed.
Rendered SQL (name + format filter):
SELECT ... FROM public.repositories
WHERE (((repositories.namespace_id = $1::uuid) AND (repositories.soft_deleted_at IS NULL))
AND (repositories.format = $2::smallint)) AND (repositories.name > $3::text)
ORDER BY repositories.name ASC
LIMIT $4;Plan (format filter):
Limit (cost=0.28..5.03 rows=21 width=187) (actual time=0.022..0.027 rows=21 loops=1)
Buffers: shared hit=4
-> Index Scan using repositories_p16_namespace_id_format_name_idx on repositories_p16 repositories (cost=0.28..185.10 rows=817 width=187) (actual time=0.021..0.025 rows=21 loops=1)
Index Cond: ((namespace_id = '<ns>'::uuid) AND (format = '2'::smallint) AND (name > 'explain-2500'::text))
Buffers: shared hit=4
Planning Time: 1.876 ms
Execution Time: 0.043 ms