Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions apps/user_status/appinfo/info.xml
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,9 @@
<background-jobs>
<job>OCA\UserStatus\BackgroundJob\ClearOldStatusesBackgroundJob</job>
</background-jobs>
<commands>
<command>OCA\UserStatus\Command\Repair</command>
</commands>
<contactsmenu>
<provider>OCA\UserStatus\ContactsMenu\StatusProvider</provider>
</contactsmenu>
Expand Down
2 changes: 2 additions & 0 deletions apps/user_status/composer/composer/autoload_classmap.php
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
'OCA\\UserStatus\\AppInfo\\Application' => $baseDir . '/../lib/AppInfo/Application.php',
'OCA\\UserStatus\\BackgroundJob\\ClearOldStatusesBackgroundJob' => $baseDir . '/../lib/BackgroundJob/ClearOldStatusesBackgroundJob.php',
'OCA\\UserStatus\\Capabilities' => $baseDir . '/../lib/Capabilities.php',
'OCA\\UserStatus\\Command\\Repair' => $baseDir . '/../lib/Command/Repair.php',
'OCA\\UserStatus\\Connector\\UserStatus' => $baseDir . '/../lib/Connector/UserStatus.php',
'OCA\\UserStatus\\Connector\\UserStatusProvider' => $baseDir . '/../lib/Connector/UserStatusProvider.php',
'OCA\\UserStatus\\ContactsMenu\\StatusProvider' => $baseDir . '/../lib/ContactsMenu/StatusProvider.php',
Expand Down Expand Up @@ -37,5 +38,6 @@
'OCA\\UserStatus\\ResponseDefinitions' => $baseDir . '/../lib/ResponseDefinitions.php',
'OCA\\UserStatus\\Service\\JSDataService' => $baseDir . '/../lib/Service/JSDataService.php',
'OCA\\UserStatus\\Service\\PredefinedStatusService' => $baseDir . '/../lib/Service/PredefinedStatusService.php',
'OCA\\UserStatus\\Service\\StatusRepairService' => $baseDir . '/../lib/Service/StatusRepairService.php',
'OCA\\UserStatus\\Service\\StatusService' => $baseDir . '/../lib/Service/StatusService.php',
);
2 changes: 2 additions & 0 deletions apps/user_status/composer/composer/autoload_static.php
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ class ComposerStaticInitUserStatus
'OCA\\UserStatus\\AppInfo\\Application' => __DIR__ . '/..' . '/../lib/AppInfo/Application.php',
'OCA\\UserStatus\\BackgroundJob\\ClearOldStatusesBackgroundJob' => __DIR__ . '/..' . '/../lib/BackgroundJob/ClearOldStatusesBackgroundJob.php',
'OCA\\UserStatus\\Capabilities' => __DIR__ . '/..' . '/../lib/Capabilities.php',
'OCA\\UserStatus\\Command\\Repair' => __DIR__ . '/..' . '/../lib/Command/Repair.php',
'OCA\\UserStatus\\Connector\\UserStatus' => __DIR__ . '/..' . '/../lib/Connector/UserStatus.php',
'OCA\\UserStatus\\Connector\\UserStatusProvider' => __DIR__ . '/..' . '/../lib/Connector/UserStatusProvider.php',
'OCA\\UserStatus\\ContactsMenu\\StatusProvider' => __DIR__ . '/..' . '/../lib/ContactsMenu/StatusProvider.php',
Expand Down Expand Up @@ -52,6 +53,7 @@ class ComposerStaticInitUserStatus
'OCA\\UserStatus\\ResponseDefinitions' => __DIR__ . '/..' . '/../lib/ResponseDefinitions.php',
'OCA\\UserStatus\\Service\\JSDataService' => __DIR__ . '/..' . '/../lib/Service/JSDataService.php',
'OCA\\UserStatus\\Service\\PredefinedStatusService' => __DIR__ . '/..' . '/../lib/Service/PredefinedStatusService.php',
'OCA\\UserStatus\\Service\\StatusRepairService' => __DIR__ . '/..' . '/../lib/Service/StatusRepairService.php',
'OCA\\UserStatus\\Service\\StatusService' => __DIR__ . '/..' . '/../lib/Service/StatusService.php',
);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
namespace OCA\UserStatus\BackgroundJob;

use OCA\UserStatus\Db\UserStatusMapper;
use OCA\UserStatus\Service\StatusRepairService;
use OCA\UserStatus\Service\StatusService;
use OCP\AppFramework\Utility\ITimeFactory;
use OCP\BackgroundJob\TimedJob;
Expand All @@ -26,10 +27,12 @@ class ClearOldStatusesBackgroundJob extends TimedJob {
*
* @param ITimeFactory $time
* @param UserStatusMapper $mapper
* @param StatusRepairService $repairService
*/
public function __construct(
ITimeFactory $time,
private UserStatusMapper $mapper,
private StatusRepairService $repairService,
) {
parent::__construct($time);

Expand All @@ -45,5 +48,6 @@ protected function run($argument) {

$this->mapper->clearOlderThanClearAt($now);
$this->mapper->clearStatusesOlderThan($now - StatusService::INVALIDATE_STATUS_THRESHOLD, $now);
$this->repairService->deleteStrandedBackups();
}
}
110 changes: 110 additions & 0 deletions apps/user_status/lib/Command/Repair.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
<?php

declare(strict_types=1);

/**
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/

namespace OCA\UserStatus\Command;

use OCA\UserStatus\Service\StatusRepairService;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;

class Repair extends Command {
Comment thread
miaulalala marked this conversation as resolved.

public function __construct(
private StatusRepairService $repairService,
) {
parent::__construct();
}

#[\Override]
protected function configure(): void {
$this
->setName('user-status:repair')
->setDescription('Repair user statuses left behind by an interrupted automated status')
->addOption('dry-run', null, InputOption::VALUE_NONE, 'Only report what would be repaired');
}

#[\Override]
public function execute(InputInterface $input, OutputInterface $output): int {
$dryRun = (bool)$input->getOption('dry-run');
if ($dryRun) {
$output->writeln('<comment>Dry run, no changes will be written.</comment>');
$output->writeln('');
}

$this->repairMissingBackupFlags($output, $dryRun);
$this->repairOrphanedStatuses($output, $dryRun);
$this->repairStrandedBackups($output, $dryRun);

return self::SUCCESS;
}

/** The flag comes from the user id, so a pre-default backup stays a backup. */
private function repairMissingBackupFlags(OutputInterface $output, bool $dryRun): void {
$ids = $this->repairService->findStatusesWithoutBackupFlagIds();
if ($ids === []) {
$output->writeln('No statuses with a missing backup flag.');
return;
}

$count = count($ids);
if ($dryRun) {
$output->writeln("Would give <info>$count</info> status(es) an explicit backup flag.");
$this->listIds($output, $ids);
return;
}

$fixed = $this->repairService->normalizeBackupFlagByIds($ids);
$output->writeln("Gave <info>$fixed</info> status(es) an explicit backup flag.");
}

private function repairOrphanedStatuses(OutputInterface $output, bool $dryRun): void {
$ids = $this->repairService->findOrphanedAutomatedStatusIds();
if ($ids === []) {
$output->writeln('No users stuck on an automated status.');
return;
}

if ($dryRun) {
$output->writeln('Would clear <info>' . count($ids) . '</info> status(es) stuck on an automated status.');
$this->listIds($output, $ids);
return;
}

$deleted = $this->repairService->deleteByIds($ids);
$output->writeln("Cleared <info>$deleted</info> status(es) stuck on an automated status.");
}

private function repairStrandedBackups(OutputInterface $output, bool $dryRun): void {
$ids = $this->repairService->findStrandedBackupIds();
if ($ids === []) {
$output->writeln('No stranded backup statuses.');
return;
}

if ($dryRun) {
$output->writeln('Would remove <info>' . count($ids) . '</info> stranded backup status(es).');
$this->listIds($output, $ids);
return;
}

$deleted = $this->repairService->deleteByIds($ids);
$output->writeln("Removed <info>$deleted</info> stranded backup status(es).");
}

/**
* @param list<int> $ids
*/
private function listIds(OutputInterface $output, array $ids): void {
if ($output->getVerbosity() >= OutputInterface::VERBOSITY_VERBOSE) {
$output->writeln(' ids: ' . implode(', ', $ids));
}
}
}
153 changes: 142 additions & 11 deletions apps/user_status/lib/Db/UserStatusMapper.php
Original file line number Diff line number Diff line change
Expand Up @@ -163,11 +163,135 @@ public function deleteCurrentStatusToRestoreBackup(string $userId, string $messa
return $qb->executeStatement() > 0;
}

public function deleteByIds(array $ids): void {
/**
* @param list<string> $automatedMessageIds
* @return int Number of deleted backup rows
*/
public function deleteStrandedBackups(array $automatedMessageIds): int {
Comment thread
miaulalala marked this conversation as resolved.
return $this->deleteByIds($this->findStrandedBackupIds($automatedMessageIds));
}

/**
* @param list<string> $automatedMessageIds
* @return list<int>
*/
public function findStrandedBackupIds(array $automatedMessageIds): array {
if ($automatedMessageIds === []) {
return [];
}

$qb = $this->db->getQueryBuilder();
$qb->delete($this->tableName)
->where($qb->expr()->in('id', $qb->createNamedParameter($ids, IQueryBuilder::PARAM_INT_ARRAY)));
$qb->executeStatement();
$qb->select('b.id')
->from($this->tableName, 'b')
->where($qb->expr()->eq('b.is_backup', $qb->createNamedParameter(true, IQueryBuilder::PARAM_BOOL)));

// A NULL is_backup counts as live: odd data keeps the backup.
$qb->leftJoin('b', $this->tableName, 'l', $qb->expr()->andX(
$qb->expr()->eq('l.user_id', $qb->func()->substring('b.user_id', $qb->createNamedParameter(2, IQueryBuilder::PARAM_INT))),
$qb->expr()->in('l.message_id', $qb->createNamedParameter($automatedMessageIds, IQueryBuilder::PARAM_STR_ARRAY)),
))
->andWhere($qb->expr()->isNull('l.id'));

return $this->fetchIds($qb);
}
Comment thread
miaulalala marked this conversation as resolved.

/**
* @param list<string> $automatedMessageIds
* @return list<int>
*/
public function findOrphanedAutomatedStatusIds(array $automatedMessageIds): array {
if ($automatedMessageIds === []) {
return [];
}

$qb = $this->db->getQueryBuilder();
$qb->select('l.id')
->from($this->tableName, 'l')
->leftJoin('l', $this->tableName, 'b', $qb->expr()->eq(
'b.user_id',
$qb->func()->concat($qb->createNamedParameter('_'), 'l.user_id'),
))
->where($qb->expr()->in('l.message_id', $qb->createNamedParameter($automatedMessageIds, IQueryBuilder::PARAM_STR_ARRAY)))
->andWhere($qb->expr()->isNull('b.id'))
->andWhere($qb->expr()->neq(
$qb->func()->substring('l.user_id', $qb->createNamedParameter(1, IQueryBuilder::PARAM_INT), $qb->createNamedParameter(1, IQueryBuilder::PARAM_INT)),
$qb->createNamedParameter('_'),
));

return $this->fetchIds($qb);
}

/**
* @return list<int>
*/
private function fetchIds(IQueryBuilder $qb): array {
$result = $qb->executeQuery();
$ids = [];
while ($row = $result->fetch()) {
$ids[] = (int)$row['id'];
}
$result->closeCursor();

return $ids;
}

/**
* @return list<int>
*/
public function findStatusesWithoutBackupFlagIds(): array {
$qb = $this->db->getQueryBuilder();
$qb->select('id')
->from($this->tableName)
->where($qb->expr()->isNull('is_backup'));

return $this->fetchIds($qb);
}

/**
* Takes is_backup from the user id prefix: false for everything would make a
* pre-default backup an unrestorable live row called "_alice".
*
* @param list<int> $ids
* @return int Number of rows given an explicit is_backup value
*/
public function normalizeBackupFlagByIds(array $ids): int {
$updated = 0;
foreach (array_chunk($ids, IQueryBuilder::MAX_IN_PARAMETERS) as $chunk) {
foreach ([true, false] as $isBackup) {
$qb = $this->db->getQueryBuilder();
$firstCharacter = $qb->func()->substring(
'user_id',
$qb->createNamedParameter(1, IQueryBuilder::PARAM_INT),
$qb->createNamedParameter(1, IQueryBuilder::PARAM_INT),
);
$underscore = $qb->createNamedParameter('_');
$qb->update($this->tableName)
->set('is_backup', $qb->createNamedParameter($isBackup, IQueryBuilder::PARAM_BOOL))
->where($qb->expr()->in('id', $qb->createNamedParameter($chunk, IQueryBuilder::PARAM_INT_ARRAY)))
->andWhere($isBackup
? $qb->expr()->eq($firstCharacter, $underscore)
: $qb->expr()->neq($firstCharacter, $underscore));
$updated += $qb->executeStatement();
}
}

return $updated;
}

/**
* @param list<int> $ids
* @return int Number of deleted rows
*/
public function deleteByIds(array $ids): int {
$deleted = 0;
foreach (array_chunk($ids, IQueryBuilder::MAX_IN_PARAMETERS) as $chunk) {
$qb = $this->db->getQueryBuilder();
$qb->delete($this->tableName)
->where($qb->expr()->in('id', $qb->createNamedParameter($chunk, IQueryBuilder::PARAM_INT_ARRAY)));
$deleted += $qb->executeStatement();
}

return $deleted;
}

/**
Expand All @@ -187,13 +311,20 @@ public function createBackupStatus(string $userId): bool {
return $qb->executeStatement() > 0;
}

public function restoreBackupStatuses(array $ids): void {
$qb = $this->db->getQueryBuilder();
$qb->update($this->tableName)
->set('is_backup', $qb->createNamedParameter(false, IQueryBuilder::PARAM_BOOL))
->set('user_id', $qb->func()->substring('user_id', $qb->createNamedParameter(2, IQueryBuilder::PARAM_INT)))
->where($qb->expr()->in('id', $qb->createNamedParameter($ids, IQueryBuilder::PARAM_INT_ARRAY)));
/**
* @param list<int> $ids
* @param int $statusTimestamp The backed up one would already be stale.
*/
public function restoreBackupStatuses(array $ids, int $statusTimestamp): void {
foreach (array_chunk($ids, IQueryBuilder::MAX_IN_PARAMETERS) as $chunk) {
$qb = $this->db->getQueryBuilder();
$qb->update($this->tableName)
->set('is_backup', $qb->createNamedParameter(false, IQueryBuilder::PARAM_BOOL))
->set('status_timestamp', $qb->createNamedParameter($statusTimestamp, IQueryBuilder::PARAM_INT))
->set('user_id', $qb->func()->substring('user_id', $qb->createNamedParameter(2, IQueryBuilder::PARAM_INT)))
->where($qb->expr()->in('id', $qb->createNamedParameter($chunk, IQueryBuilder::PARAM_INT_ARRAY)));

$qb->executeStatement();
$qb->executeStatement();
}
}
}
Loading
Loading