queue->enqueue($siteId, self::ERASE_ASSISTANT, ['bot_id' => $botId], $key, 0, self::ERASE_ATTEMPTS); return true; } catch (\Aiwend\Queue\JobRejectedException) { return false; } } public function id(): string { return 'chat'; } public function label(): string { return __('Chat', 'aiwend'); } public function dependencies(): array { return []; } public function jobTypes(): array { return ['chat.retention', self::ERASE_ASSISTANT]; } public function prerequisitesMet(): bool { return true; } public function prerequisiteMessage(): string { return ''; } public function boot(): void { add_action('rest_api_init', function (): void { (new ChatController($this->plugin))->register(); (new ChatController($this->plugin, true))->register(); // private preview for operators }); (new ChatWidget($this->plugin))->register(); (new ConversationAdmin($this->plugin))->register(); add_action('aiwend_register_job_handlers', function (Worker $worker): void { $worker->handle(self::ERASE_ASSISTANT, function (Job $job): void { $botId = (int) ($job->payload['bot_id'] ?? 0); $result = $this->plugin->assistantDeletion()->erase($job->siteId, $botId); if ($result['code'] === 'deleting') { // More conversations than one batch: the next batch is a new job (a new key per parent job), so every run stays short. self::queueErase($this->plugin, $job->siteId, $botId, 'bot-erase:' . $botId . ':after:' . $job->id); } else { $this->plugin->audit->record($job->siteId, 'system', 'bot.erase', "bot:$botId", $result['code'] === 'deleted' ? 'ok' : 'not_found'); } }); $worker->handle(self::RETENTION, fn(Job $job) => $this->retention($job)); }); } /** * The daily retention job (phase 113: bounded and resumable). The first job of a day does the cheap, bounded housekeeping and then one * batch of expired conversations; when more are left it queues a continuation (key `chat.retention:more:`, so a retried job * can never start a second chain) and every run stays short. Nothing is remembered between runs: the data is the cursor, so an * interrupted or failed chain is picked up by the next daily job, which recomputes the expired set. If the Chat module is switched off * the queue refuses the continuation and the jobs are suspended with the module; retention then waits for it, as it always did. */ public function retention(Job $job): void { if (empty($job->payload['continuation'])) { // Per-client daily budget buckets are useless two days after their day: remove them (bounded, unheld rows only). $before = $this->plugin->clock->now() - 2 * 86400; for ($batch = 0; $batch < 10 && $this->plugin->ledger->purgeClientBuckets($job->siteId, $before, 500) === 500; $batch++) { // keep going until a batch comes back short, at most 5,000 rows per run } // Safety net: an assistant whose deletion is stuck (its job was lost, the module was off, cron did not run) gets its // erase job queued again, at most once a day each (the key carries the date), twenty assistants at most. foreach (array_slice($this->plugin->bots->deleting($job->siteId), 0, 20) as $deleting) { self::queueErase($this->plugin, $job->siteId, (int) $deleting['id'], 'bot-erase:' . (int) $deleting['id'] . ':daily-' . gmdate('Ymd')); } } $step = $this->plugin->sessions->purgeBatch($job->siteId, $this->plugin->retentionDays('aiwend_chat_retention_days')); if ($step['more']) { try { $this->plugin->queue->enqueue($job->siteId, self::RETENTION, ['continuation' => true], 'chat.retention:more:' . $job->id, 0, self::ERASE_ATTEMPTS); } catch (\Aiwend\Queue\JobRejectedException) { // module switched off in the meantime: the next daily job resumes } } } }