feat(datastore): create namespaces idempotently on the platform anchor

What

The store half of POST /api/gitlab/v1/namespaces (S33, Namespace provisioning): NamespaceStore.Create, idempotent on the platform anchor (platform, entity_type, entity_id).

  • One transaction, two inserts: the namespace row and its default repository_collections row commit together, so a provisioned namespace can serve its first repository create (linkDefaultCollection resolves the default collection).
  • Idempotent replay: an anchor conflict (partial unique index, WHERE purged_at IS NULL) probes the existing row and exact-compares all six caller-owned fields. Exact match replays the stored row read-only (created == false, original created_at); any mismatch is ErrNamespaceAnchorMismatch. A purged anchor re-onboards as a fresh row by design.
  • Conflict classification: 23505 errors are classified by constraint name; an exact replay violates both unique indexes and Postgres reports the slug index first, so Create probes the anchor on either conflict before concluding ErrNamespaceSlugTaken. Both sentinels are returned unwrapped so PgError detail (which echoes caller values) cannot leak.
  • Rollback failures are logged (sql.ErrTxDone filtered) rather than joined onto classification sentinels, where replay paths would discard them.

Stacking

Rebased onto main after !1029 (merged) (Step 4, GitLab API scaffolding) merged. The handler half follows in !1052 (merged), stacked on this branch. Step 5 of the S33 Phase 1 plan was split store/handler to keep each MR reviewable.

Testing

DB-backed integration tests pin the contract through the real queries: full-field persist with lifecycle columns NULL, exact replay (no second row, original created_at), replay against a suspended row, per-field mismatch (slug, both billing fields), slug taken, purged-anchor re-onboard plus replay-after-re-onboard (pins the probe's purged_at IS NULL predicate), default-collection presence/atomicity (a forced collection-insert failure rolls back the namespace row; a slug conflict leaves no orphan collection).

No docs/testing/ catalog update: the GitLab API surface has no e2e scenario catalog yet; e2e coverage for provisioning arrives with the platform-client integration.

Database Review Evidence

Queries

Note

Plans are from EXPLAIN (ANALYZE, BUFFERS) against a local PostgreSQL 14.22 (Homebrew) instance with a throwaway role and database, with synthesized seed data rolled back per query and the role and database dropped at the end of the run. Deviation: the canonical version is PostgreSQL 17 (GL_PG_CURR_VERSION in .gitlab-ci-other-versions.yml), but Docker was unavailable for the ephemeral container, so the run used the local PostgreSQL 14 fallback. Numbers reflect moderate cardinality and do not capture production-scale effects. See Database review evidence for seed sizing, methodology, and the anomalies the skill flags. Expand each row's details for the seed shape, rendered SQL, bound args, and raw plan.

Method Plan node Index Rows (plan / actual) Cost Time Buffers (hit / read) Partitions
datastore.findAnchorRow Limit unique_namespaces_platform_and_entity_type_and_entity_id 1 / 1 8.30 0.005ms 3 / 0 n/a
datastore.insertDefaultCollection Insert n/a 0 / 0 0.01 0.855ms 3 / 3 1
datastore.insertNamespace Insert n/a 1 / 1 0.01 1.824ms 23 / 3 n/a
datastore.findAnchorRow

Summary: Plan matches the method's intent: the anchor probe is a single-row Index Scan over the partial unique index unique_namespaces_platform_and_entity_type_and_entity_id, whose WHERE (purged_at IS NULL) predicate the query's purged_at IS NULL clause satisfies. Estimate matches reality (1 / 1) and execution stays at microseconds against 5000 seeded namespaces. No anomalies.

Seed shape: namespaces=5000

Rendered SQL:

SELECT namespaces.id AS "namespaces.id",
     namespaces.created_at AS "namespaces.created_at",
     namespaces.slug AS "namespaces.slug",
     namespaces.platform AS "namespaces.platform",
     namespaces.entity_type AS "namespaces.entity_type",
     namespaces.entity_id AS "namespaces.entity_id",
     namespaces.billing_entity_type AS "namespaces.billing_entity_type",
     namespaces.billing_entity_id AS "namespaces.billing_entity_id",
     namespaces.delivery_mode_override AS "namespaces.delivery_mode_override",
     namespaces.suspended_at AS "namespaces.suspended_at",
     namespaces.disabled_at AS "namespaces.disabled_at",
     namespaces.blocked_at AS "namespaces.blocked_at",
     namespaces.deleted_at AS "namespaces.deleted_at",
     namespaces.purged_at AS "namespaces.purged_at"
FROM public.namespaces
WHERE (((namespaces.platform = $1::text) AND (namespaces.entity_type = $2::text)) AND (namespaces.entity_id = $3::text)) AND (namespaces.purged_at IS NULL)
LIMIT $4;

Bound args: ['gitlab', 'group', 'review-prep-entity-002500', 1]

Plan (EXPLAIN (ANALYZE, BUFFERS) output):

 Limit  (cost=0.28..8.30 rows=1 width=160) (actual time=0.005..0.005 rows=1 loops=1)
   Buffers: shared hit=3
   ->  Index Scan using unique_namespaces_platform_and_entity_type_and_entity_id on namespaces  (cost=0.28..8.30 rows=1 width=160) (actual time=0.005..0.005 rows=1 loops=1)
         Index Cond: ((platform = 'gitlab'::text) AND (entity_type = 'group'::text) AND (entity_id = 'review-prep-entity-002500'::text))
         Buffers: shared hit=3
 Planning:
   Buffers: shared hit=83
 Planning Time: 0.608 ms
 Execution Time: 0.012 ms

Timings: planning 0.608ms, execution 0.012ms, total 0.620ms.

datastore.insertDefaultCollection

Summary: Plan matches the method's intent: a single-row INSERT into the hash-partitioned repository_collections, routed to exactly one partition (repository_collections_p14) by the namespace_id key. The FK trigger validating namespace_id against namespaces accounts for most of the execution time (0.778ms of 1.746ms) — a single PK probe on the ancestor row seeded in the same transaction. No anomalies.

Seed shape: namespaces=1

Rendered SQL:

INSERT INTO public.repository_collections (namespace_id, id, is_default, name)
VALUES ($1::uuid, $2::uuid, $3::boolean, $4::text);

Bound args: ['9e4f6a2b-7c1d-4e8f-a3b5-0d9c8b7a6f5e', '3c9a7f10-88b2-4de1-b7a4-6f5e4d3c2b1a', true, 'default']

Plan (EXPLAIN (ANALYZE, BUFFERS) output):

 Insert on repository_collections  (cost=0.00..0.01 rows=0 width=0) (actual time=0.855..0.855 rows=0 loops=1)
   Buffers: shared hit=3 read=3 dirtied=7 written=4
   ->  Result  (cost=0.00..0.01 rows=1 width=73) (actual time=0.001..0.001 rows=1 loops=1)
 Planning Time: 0.032 ms
 Trigger for constraint fk_repository_collections_namespace_id_namespaces on repository_collections_p14: time=0.778 calls=1
 Execution Time: 1.746 ms

Timings: planning 0.032ms, execution 1.746ms, total 1.778ms.

datastore.insertNamespace

Summary: Plan matches the method's intent: a single-row INSERT into the unpartitioned namespaces with a RETURNING projection, planned as a trivial Insert-over-Result. Index maintenance covers the primary key plus the two unique indexes (unique_namespaces_slug and the partial anchor index) whose 23505 violations Create classifies for the replay probe. No anomalies.

Seed shape: none (INSERT write target against an empty table)

Rendered SQL:

INSERT INTO public.namespaces (id, slug, platform, entity_type, entity_id, billing_entity_type, billing_entity_id)
VALUES ($1::uuid, $2::text, $3::text, $4::text, $5::text, $6::text, $7::text)
RETURNING namespaces.id AS "namespaces.id",
          namespaces.created_at AS "namespaces.created_at",
          namespaces.slug AS "namespaces.slug",
          namespaces.platform AS "namespaces.platform",
          namespaces.entity_type AS "namespaces.entity_type",
          namespaces.entity_id AS "namespaces.entity_id",
          namespaces.billing_entity_type AS "namespaces.billing_entity_type",
          namespaces.billing_entity_id AS "namespaces.billing_entity_id",
          namespaces.delivery_mode_override AS "namespaces.delivery_mode_override",
          namespaces.suspended_at AS "namespaces.suspended_at",
          namespaces.disabled_at AS "namespaces.disabled_at",
          namespaces.blocked_at AS "namespaces.blocked_at",
          namespaces.deleted_at AS "namespaces.deleted_at",
          namespaces.purged_at AS "namespaces.purged_at";

Bound args: ['7b0d2b96-3f3f-4a1e-9c1a-1a2b3c4d5e6f', 'review-prep-ns-1', 'gitlab', 'group', 'review-prep-entity-1', 'group', 'review-prep-billing-1']

Plan (EXPLAIN (ANALYZE, BUFFERS) output):

 Insert on namespaces  (cost=0.00..0.01 rows=1 width=258) (actual time=1.823..1.824 rows=1 loops=1)
   Buffers: shared hit=23 read=3 dirtied=7 written=4
   ->  Result  (cost=0.00..0.01 rows=1 width=258) (actual time=0.007..0.008 rows=1 loops=1)
 Planning:
   Buffers: shared hit=4
 Planning Time: 0.187 ms
 Execution Time: 1.896 ms

Timings: planning 0.187ms, execution 1.896ms, total 2.083ms.

Related to #261 (closed)

Edited by João Pereira

Merge request reports

Loading
Loading