Skip to content

[BUG] expired_user table accumulates duplicate rows for the same expiration event (3000+ rows from 6 users) #1341

Description

@ZeroFux78

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
)

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions