setInterval(self::JOB_INTERVAL_SECONDS); $this->setTimeSensitivity(IJob::TIME_INSENSITIVE); } protected function run(mixed $argument): void { $this->logger->info('RefreshUserTokens: Starting background token refresh'); $refreshed = 0; $failed = 0; $skipped = 0; $offset = 0; $totalUsers = 0; // Process users in batches to prevent memory issues on large installations do { $userIds = $this->tokenStorage->getAllUsersWithTokens(self::BATCH_SIZE, $offset); $batchCount = count($userIds); $totalUsers += $batchCount; foreach ($userIds as $userId) { $result = $this->refreshUserTokenIfNeeded($userId); match ($result) { 'refreshed' => $refreshed++, 'failed' => $failed++, 'skipped' => $skipped++, }; } $offset += self::BATCH_SIZE; } while ($batchCount === self::BATCH_SIZE); $this->logger->info("RefreshUserTokens: Complete - total=$totalUsers, refreshed=$refreshed, failed=$failed, skipped=$skipped"); } /** * Refresh a user's token if it's nearing expiration. * * Calculates the refresh threshold based on the token's actual lifetime, * refreshing when less than 50% of the lifetime remains. * * Uses locking to prevent race conditions with on-demand refresh in * getAccessToken(). If lock cannot be acquired, skips this user since * on-demand refresh is already handling it. * * @return string 'refreshed', 'failed', or 'skipped' */ private function refreshUserTokenIfNeeded(string $userId): string { $token = $this->tokenStorage->getUserToken($userId); if ($token === null) { return 'skipped'; } $expiresAt = (int)($token['expires_at'] ?? 0); $issuedAt = isset($token['issued_at']) ? (int)$token['issued_at'] : null; $timeRemaining = $expiresAt - time(); // Calculate token lifetime from stored data or use default if ($issuedAt !== null) { $tokenLifetime = $expiresAt - $issuedAt; } else { // Fallback: use default lifetime assumption $tokenLifetime = self::DEFAULT_TOKEN_LIFETIME_SECONDS; } // Calculate threshold: refresh when 50% of lifetime remains $threshold = max( (int)($tokenLifetime * self::REFRESH_AT_REMAINING_PERCENT), self::MIN_THRESHOLD_SECONDS ); if ($timeRemaining > $threshold) { // Token still has plenty of time, skip return 'skipped'; } // Token is expiring soon, attempt refresh with lock try { return $this->tokenStorage->withTokenLock($userId, function () use ($userId) { // Re-check token after acquiring lock (double-check pattern) // Another process may have refreshed while we waited for lock $currentToken = $this->tokenStorage->getUserToken($userId); if ($currentToken === null) { return 'skipped'; } // Recalculate threshold with current token data $currentExpiresAt = (int)($currentToken['expires_at'] ?? 0); $currentIssuedAt = isset($currentToken['issued_at']) ? (int)$currentToken['issued_at'] : null; $currentTimeRemaining = $currentExpiresAt - time(); if ($currentIssuedAt !== null) { $currentTokenLifetime = $currentExpiresAt - $currentIssuedAt; } else { $currentTokenLifetime = self::DEFAULT_TOKEN_LIFETIME_SECONDS; } $currentThreshold = max( (int)($currentTokenLifetime * self::REFRESH_AT_REMAINING_PERCENT), self::MIN_THRESHOLD_SECONDS ); if ($currentTimeRemaining > $currentThreshold) { // Token was refreshed by another process while we waited $this->logger->debug("RefreshUserTokens: Token already refreshed for user $userId while waiting for lock"); return 'skipped'; } // Still needs refresh, proceed if (!isset($currentToken['refresh_token'])) { $this->logger->warning("RefreshUserTokens: User $userId has no refresh token"); return 'failed'; } $this->logger->debug("RefreshUserTokens: Refreshing token for user $userId (remaining={$currentTimeRemaining}s, threshold={$currentThreshold}s)"); /** @var string $refreshToken */ $refreshToken = $currentToken['refresh_token']; $newTokenData = $this->tokenRefresher->refreshAccessToken($refreshToken); if ($newTokenData === null) { $this->logger->warning("RefreshUserTokens: Refresh returned null for user $userId"); // Don't delete token here - let on-demand refresh handle cleanup return 'failed'; } // Calculate new expiration and store issued_at for future calculations $expiresIn = (int)($newTokenData['expires_in'] ?? self::DEFAULT_TOKEN_LIFETIME_SECONDS); $now = time(); /** @var string $accessToken */ $accessToken = $newTokenData['access_token']; /** @var string $newRefreshToken */ $newRefreshToken = $newTokenData['refresh_token'] ?? $refreshToken; $this->tokenStorage->storeUserToken( $userId, $accessToken, $newRefreshToken, $now + $expiresIn, $now // issued_at ); $this->logger->debug("RefreshUserTokens: Successfully refreshed token for user $userId"); return 'refreshed'; }); } catch (LockedException $e) { // Lock held by on-demand refresh - expected, not an error $this->logger->debug("RefreshUserTokens: Lock held for user $userId, skipping"); return 'skipped'; } catch (\Exception $e) { $this->logger->error("RefreshUserTokens: Failed to refresh for user $userId: " . $e->getMessage()); return 'failed'; } } }