Report when older db_key_base salts can be retired
What does this MR do and why?
Adds Gitlab::Encryption::DbKeyBaseRotation::TokenDigestsStatus. It reports when the old db_key_base salt can be retired for token digests. EE adds runner controller tokens.
Token digests don't tell which key salted them. Rehashing on use (!259034 (merged)) rewrites a digest found under an older salt when the token is used. So only active tokens created before the rotation and not used since may still need an older salt.
.new(rotated_at:)raisesArgumentErrorunlessrotated_atis a time in the past. It must be when the last node switched to the new key, plus the rehash lease timeout.#executereturns one result per digest token model withremaining,expired,without_expiryandlatest_expiry. It iterates each table in batches of 1,000 over the primary key, with one aggregate query per batch. The result is memoized.#retirement_datereturns today when none remain, the day after the latest expiry date when all remaining tokens expire, or nil while a remaining token never expires. Runner controller tokens have no expiry, so only use, revocation or rotation clears them.
Nothing calls it yet. The gitlab:db_key_base:status rake task (https://gitlab.com/gitlab-org/gitlab/-/work_items/634003) will, for runbook step 5. It doesn't depend on that code, but is only meaningful once the rehash ships. That task should refuse to report while the rehash isn't enabled, and warn about expired tokens.
When the report can over-count or under-count
- Over-counts (safe):
last_used_atupdates are throttled (10 minutes for personal access tokens), so a recently used token may still be counted. Tokens created or used exactly atrotated_atare counted. rotated_attoo early (under-counts): a node still on the old key creates old-salt tokens after it, and can rehash tokens back to the old key. Hence "last node switched, plus the rehash lease timeout".- Failed rehash (accepted, under-counts): a token whose rehash failed on its last use since the rotation is missed, because
last_used_atstill moves. A later use retries the rehash once its 10-minute lease expires. The remaining risk is a token used once since the rotation, with that one rehash failing. - Expired tokens: they are reported as
expiredbut don't affectretirement_date. Extending their expiry after retirement (for example withgitlab:tokens:edit) would break them. - Day after the latest expiry: a personal access token stops working at 00:00 UTC on its
expires_atdate. The extra day keeps instances ahead of UTC from retiring a few hours early.
What retiring the salt doesn't cover
- Replaying a revoked (rotated) personal access token revokes its token family. After retirement, a revoked token from before the rotation can't be found, so it gets a plain 401 instead.
db_key_basealso saltsUsers::CreditCardValidationhashes andweb_hook_logs.url_hash. This report covers token digests only.
Database
Each table is iterated with each_batch over its primary key, in batches of 1,000, because no index covers revoked, created_at and last_used_at. Plans are from Database Lab (gitlab-production-main and gitlab-production-ci), with rotated_at = 2026-10-01.
personal_access_tokens batch boundary
SELECT "personal_access_tokens"."id"
FROM "personal_access_tokens"
WHERE "personal_access_tokens"."id" >= 10000000
ORDER BY "personal_access_tokens"."id" ASC
LIMIT 1 OFFSET 1000Index Only Scan on personal_access_tokens_pkey: 620 buffers, 18 ms cold.
personal_access_tokens aggregate, per batch
SELECT COUNT(*) FILTER (WHERE expires_at IS NULL OR expires_at > NOW()),
COUNT(*) FILTER (WHERE expires_at <= NOW()),
COUNT(*) FILTER (WHERE expires_at IS NULL),
MAX(expires_at)
FROM "personal_access_tokens"
WHERE "personal_access_tokens"."id" >= 15000000
AND "personal_access_tokens"."id" < (SELECT id FROM personal_access_tokens WHERE id >= 15000000 ORDER BY id OFFSET 1000 LIMIT 1)
AND ("personal_access_tokens"."revoked" = FALSE OR "personal_access_tokens"."revoked" IS NULL)
AND "personal_access_tokens"."created_at" <= '2026-10-01 00:00:00'
AND ("personal_access_tokens"."last_used_at" IS NULL OR "personal_access_tokens"."last_used_at" <= '2026-10-01 00:00:00')
LIMIT 1each_batch passes the upper bound as a literal id. I ran it as a subquery so each batch holds exactly 1,000 rows, because ids are sparse. The timings below include that subquery, about 20 ms.
| Batch | Buffers | Time |
|---|---|---|
| 1,000 rows from id 15M, cold | 1,771 | 670 ms |
| 1,000 rows from id 5M, cold | 1,430 | 610 ms |
| Same batch, warm | 1,430 | 3.5 ms |
| 1,000 rows, cold (@terrichu: 175134, 175067) | 1,002 | 0.26-0.66 s |
Each row costs about one heap page, so buffers and time grow with the batch size. For comparison, a 10,000-row batch read about 10,000 buffers and took 5.8-8.6 s cold (175132).
Plan: 1,000 rows from id 15M, cold
Limit (cost=150609.31..150609.32 rows=1 width=28) (actual time=670.085..670.088 rows=1 loops=1)
Buffers: shared hit=834 read=937 dirtied=17
I/O Timings: read=665.742 write=0.000
InitPlan 1
-> Limit (cost=30.92..30.95 rows=1 width=4) (actual time=20.757..20.759 rows=1 loops=1)
Buffers: shared hit=764 read=28 dirtied=17
I/O Timings: read=19.869 write=0.000
-> Index Only Scan using personal_access_tokens_pkey on public.personal_access_tokens personal_access_tokens_1 (cost=0.44..283032.32 rows=9285831 width=4) (actual time=1.622..20.716 rows=1001 loops=1)
Index Cond: (personal_access_tokens_1.id >= 15000000)
Heap Fetches: 21
Buffers: shared hit=764 read=28 dirtied=17
I/O Timings: read=19.869 write=0.000
-> Aggregate (cost=150578.36..150578.37 rows=1 width=28) (actual time=670.083..670.083 rows=1 loops=1)
Buffers: shared hit=834 read=937 dirtied=17
I/O Timings: read=665.742 write=0.000
-> Index Scan using index_personal_access_tokens_on_id_and_created_at on public.personal_access_tokens (cost=0.44..149563.18 rows=50759 width=4) (actual time=23.557..669.646 rows=761 loops=1)
Index Cond: ((personal_access_tokens.id >= 15000000) AND (personal_access_tokens.id < (InitPlan 1).col1) AND (personal_access_tokens.created_at <= '2026-10-01 00:00:00'::timestamp without time zone))
Filter: (((NOT personal_access_tokens.revoked) OR (personal_access_tokens.revoked IS NULL)) AND ((personal_access_tokens.last_used_at IS NULL) OR (personal_access_tokens.last_used_at <= '2026-10-01 00:00:00+00'::timestamp with time zone)))
Rows Removed by Filter: 239
Buffers: shared hit=834 read=937 dirtied=17
I/O Timings: read=665.742 write=0.000ci_runner_controller_tokens (EE, CI database)
SELECT COUNT(*)
FROM "ci_runner_controller_tokens"
WHERE "ci_runner_controller_tokens"."id" >= 1 AND "ci_runner_controller_tokens"."id" < 1001
AND "ci_runner_controller_tokens"."status" = 0
AND "ci_runner_controller_tokens"."created_at" <= '2026-10-01 00:00:00'
AND ("ci_runner_controller_tokens"."last_used_at" IS NULL OR "ci_runner_controller_tokens"."last_used_at" <= '2026-10-01 00:00:00')The table is empty on GitLab.com: 0.08 ms for the each_batch start (175130) and 0.07 ms for the count (175131).
Expected total runtime on GitLab.com
personal_access_tokens has about 16.7M rows in 56 GB (from @terrichu's review), so a full run is about 16,700 batches and reads the whole heap.
- All cold, at about 0.65 s per batch: about 3 hours.
- All warm, at about 4 ms per batch: about a minute.
A real run falls between the two. Where it runs on GitLab.com (primary or replica) is still open in the review. GitLab.com is out of scope for the current spike.
References
- Task: https://gitlab.com/gitlab-org/gitlab/-/work_items/631693
- Phase 4: https://gitlab.com/gitlab-org/gitlab/-/work_items/631458
- Phase 4 rake task: https://gitlab.com/gitlab-org/gitlab/-/work_items/634003
- Phase 2 MR: !258947 (merged)
- Epic: https://gitlab.com/groups/gitlab-org/-/work_items/23829
- Rehash token digests on use: https://gitlab.com/gitlab-org/gitlab/-/work_items/631700
Screenshots or screen recordings
Not applicable, backend only.
How to set up and validate locally
-
Run the specs:
bin/rspec spec/lib/gitlab/encryption/db_key_base_rotation ee/spec/lib/ee/gitlab/encryption/db_key_base_rotation
MR acceptance checklist
Evaluate this MR against the MR acceptance checklist. It helps you analyze changes to reduce risks in quality, performance, reliability, security, and maintainability.