Using Wizarr v2026.7.0
Describe the bug
The expired_user table accumulates duplicate rows for the exact same expiration event (identical original_user_id, username, and expired_at timestamp), rather than logging each expiration once. Over time this causes the "Recently Expired Users" list in the Users admin page to balloon into the thousands, become unresponsive/laggy, and there is no reliable way to clear it from the UI — the "Clean Expired Users" cleanup action (added in v2025.12.0, closing #1047) does not remove these entries.
In our case, 3,156 total rows in expired_user collapsed down to just 6 distinct username/expired_at combinations once deduplicated — meaning the same expiration event was logged as many as 1,550 separate times for a single user.
Screenshots
Screenshots not retained, but see Logs section below for the exact row counts / duplication pattern instead.
Logs
Query against expired_user table, grouped by username + expired_at:
('user1', '2026-05-13 04:15:06.305470', 1550)
('user2', '2026-03-24 18:48:34.513371', 1051)
('user5', '2026-04-08 02:36:34.805862', 390)
('user3', '2026-03-30 05:56:25.716361', 88)
('user3', '2026-03-23 05:56:25.716361', 57)
('user4', '2026-03-14 20:59:00.000000', 9)
('user4', '2026-03-14 18:59:00.000000', 6)
('user6', '2026-03-14 17:59:00.000000', 4)
('user6', '2026-04-07 03:42:56.071110', 1)
Total rows in expired_user: 3156
Distinct usernames: 6
expired_user schema for reference:
(0, 'id', 'INTEGER', 1, None, 1)
(1, 'original_user_id', 'INTEGER', 1, None, 0)
(2, 'username', 'VARCHAR', 1, None, 0)
(3, 'email', 'VARCHAR', 0, None, 0)
(4, 'invitation_code', 'VARCHAR', 0, None, 0)
(5, 'server_id', 'INTEGER', 0, None, 0)
(6, 'expired_at', 'DATETIME', 1, None, 0)
(7, 'deleted_at', 'DATETIME', 1, None, 0)
Additional context
This appears consistent with the recurring expiry-check job (runs every ~15 min per the 2025.5.d changelog: "check_expiring job every 15 min removes expired invites/users") re-inserting a new expired_user row on every run for any user still in an expired state, rather than checking whether a row for that original_user_id + expired_at already exists before inserting. The row counts roughly track with how long each user has sat in an expired state divided by the 15-minute interval (e.g. ~1,550 rows over ~16 days for one user).
Related to #1047, but distinct: that issue was about there being no way to clear the list at all (addressed by the cleanup route in v2025.12.0). This issue is that the underlying table keeps refilling with duplicates of the same event even after cleanup, because the root insert logic isn't idempotent. Deleting all rows (or even using the built-in cleanup action) is only a temporary fix — the count starts climbing again immediately for any username still sitting in an expired state.
Suggested fix: before inserting into expired_user, check for an existing row matching original_user_id + expired_at (or some other reasonable idempotency key) and skip the insert if one already exists — the same pattern used elsewhere for idempotent extend/webhook handling.
As a workaround, we're running a periodic dedup query to collapse rows down to one per (original_user_id, username, expired_at) group:
DELETE FROM expired_user
WHERE id NOT IN (
SELECT MIN(id)
FROM expired_user
GROUP BY original_user_id, username, expired_at
)
Using Wizarr v2026.7.0
Describe the bug
The
expired_usertable accumulates duplicate rows for the exact same expiration event (identicaloriginal_user_id,username, andexpired_attimestamp), rather than logging each expiration once. Over time this causes the "Recently Expired Users" list in the Users admin page to balloon into the thousands, become unresponsive/laggy, and there is no reliable way to clear it from the UI — the "Clean Expired Users" cleanup action (added in v2025.12.0, closing #1047) does not remove these entries.In our case, 3,156 total rows in
expired_usercollapsed down to just 6 distinct username/expired_at combinations once deduplicated — meaning the same expiration event was logged as many as 1,550 separate times for a single user.Screenshots
Screenshots not retained, but see Logs section below for the exact row counts / duplication pattern instead.
Logs
Query against expired_user table, grouped by username + expired_at:
('user1', '2026-05-13 04:15:06.305470', 1550)
('user2', '2026-03-24 18:48:34.513371', 1051)
('user5', '2026-04-08 02:36:34.805862', 390)
('user3', '2026-03-30 05:56:25.716361', 88)
('user3', '2026-03-23 05:56:25.716361', 57)
('user4', '2026-03-14 20:59:00.000000', 9)
('user4', '2026-03-14 18:59:00.000000', 6)
('user6', '2026-03-14 17:59:00.000000', 4)
('user6', '2026-04-07 03:42:56.071110', 1)
Total rows in expired_user: 3156
Distinct usernames: 6
expired_userschema for reference:(0, 'id', 'INTEGER', 1, None, 1)
(1, 'original_user_id', 'INTEGER', 1, None, 0)
(2, 'username', 'VARCHAR', 1, None, 0)
(3, 'email', 'VARCHAR', 0, None, 0)
(4, 'invitation_code', 'VARCHAR', 0, None, 0)
(5, 'server_id', 'INTEGER', 0, None, 0)
(6, 'expired_at', 'DATETIME', 1, None, 0)
(7, 'deleted_at', 'DATETIME', 1, None, 0)
Additional context
This appears consistent with the recurring expiry-check job (runs every ~15 min per the 2025.5.d changelog: "check_expiring job every 15 min removes expired invites/users") re-inserting a new
expired_userrow on every run for any user still in an expired state, rather than checking whether a row for thatoriginal_user_id+expired_atalready exists before inserting. The row counts roughly track with how long each user has sat in an expired state divided by the 15-minute interval (e.g. ~1,550 rows over ~16 days for one user).Related to #1047, but distinct: that issue was about there being no way to clear the list at all (addressed by the cleanup route in v2025.12.0). This issue is that the underlying table keeps refilling with duplicates of the same event even after cleanup, because the root insert logic isn't idempotent. Deleting all rows (or even using the built-in cleanup action) is only a temporary fix — the count starts climbing again immediately for any username still sitting in an expired state.
Suggested fix: before inserting into
expired_user, check for an existing row matchingoriginal_user_id+expired_at(or some other reasonable idempotency key) and skip the insert if one already exists — the same pattern used elsewhere for idempotent extend/webhook handling.As a workaround, we're running a periodic dedup query to collapse rows down to one per (original_user_id, username, expired_at) group: