[Bug]: user status lifecycle leaves undeletable orphans
@miaulalala is already working on this.
Since Aug 11, 2026.
- Dominant language
- PHP
- Stars
- 36.9k
- Forks
- 5.2k
- Avg merge
- 2d 3h
- Merged PRs (30d)
- 713
Description
⚠️ This issue respects the following points: ⚠️
- This is a bug, not a question or a configuration/webserver/proxy issue.
- This issue is not already reported on Github OR Nextcloud Community Forum (I've searched it).
- Nextcloud Server is up to date. See Maintenance and Release Schedule for supported versions.
- I agree to follow Nextcloud's Code of Conduct.
Bug description
Summary
On 33.0.7, users randomly go Offline while active and never come back Online.
PR #61619 (stable33 backport of #59535) is the only user_status change between 33.0.6 and 33.0.7. That PR stops new corruption from one specific path, but it repairs none of the existing damage, and one of its two changes introduces a new permanent-failure mode of its own.
All of this was investigated on real containers. Time was simulated by ageing status_timestamp backwards.
The inspection query used throughout:
SELECT user_id, status, message_id, is_backup, clear_at, status_timestamp
FROM oc_user_status WHERE user_id IN ('alice', '_alice');
Constants: automated status message ids are meeting / call / availability / out-of-office; INVALIDATE_STATUS_THRESHOLD is 15 minutes; the client heartbeat fires every 5 minutes.
Revert of #61619 🛑
- Reverting only buys self-healing for stranded backups that carry a
clear_at. Theclear_at IS NULLcase — a plain online status, the common one — is immortal on 33.0.6 too, becauseclearOlderThanClearAtrequiresclear_at IS NOT NULL. - Brings back the following issues:
- backups get flattened to offline after 15 minutes, so every meeting longer than 15 minutes ends with the user shown as offline
- fresh orphans appear whenever a healthy backup's
clear_atexpires mid-meeting - backup rows leak into
findAll()as phantom_userentries.
- Scenario B, the main orphan generator, is byte-identical either way.
Not covered by the PR, and worth tracking as follow-ups:
- Making
is_backupNOT NULL — E is worked around by the repair command, not actually fixed. - A bounded backup lifetime to replace the
clear_at-based cleanup that #61619 removed. That removal assumed orphans were self-limiting; scenario B disproves that assumption, and this is probably the single most useful thing to take away from this issue.
Note: the calendar-busy early-return in UserLiveStatusListener should not be treated as safe. It's what turns a recoverable state into a permanent one.
Steps to reproduce
Reproduction
A. New in 33.0.7: a stranded backup silently kills all future automated statuses
Reproduces on a clean 33.0.7, no historical corruption needed.
alicehas a normal status. Create a calendar event that marks her busy and let the calendar automation run.- Expected DB state: a live row for
alicewithmessage_id = 'meeting', plus a backup row_alicewithis_backup = 1.
- Expected DB state: a live row for
- While the meeting is still running, have
aliceclick "Clear status message" (setting a custom message or picking a predefined status does the same thing).- The live row's
message_idbecomes NULL.
- The live row's
- Let the meeting end.
revertUserStatus()matches on the live row still carrying the automated message id. It no longer matches, so the backup is neither restored nor deleted.- Observed:
_alicestays in the table indefinitely.
- Trigger any further automated status — another meeting, a Talk call, out-of-office.
backupCurrentStatus()hits the unique constraint onuser_idbecause_alicealready exists, returns false, andsetUserStatus()silently aborts, returning null.- Observed: no automated status is ever applied to alice again. No error surfaces.
On 33.0.6 this self-healed, but only when the backup carried a clear_at: the unfiltered clearOlderThanClearAt eventually deleted the row. #61619 added is_backup = false to that query, and nothing else ever deletes a _userId row.
B. Permanent stuck-offline for a user with no prior status row
Predates #61619 and affects all versions. This is the main generator of the reported symptom.
- Take a user who has never set a status — no
oc_user_statusrow at all. - Create a calendar event marking them busy.
createBackupStatus()runsUPDATE ... SET is_backup = true, user_id = '_' || uid WHERE user_id = uid. With no row to update, it affects 0 rows and returns false.backupCurrentStatus()discards that return value and returns true as long as no exception was thrown.- Observed: a live automated-status row gets inserted, but no backup row is created.
- Let the meeting end.
revertUserStatus()finds no backup and returns early, leaving the automated status on the live row. Nothing else removes it:clearStatusesOlderThan()skips user-defined statuses, and calendar statuses carry noclear_at.
- Have the user set themselves Online manually in the UI.
- Wait 15 minutes (or age
status_timestampback by 15 minutes).processStatus()/cleanStatus()flips them to Offline — ONLINE is not protected fromcleanStatus.
- Leave the client running so heartbeats keep firing every 5 minutes.
UserLiveStatusListenerearly-returns onmessage_id === MESSAGE_CALENDAR_BUSY, so no heartbeat can restore Online.- Observed: permanently stuck Offline. The only escape found was "Clear status message".
C. Pre-existing damage carried across the upgrade
Same end state as B, reached differently, and the reason 33.0.7 alone doesn't fix already-affected instances.
- On 33.0.6, a user is on an automated status with a valid backup row whose
clear_atexpires while the meeting is still running. - 33.0.6's unfiltered
clearOlderThanClearAtdeletes the backup row, orphaning the live row. - Upgrade to 33.0.7.
- Observed: the user is now in state B (step 3 onwards). 33.0.7 ships no migration and no repair command, so they stay stuck.
D. Meeting longer than 15 minutes restores a stale status
- Put a user on an automated status lasting longer than 15 minutes (a 90-minute meeting).
- Let it end normally, with a healthy backup present.
revertUserStatus()restores the backup without refreshingstatus_timestamp, unlessrevertedManuallyis set.- Observed: the restored status is already older than
INVALIDATE_STATUS_THRESHOLD, so the very next read runscleanStatus()and rewrites the user to Offline.
- Wait for the next heartbeat (up to 5 minutes).
- Observed: it recovers. So this is a visibility window, not a permanent stick.
E. Latent, upgraded-instances-only — NULL is_backup
Not reproducible on a fresh install; noting this as background rather than a repro.
is_backupistinyint(1) DEFAULT 0but nullable.is_backup = falsedoesn't match NULL, so a NULL row is invisible tofindAll()/findAllRecent()— other users see that person as offline — and it's skipped by both cleanup queries.
Expected behavior
User status are consistent and revert properly, don't leave orphaned rows
Contributor guide
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
Assessment
This issue has not been assessed yet.