Implement cleanup job for OAuth2 device code grants

Add cleanup job that removes device code grants older than 7 days.
Uses ULID cursor-based pagination for efficiency.

- Add cleanup method to OAuth2DeviceCodeGrantRepository
- Add CleanupOAuthDeviceCodeGrantsJob task
- Register handler and schedule to run hourly
This commit is contained in:
Quentin Gliech
2026-01-16 12:32:17 +01:00
parent fc07a32a8c
commit 67a0d0e92e
6 changed files with 177 additions and 2 deletions

View File

@@ -0,0 +1,24 @@
{
"db_name": "PostgreSQL",
"query": "\n WITH to_delete AS (\n SELECT oauth2_device_code_grant_id\n FROM oauth2_device_code_grant\n WHERE ($1::uuid IS NULL OR oauth2_device_code_grant_id > $1)\n AND oauth2_device_code_grant_id <= $2\n ORDER BY oauth2_device_code_grant_id\n LIMIT $3\n )\n DELETE FROM oauth2_device_code_grant\n USING to_delete\n WHERE oauth2_device_code_grant.oauth2_device_code_grant_id = to_delete.oauth2_device_code_grant_id\n RETURNING oauth2_device_code_grant.oauth2_device_code_grant_id\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "oauth2_device_code_grant_id",
"type_info": "Uuid"
}
],
"parameters": {
"Left": [
"Uuid",
"Uuid",
"Int8"
]
},
"nullable": [
false
]
},
"hash": "45d7e962d91fcdcf8284d81d04bc0737c0d20799b497089a566e2ff704d56b67"
}

View File

@@ -1,3 +1,4 @@
// Copyright 2025, 2026 Element Creations Ltd.
// Copyright 2024, 2025 New Vector Ltd.
// Copyright 2023, 2024 The Matrix.org Foundation C.I.C.
//
@@ -469,4 +470,54 @@ impl OAuth2DeviceCodeGrantRepository for PgOAuth2DeviceCodeGrantRepository<'_> {
Ok(device_code_grant)
}
#[tracing::instrument(
name = "db.oauth2_device_code_grant.cleanup",
skip_all,
fields(
db.query.text,
since = since.map(tracing::field::display),
until = %until,
limit = limit,
),
err,
)]
async fn cleanup(
&mut self,
since: Option<Ulid>,
until: Ulid,
limit: usize,
) -> Result<(usize, Option<Ulid>), Self::Error> {
// `MAX(uuid)` isn't a thing in Postgres, so we can't just re-select the
// deleted rows and do a MAX on the `oauth2_device_code_grant_id`.
// Instead, we do the aggregation on the client side, which is a little
// less efficient, but good enough.
let res = sqlx::query_scalar!(
r#"
WITH to_delete AS (
SELECT oauth2_device_code_grant_id
FROM oauth2_device_code_grant
WHERE ($1::uuid IS NULL OR oauth2_device_code_grant_id > $1)
AND oauth2_device_code_grant_id <= $2
ORDER BY oauth2_device_code_grant_id
LIMIT $3
)
DELETE FROM oauth2_device_code_grant
USING to_delete
WHERE oauth2_device_code_grant.oauth2_device_code_grant_id = to_delete.oauth2_device_code_grant_id
RETURNING oauth2_device_code_grant.oauth2_device_code_grant_id
"#,
since.map(Uuid::from),
Uuid::from(until),
i64::try_from(limit).unwrap_or(i64::MAX)
)
.traced()
.fetch_all(&mut *self.conn)
.await?;
let count = res.len();
let max_id = res.into_iter().max();
Ok((count, max_id.map(Ulid::from)))
}
}