/. * * user_migration is an optional dependency (unlike files_external): without it, only this * per-user backup/restore is unavailable, the full backup still works. */ final class UserBackupService { public function __construct( private TargetService $targets, private LeaseService $leases, private UserMigrationServiceLocator $locator, ) { } /** @throws UserMigrationException when user_migration is not enabled, or the export itself fails */ public function backupUser(Target $target, IUser $user): string { $service = $this->locator->get(); return $this->withSharedLock($target, function () use ($target, $user, $service) { $repo = $this->targets->repository($target); $dest = new RepositoryExportDestination($repo, $user->getUID()); $service->export($dest, $user); // A short exclusive lock around the catalog write only: the shared lock above // allows another concurrent backup (full or per-user) to be writing the catalog at // the same time. The repository is reopened inside the lock, so the write builds on // the latest catalog generation instead of the one loaded before the lock was taken. $this->withCatalogLock($target, fn () => $this->targets->repository($target)->recordUserExport($dest->manifestPath())); return $dest->manifestPath(); }); } private function withSharedLock(Target $target, callable $fn): mixed { try { // UserMigrationService::export() exposes no progress, so unlike // RestoreService::restoreUserFiles() this lease cannot be refreshed mid-call; a very // large account could in theory outrun this TTL. Resumable, step-based per-user // backup (like the main BackupService run) is a possible future improvement. return $this->leases->with(PruneService::lockKey($target), LeaseService::SHARED, 3600, $fn); } catch (LockedException) { throw new \RuntimeException('A cleanup of ' . $target->getName() . ' is in progress; try again later'); } } private function withCatalogLock(Target $target, callable $fn): mixed { try { return $this->leases->with(PruneService::catalogLockKey($target), LeaseService::EXCLUSIVE, 30, $fn); } catch (LockedException) { throw new \RuntimeException('Another catalog write for ' . $target->getName() . ' is in progress; try again'); } } }