Skip to content

fix(user_status): fix the status lifecycle - #63151

Open
miaulalala wants to merge 4 commits into
masterfrom
fix/noid/user-status-orphan-lifecycle
Open

fix(user_status): fix the status lifecycle#63151
miaulalala wants to merge 4 commits into
masterfrom
fix/noid/user-status-orphan-lifecycle

Conversation

@miaulalala

Copy link
Copy Markdown
Contributor

Fixes #63150

Summary

Commit Addresses
ff07d7717f48713f7ccfd2119d4a66c1cd53720a fix(user_status): clear unreachable automated status when there is no backup - revertUserStatus() deletes the live row instead of bailing out, when its message id still matches the automation being reverted. A status the user has since changed themselves is left untouched.
b6bd023365446ea2abd2088c55f93dd6a2e5fa4d fix(user_status): refresh the status timestamp when restoring a backup - covers item 4.
34107e90ee5e868b7088974b02a951b754ccb247 fix(user_status): delete stranded backup statuses in the cleanup job - state-based, not age-based (an out-of-office backup can legitimately be weeks old). Three simple statements rather than a self-referencing DELETE, for cross-database portability. A live row with is_backup NULL is treated as reachable, so unexpected data errs towards keeping the backup.
5e610de57a9a29c12ea24b5875b34923763aa730 feat(user_status): add occ user-status:repair for statuses left behind - repairs the three existing shapes (NULL is_backup, live automated row with no backup, unmatchable backup row). Supports --dry-run. Orphaned rows are deleted rather than rewritten, matching what revertUserStatus() now does; the next heartbeat recreates a normal status.

Checklist

AI (if applicable)

  • The content of this PR was partly or fully generated using AI

@miaulalala miaulalala added this to the Nextcloud 35 milestone Aug 11, 2026
@miaulalala miaulalala self-assigned this Aug 11, 2026
@miaulalala
miaulalala requested review from a team, Antreesy and nickvergessen as code owners August 11, 2026 08:39
@miaulalala miaulalala added bug 2. developing Work in progress labels Aug 11, 2026
@miaulalala
miaulalala requested review from Altahrim, icewind1991 and salmart-dev and removed request for a team August 11, 2026 08:39
@miaulalala
miaulalala requested a review from come-nc August 11, 2026 08:39
@miaulalala miaulalala changed the title fix(user_status): fix the status lifecylce fix(user_status): fix the status lifecycle Aug 11, 2026
@miaulalala

Copy link
Copy Markdown
Contributor Author

/backport to stable34

@miaulalala

Copy link
Copy Markdown
Contributor Author

/backport to stable33

@miaulalala

Copy link
Copy Markdown
Contributor Author

/backport to stable32

@miaulalala miaulalala added 3. to review Waiting for reviews and removed 2. developing Work in progress labels Aug 11, 2026

@come-nc come-nc left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The commit descriptions are really verbose, that’s not helping much.

Comment thread apps/user_status/lib/Db/UserStatusMapper.php Outdated
Comment thread apps/user_status/lib/Command/Repair.php
Comment on lines +201 to +232
public function findStrandedBackupIds(array $automatedMessageIds): array {
$qb = $this->db->getQueryBuilder();
$qb->select('id', 'user_id')
->from($this->tableName)
->where($qb->expr()->eq('is_backup', $qb->createNamedParameter(true, IQueryBuilder::PARAM_BOOL)));

$result = $qb->executeQuery();
/** @var array<string, int> $backups live user id => backup row id */
$backups = [];
while ($row = $result->fetch()) {
// Strip the underscore prefix that was added when creating the backup
$backups[substr((string)$row['user_id'], 1)] = (int)$row['id'];
}
$result->closeCursor();

if ($backups === []) {
return [];
}

$reachable = $automatedMessageIds === []
? []
: $this->findUsersOnAutomatedStatus(array_keys($backups), $automatedMessageIds);

$stranded = [];
foreach ($backups as $userId => $id) {
if (!isset($reachable[$userId])) {
$stranded[] = $id;
}
}

return $stranded;
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All in all this reads really convoluted to me, I tried to understand what it does but I’m still a bit lost.
It gets statuses where is_backup is true, and in this case user_id also start with a an underscore, which we remove and then search for statuses matching those user ids and the id given as parameter?
It feels expensive to compute a list of all backed up users only to try a few ids.

Is it not feasible in one request with a join? Or would that be a bad idea for some reason?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed, changed to self join. Added some tests to make sure it also works on Oracle, let's see what CI says.

… backup

revertUserStatus() bailed out as soon as no backup row was found, leaving
the automated status on the live row. Nothing else ever removes it, so the
user is stuck: setting themselves online manually is cleaned to offline 15
minutes later, and UserLiveStatusListener returns early for
MESSAGE_CALENDAR_BUSY so no heartbeat can undo it.

Delete the live row instead when its message id still matches the
automation being reverted. A status the user has since changed themselves
no longer matches and is left untouched.

AI-Assisted-By: Claude Opus 5
Signed-off-by: Anna Larch <anna@nextcloud.com>
A backup keeps the status_timestamp it had when the automation took over,
so any automated status lasting longer than INVALIDATE_STATUS_THRESHOLD is
restored already stale and rewritten to offline by the very next read.

Stamp the restored status with the time of the revert, as the manual revert
path already did. A user who really went away now stays online for up to
INVALIDATE_STATUS_THRESHOLD instead, which is the better failure mode.

AI-Assisted-By: Claude Opus 5
Signed-off-by: Anna Larch <anna@nextcloud.com>
A backup can only be restored by revertUserStatus(), which matches on the
live row still carrying the automated message id. Once that no longer holds
the backup is unreachable, and since 33.0.7 excluded backups from
clearOlderThanClearAt() nothing removes it any more. createBackupStatus()
then keeps hitting the unique constraint on user_id, so setUserStatus()
silently aborts every later automated status change for that user.

Delete unreachable backups from the existing cleanup job. The check is
state based rather than age based on purpose: an out-of-office backup can
legitimately be weeks old.

AI-Assisted-By: Claude Opus 5
Signed-off-by: Anna Larch <anna@nextcloud.com>
The preceding fixes stop new damage, but nothing repairs what is already in
the database: reverts for call, availability and out-of-office are driven by
automations that never fire again for a user who is already stuck.

Add a command that repairs the three shapes, with --dry-run to see the scope
first:

- statuses whose is_backup is NULL, which every query comparing the column
  against false skips
- live rows on an automated status with no backup to revert into
- backup rows that can no longer be matched

Orphaned rows are deleted rather than rewritten, matching what
revertUserStatus() now does, and the next heartbeat recreates a normal
status.

AI-Assisted-By: Claude Opus 5
Signed-off-by: Anna Larch <anna@nextcloud.com>
@miaulalala
miaulalala force-pushed the fix/noid/user-status-orphan-lifecycle branch from 5e610de to 8204069 Compare August 11, 2026 15:03
@miaulalala
miaulalala requested a review from come-nc August 11, 2026 15:15
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Bug]: user status lifecycle leaves undeletable orphans

2 participants