Files
nc-talk-ai/lib/Jobs/ProcessQueuedRequestsJob.php
T
Pascal Kienast 0739d3da6a Initial open-source release of Talk AI
Talk AI is a multi-bot AI assistant manager for Nextcloud Talk:
per-bot prompts and models, agentic tool calling (MCP + built-in
tools), RAG over Nextcloud files, room-document search, vision and
speech-to-text attachments, persistent bot wikis, approval workflows,
rate limiting, and multi-provider LLM support (any OpenAI-compatible
endpoint).

Developed within EDUC - the European Digital UniverCity
(https://educalliance.eu), where it runs as the 'EDUC AI' assistant on
the alliance-wide Nextcloud portal. This public repository is the
upstream point of truth; deployment-specific tools plug in via the
tool-provider extension point (docs/TOOL_PROVIDERS.md).

License: AGPL-3.0-or-later.
2026-07-08 21:13:13 +02:00

253 lines
8.7 KiB
PHP

<?php
declare(strict_types=1);
namespace OCA\EducAI\Jobs;
use OCA\EducAI\Db\BotMapper;
use OCA\EducAI\Db\QueuedRequest;
use OCA\EducAI\Service\BotService;
use OCA\EducAI\Service\RateLimitService;
use OCA\EducAI\Webhook\TalkHandler;
use OCP\AppFramework\Db\DoesNotExistException;
use OCP\AppFramework\Utility\ITimeFactory;
use OCP\BackgroundJob\TimedJob;
use Psr\Log\LoggerInterface;
/**
* Background job that processes queued LLM requests.
*
* When rate limits are hit, requests are queued instead of failing.
* This job runs every minute to process pending requests as
* rate limit capacity becomes available.
*/
class ProcessQueuedRequestsJob extends TimedJob {
private const GENERIC_FAILURE_NOTIFICATION = 'Your request could not be processed after multiple attempts. Please try again later.';
/**
* Maximum requests to process per job run
*/
private const MAX_REQUESTS_PER_RUN = 10;
/**
* Maximum age of a queued request before it's considered stale (1 hour)
*/
private const MAX_REQUEST_AGE_SECONDS = 3600;
/**
* Maximum retry attempts for failed requests
*/
private const MAX_RETRY_ATTEMPTS = 3;
private RateLimitService $rateLimitService;
private BotService $botService;
private BotMapper $botMapper;
private TalkHandler $talkHandler;
private LoggerInterface $logger;
public function __construct(
ITimeFactory $time,
RateLimitService $rateLimitService,
BotService $botService,
BotMapper $botMapper,
TalkHandler $talkHandler,
LoggerInterface $logger
) {
parent::__construct($time);
$this->rateLimitService = $rateLimitService;
$this->botService = $botService;
$this->botMapper = $botMapper;
$this->talkHandler = $talkHandler;
$this->logger = $logger;
// Run every minute (60 seconds)
$this->setInterval(60);
// Allow some flexibility in timing
$this->setTimeSensitivity(self::TIME_SENSITIVE);
}
/**
* @param array<string,mixed> $arguments
*/
protected function run($arguments): void {
// Check if rate limiting is enabled
if (!$this->rateLimitService->isEnabled()) {
$this->logger->debug('EducAI: Rate limiting disabled, skipping queue processing');
return;
}
// Get queue stats
$stats = $this->rateLimitService->getQueueStats();
if ($stats['pending'] === 0 && $stats['processing'] === 0) {
$this->logger->debug('EducAI: No queued requests to process');
return;
}
$this->logger->info('EducAI: Starting queue processing job', [
'pending' => $stats['pending'],
'processing' => $stats['processing'],
]);
// First, handle stale processing requests (reset them)
$this->rateLimitService->cleanup(self::MAX_REQUEST_AGE_SECONDS);
// Process pending requests
$processedCount = 0;
$maxToProcess = min(self::MAX_REQUESTS_PER_RUN, $stats['pending']);
for ($i = 0; $i < $maxToProcess; $i++) {
// Check if we have rate limit capacity
if (!$this->rateLimitService->canProcess()) {
$waitSeconds = $this->rateLimitService->getSecondsUntilAvailable();
$this->logger->info('EducAI: Rate limit reached, stopping queue processing', [
'processed' => $processedCount,
'wait_seconds' => $waitSeconds,
]);
break;
}
// Get next pending request
$request = $this->rateLimitService->getNextPending();
if ($request === null) {
break;
}
// Skip stale requests
if ($request->isStale(self::MAX_REQUEST_AGE_SECONDS)) {
$this->rateLimitService->markFailed($request, 'Request expired (too old)');
$this->sendFailureNotification($request, 'Your request has expired. Please try again.');
continue;
}
// Process the request
$this->processQueuedRequest($request);
$processedCount++;
// Small delay between requests to respect rate limits
usleep(100000); // 100ms
}
$this->logger->info('EducAI: Queue processing completed', [
'processed_count' => $processedCount,
'remaining_pending' => $stats['pending'] - $processedCount,
]);
}
/**
* Process a single queued request
*/
private function processQueuedRequest(QueuedRequest $request): void {
$requestId = $request->getId();
$this->logger->info('EducAI: Processing queued request', [
'request_id' => $requestId,
'bot_id' => $request->getBotId(),
'attempts' => $request->getAttempts(),
]);
// Mark as processing
$this->rateLimitService->markProcessing($request);
// Record rate limit usage
$this->rateLimitService->recordUsage();
try {
// Get the bot
$bot = $this->botMapper->findById($request->getBotId());
if (!$bot->getIsActive()) {
throw new \Exception('Bot is no longer active');
}
// Process the message (with isFromQueue=true to skip rate limit check)
$response = $this->botService->processMessage(
$bot,
$request->getMessage(),
$request->getRoomToken(),
$request->getUserId(),
$request->getOriginalMessage(),
null, // No streaming for queued requests
true, // isFromQueue = true
null,
null,
$request->getThreadRootMessageId(),
$request->getReplyToMessageId()
);
// Mark as completed
$this->rateLimitService->markCompleted($request, $response);
// Send the response to Talk
$this->talkHandler->sendReplyToTalk(
$request->getRoomToken(),
$response,
$request->getReplyToMessageId() ?? 0
);
$this->logger->info('EducAI: Successfully processed queued request', [
'request_id' => $requestId,
'response_length' => strlen($response),
]);
} catch (DoesNotExistException $e) {
$error = 'Bot no longer exists';
$this->rateLimitService->markFailed($request, $error);
$this->sendFailureNotification($request, 'The bot is no longer available.');
$this->logger->warning('EducAI: Queued request failed - bot not found', [
'request_id' => $requestId,
'bot_id' => $request->getBotId(),
]);
} catch (\Exception $e) {
$error = $e->getMessage();
// Check if we should retry (note: attempts already incremented in markProcessing)
if ($request->getAttempts() < self::MAX_RETRY_ATTEMPTS) {
// Reset to pending so it will be picked up again
$this->rateLimitService->markForRetry($request, $error);
$this->logger->warning('EducAI: Queued request failed, will retry', [
'request_id' => $requestId,
'attempts' => $request->getAttempts(),
'max_attempts' => self::MAX_RETRY_ATTEMPTS,
'error' => $error,
]);
} else {
// Max retries exceeded
$this->rateLimitService->markFailed($request, 'Max retries exceeded: ' . $error);
$this->sendFailureNotification(
$request,
self::GENERIC_FAILURE_NOTIFICATION
);
$this->logger->error('EducAI: Queued request permanently failed', [
'request_id' => $requestId,
'attempts' => $request->getAttempts(),
'error' => $error,
]);
}
}
}
/**
* Send a failure notification to the Talk room
*/
private function sendFailureNotification(QueuedRequest $request, string $message): void {
try {
$this->talkHandler->sendReplyToTalk(
$request->getRoomToken(),
'⚠️ ' . $message,
$request->getReplyToMessageId() ?? 0
);
} catch (\Exception $e) {
$this->logger->error('EducAI: Failed to send failure notification', [
'request_id' => $request->getId(),
'error' => $e->getMessage(),
]);
}
}
}