Implement OAuth access token archival system and retention policy
What does this MR do and why?
- Adds
oauth_access_token_archived_recordstable and basic model- The table has only organization FKs and minimal constraints because it is meant to be a simple dumping ground for data
- The model has no real functionality since this model won't be used throughout the application, it will just be used in one data migration
- Records revoked and created more than 1 month ago in
oauth_access_tokenwill be copied tooauth_access_token_archived_recordsand then deleted fromoauth_access_token- In this way, we soft delete records in the archive.
- We'll drop the
oauth_access_token_archived_recordstable in a few milestones once we've confirmed the retention policy is being enforced with no issues
This will drastically reduce the number of records in the oauth_access_token and its size (after table swap with a clean table, or pg_repack). A simulation on postgres.ai using the main production DB replica shows that applying cleanup according to the retention policy would reduce table size from 109 GB to ~10 GB (indexes excluded). More details: https://gitlab.com/gitlab-org/gitlab/-/issues/521855#note_2703882221
This is the Part 1 of https://gitlab.com/gitlab-org/gitlab/-/issues/521855.
Part 2 will be handled in: !202767 (merged)
References
Main issue: https://gitlab.com/gitlab-org/gitlab/-/issues/521855
Plan: #555382
Details: https://gitlab.com/gitlab-org/gitlab/-/issues/521855#note_2703882221
Screenshots or screen recordings
| Before | After |
|---|---|
How to set up and validate locally
- Run migrations, run tests
Queries in batch
SELECT "oauth_access_tokens"."id" FROM "oauth_access_tokens" WHERE "oauth_access_tokens"."id" BETWEEN 1 AND 2080011 AND "oauth_access_tokens"."id" >= 82874 ORDER BY "oauth_access_tokens"."id" ASC LIMIT 1 OFFSET 1000
WITH deleted AS (
DELETE FROM oauth_access_tokens
WHERE id IN (SELECT "oauth_access_tokens"."id" FROM "oauth_access_tokens" WHERE "oauth_access_tokens"."id" BETWEEN 1 AND 2080011 AND "oauth_access_tokens"."id" >= 82874 AND "oauth_access_tokens"."id" < 88886 AND "oauth_access_tokens"."revoked_at" < '2025-08-10 00:00:00' LIMIT 1000)
RETURNING *
)
INSERT INTO oauth_access_token_archived_records
(id, resource_owner_id, application_id, token, refresh_token,
expires_in, revoked_at, created_at, scopes, organization_id, archived_at)
SELECT
id, resource_owner_id, application_id, token, refresh_token,
expires_in, revoked_at, created_at, scopes, organization_id,
CURRENT_TIMESTAMP as archived_at
FROM deleted
Before optimization
SELECT "oauth_access_tokens"."id" FROM "oauth_access_tokens" WHERE "oauth_access_tokens"."id" BETWEEN 1 AND 2040018 AND "oauth_access_tokens"."revoked_at" IS NOT NULL AND "oauth_access_tokens"."id" >= 2038198 ORDER BY "oauth_access_tokens"."id" ASC LIMIT 1 OFFSET 1000
SELECT 1 AS one FROM "oauth_access_tokens" WHERE "oauth_access_tokens"."id" BETWEEN 1 AND 2040018 AND "oauth_access_tokens"."revoked_at" IS NOT NULL AND "oauth_access_tokens"."id" >= 2038198 AND "oauth_access_tokens"."id" < 2039355 AND "oauth_access_tokens"."created_at" <= '2025-08-02 00:00:00' LIMIT 1
SELECT "oauth_access_tokens".* FROM "oauth_access_tokens" WHERE "oauth_access_tokens"."id" BETWEEN 1 AND 2040018 AND "oauth_access_tokens"."revoked_at" IS NOT NULL AND "oauth_access_tokens"."id" >= 2038198 AND "oauth_access_tokens"."id" < 2039355 AND "oauth_access_tokens"."created_at" <= '2025-08-02 00:00:00'
BEGIN
DELETE FROM "oauth_access_tokens" WHERE "oauth_access_tokens"."id" BETWEEN 1 AND 2040018 AND "oauth_access_tokens"."revoked_at" IS NOT NULL AND "oauth_access_tokens"."id" >= 2038198 AND "oauth_access_tokens"."id" < 2039355 AND "oauth_access_tokens"."created_at" <= '2025-08-02 00:00:00'
INSERT INTO "oauth_access_token_archived_records" ("resource_owner_id","application_id","token","refresh_token","expires_in","revoked_at","scopes","organization_id","oauth_access_token_id","oauth_access_token_created_at") VALUES (61, 23, 'a0b48d44c4069d8fa6600cf637e345aeb973c3f33fa7e957e9c8916d714bcbcb', '3aa55d42b28c5e8d86ee5f2cdc8f824ce6b77b6b4993e4408b897e5f096c5983', 3600, '2024-03-21 22:37:03', 'write', 1, 2038198, '2024-03-10 23:11:18'), ....
COMMIT
Reversibility and Recovery
- The migration to queue the batched background migration is reversible.
- The operations performed by the batched background migration is not automatically reversible since it performs DELETE operations.
- Migration can be stopped at any point with only processed batches affected
- The CTE approach ensures atomicity per batch: tokens are only deleted if successfully archived.
- Unprocessed tokens remain intact
Data recovery options are the following.
Full Recovery (from archive table)
INSERT INTO oauth_access_tokens
(resource_owner_id, application_id, token, refresh_token,
expires_in, revoked_at, created_at, scopes, organization_id)
SELECT id, resource_owner_id, application_id, token, refresh_token,
expires_in, revoked_at, created_at, scopes, organization_id
FROM oauth_access_token_archived_records
WHERE archived_at >= [migration_start_timestamp];
Selective Recovery (specific tokens)
INSERT INTO oauth_access_tokens
(resource_owner_id, application_id, token, refresh_token,
expires_in, revoked_at, created_at, scopes, organization_id)
SELECT
id, resource_owner_id, application_id, token, refresh_token,
expires_in, revoked_at, created_at, scopes, organization_id
FROM oauth_access_token_archived_records
WHERE id BETWEEN 253900000 AND 253901000
ORDER BY id;
Example of deletion and re-insertion
Deletion:
exec WITH deleted AS (
DELETE FROM oauth_access_tokens
WHERE id IN (SELECT "oauth_access_tokens"."id" FROM "oauth_access_tokens" WHERE "oauth_access_tokens"."id" BETWEEN 1 AND 260000000 AND "oauth_access_tokens"."id" >= 253900000 AND "oauth_access_tokens"."id" < 253901000 AND "oauth_access_tokens"."revoked_at" < '2025-08-10 00:00:00' LIMIT 1000)
RETURNING *
)
INSERT INTO oauth_access_token_archived_records
(id, resource_owner_id, application_id, token, refresh_token,
expires_in, revoked_at, created_at, scopes, organization_id, archived_at)
SELECT
id, resource_owner_id, application_id, token, refresh_token,
expires_in, revoked_at, created_at, scopes, organization_id,
CURRENT_TIMESTAMP as archived_at
FROM deleted ;
exec WITH deleted AS ( DELETE FROM oauth_access_tokens WHERE id IN (SELECT "oauth_access_tokens"."id" FROM "oauth_access_tokens" WHERE "oauth_access_tokens"."id" BETWEEN 1 AND 260000000 AND "oauth_access_tokens"."id" >= 253900000 AND "oauth_access_tokens"."id" < 253901000 AND "oauth_access_tokens"."revoked_at" <
[...SKIP...]
The query has been executed. Duration: 20.121 ms
Re-insertion:
exec INSERT INTO oauth_access_tokens
(resource_owner_id, application_id, token, refresh_token,
expires_in, revoked_at, created_at, scopes, organization_id)
SELECT
resource_owner_id, application_id, token, refresh_token,
expires_in, revoked_at, created_at, scopes, organization_id
FROM oauth_access_token_archived_records
WHERE id BETWEEN 253900000 AND 253901000
ORDER BY id;
exec INSERT INTO oauth_access_tokens (resource_owner_id, application_id, token, refresh_token, expires_in, revoked_at, created_at, scopes, organization_id) SELECT resource_owner_id, application_id, token, refresh_token, expires_in, revoked_at, created_at, scopes, organization_id FROM oauth_access_token_archived_records WHERE id BETWEEN 253900000 AND 253901000 ORDER BY id;
The query has been executed. Duration: 213.721 ms
Important: Since the archive table preserves original id values, records can be restored with their original IDs, maintaining referential integrity.
User Experience Impact if Error Occurs
- Minimal authentication impact: Since these tokens are already revoked, they cannot be used for authentication. No active user sessions would be affected.
- Token management operations: Users attempting to view or manage their revoked tokens might encounter errors if the UI expects these records to exist.
- Token revocation operations: If application logic attempts to re-revoke or update already-revoked tokens without proper existence checks, operations might fail with record not found errors.
To support it: code audit and tests
Migrations
Migrate up
migrate:up VERSION=20250826100517
$ bin/rails db:migrate:up:main VERSION=20250826100517
main: == [advisory_lock_connection] object_id: 153960, pg_backend_pid: 93087
main: == 20250826100517 CreateOauthAccessTokenArchivedRecords: migrating ============
main: -- create_table(:oauth_access_token_archived_records, {:id=>false})
main: -- quote_column_name(:token)
main: -> 0.0000s
main: -- quote_column_name(:refresh_token)
main: -> 0.0000s
main: -- quote_column_name(:scopes)
main: -> 0.0000s
main: -> 0.0260s
main: -- execute("ALTER TABLE oauth_access_token_archived_records ADD PRIMARY KEY (id)")
main: -> 0.0013s
main: == 20250826100517 CreateOauthAccessTokenArchivedRecords: migrated (0.0317s) ===
main: == [advisory_lock_connection] object_id: 153960, pg_backend_pid: 93087
$ bin/rails db:migrate:up:ci VERSION=20250826100517
ci: == [advisory_lock_connection] object_id: 153960, pg_backend_pid: 93279
ci: == 20250826100517 CreateOauthAccessTokenArchivedRecords: migrating ============
ci: -- create_table(:oauth_access_token_archived_records, {:id=>false})
ci: -- quote_column_name(:token)
ci: -> 0.0000s
ci: -- quote_column_name(:refresh_token)
ci: -> 0.0000s
ci: -- quote_column_name(:scopes)
ci: -> 0.0000s
ci: -> 0.0276s
ci: -- execute("ALTER TABLE oauth_access_token_archived_records ADD PRIMARY KEY (id)")
ci: -> 0.0016s
I, [2025-09-09T13:40:54.725068 #93221] INFO -- : Database: 'ci', Table: 'oauth_access_token_archived_records': Lock Writes
ci: == 20250826100517 CreateOauthAccessTokenArchivedRecords: migrated (0.0391s) ===
ci: == [advisory_lock_connection] object_id: 153960, pg_backend_pid: 93279
Migrate down
migrate:down VERSION=20250826100517
$ bin/rails db:migrate:down:main VERSION=20250826100517
main: == [advisory_lock_connection] object_id: 153960, pg_backend_pid: 93898
main: == 20250826100517 CreateOauthAccessTokenArchivedRecords: reverting ============
main: -- drop_table(:oauth_access_token_archived_records)
main: -> 0.0167s
main: == 20250826100517 CreateOauthAccessTokenArchivedRecords: reverted (0.0216s) ===
main: == [advisory_lock_connection] object_id: 153960, pg_backend_pid: 93898
$ bin/rails db:migrate:down:ci VERSION=20250826100517
ci: == [advisory_lock_connection] object_id: 153960, pg_backend_pid: 94065
ci: == 20250826100517 CreateOauthAccessTokenArchivedRecords: reverting ============
ci: -- drop_table(:oauth_access_token_archived_records)
ci: -> 0.0162s
ci: == 20250826100517 CreateOauthAccessTokenArchivedRecords: reverted (0.0242s) ===
ci: == [advisory_lock_connection] object_id: 153960, pg_backend_pid: 94065
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.
Related to #521855