db->selectOne('SELECT id FROM {bots} WHERE id = ? AND site_id = ? AND status <> ?', [$botId, $siteId, BotRepository::DELETED]) === null) { return null; } $now = $this->clock->now(); $cutoff = $now - max(0, $retentionDays) * 86400; $newest = (int) ($this->db->selectOne('SELECT MAX(created_at) AS t FROM {chat_sessions} WHERE site_id = ? AND bot_id = ?', [$siteId, $botId])['t'] ?? 0); return [ 'sessions' => $this->count('SELECT COUNT(*) AS n FROM {chat_sessions} WHERE site_id = ? AND bot_id = ?', [$siteId, $botId]), 'messages' => $this->count('SELECT COUNT(*) AS n FROM {messages} m JOIN {chat_sessions} s ON s.id = m.session_id AND s.site_id = m.site_id WHERE s.site_id = ? AND s.bot_id = ?', [$siteId, $botId]), 'sources' => $this->count('SELECT COUNT(*) AS n FROM {bot_sources} WHERE site_id = ? AND bot_id = ?', [$siteId, $botId]), 'past_retention' => $retentionDays > 0 ? $this->count('SELECT COUNT(*) AS n FROM {chat_sessions} WHERE site_id = ? AND bot_id = ? AND created_at < ?', [$siteId, $botId, $cutoff]) : 0, 'days_left' => $retentionDays > 0 && $newest > 0 ? max(0, (int) ceil(($newest + $retentionDays * 86400 - $now) / 86400)) : 0, ]; } /** * Takes the assistant out of service and removes what the chosen mode says. * * @param string $conversations self::KEEP or self::ERASE; an assistant whose deletion is already running continues erasing whatever is passed * @return array{code:string, remaining:int, detached:int} code: "deleted", "deleting" (conversations left, see remaining), "not_found" or "invalid_mode" */ public function delete(int $siteId, int $botId, string $conversations, int $batch = self::BATCH, float $seconds = self::REQUEST_SECONDS): array { if (!in_array($conversations, [self::KEEP, self::ERASE], true)) { return ['code' => 'invalid_mode', 'remaining' => 0, 'detached' => 0]; } // The closure returns its results (nothing captured by reference): a transaction can be re-run after a deadlock. [$state, $detached] = $this->db->transaction(function () use ($siteId, $botId, $conversations): array { $row = $this->lock($siteId, $botId); if ($row === null || $row['status'] === BotRepository::DELETED) { return [null, 0]; } if ($row['status'] === BotRepository::DELETING) { return [BotRepository::DELETING, 0]; // a repeated request: continue below } $detached = $this->db->execute('DELETE FROM {bot_sources} WHERE site_id = ? AND bot_id = ?', [$siteId, $botId]); $this->db->execute('UPDATE {chat_sessions} SET revoked = 1 WHERE site_id = ? AND bot_id = ?', [$siteId, $botId]); if ($conversations === self::KEEP) { $this->tombstone($siteId, $botId); return [BotRepository::DELETED, $detached]; } $this->db->execute('UPDATE {bots} SET status = ?, connection_id = NULL, is_public = 0, updated_at = ? WHERE id = ? AND site_id = ?', [BotRepository::DELETING, $this->clock->now(), $botId, $siteId]); return [BotRepository::DELETING, $detached]; }); if ($state === null) { return ['code' => 'not_found', 'remaining' => 0, 'detached' => 0]; } if ($state === BotRepository::DELETED) { return ['code' => 'deleted', 'remaining' => 0, 'detached' => $detached]; } try { $result = $this->erase($siteId, $botId, $batch, $seconds); } catch (\Throwable) { // The assistant is already out of service and its deletion is recorded: a failing first batch is not an error for the administrator. // It stays "deleting" (never "deleted" while anything is left) and the background job or Continue retries it. $result = ['code' => 'deleting', 'remaining' => max(1, $this->safeRemaining($siteId, $botId)), 'detached' => 0]; } $result['detached'] = $detached; return $result; } /** * Erases up to $batch conversations of an assistant whose deletion is running and finishes it when none is left. Safe to call at any * time and any number of times: for an assistant that is not being deleted it does nothing. * * @return array{code:string, remaining:int, detached:int} "deleted" when finished, "deleting" while conversations remain, "not_found" otherwise */ public function erase(int $siteId, int $botId, int $batch = self::BATCH, float $seconds = self::REQUEST_SECONDS): array { $row = $this->db->selectOne('SELECT status FROM {bots} WHERE id = ? AND site_id = ?', [$botId, $siteId]); if ($row === null || $row['status'] !== BotRepository::DELETING) { return ['code' => 'not_found', 'remaining' => 0, 'detached' => 0]; } $deadline = microtime(true) + $seconds; $left = $batch; while ($left > 0 && microtime(true) < $deadline) { $ids = $this->db->select('SELECT id FROM {chat_sessions} WHERE site_id = ? AND bot_id = ? ORDER BY id LIMIT ?', [$siteId, $botId, min($left, 25)]); if ($ids === []) { break; } foreach ($ids as $r) { $this->sessions->delete($siteId, (int) $r['id']); $left--; } } $remaining = $this->count('SELECT COUNT(*) AS n FROM {chat_sessions} WHERE site_id = ? AND bot_id = ?', [$siteId, $botId]); if ($remaining > 0) { return ['code' => 'deleting', 'remaining' => $remaining, 'detached' => 0]; } // Nothing left: write the tombstone, unless a session slipped in meanwhile (checked again under the row lock). $finished = $this->db->transaction(function () use ($siteId, $botId): bool { $locked = $this->lock($siteId, $botId); if ($locked === null || $locked['status'] !== BotRepository::DELETING) { return $locked !== null && $locked['status'] === BotRepository::DELETED; } if ($this->count('SELECT COUNT(*) AS n FROM {chat_sessions} WHERE site_id = ? AND bot_id = ?', [$siteId, $botId]) > 0) { return false; } $this->db->execute('DELETE FROM {bot_sources} WHERE site_id = ? AND bot_id = ?', [$siteId, $botId]); $this->tombstone($siteId, $botId); return true; }); return $finished ? ['code' => 'deleted', 'remaining' => 0, 'detached' => 0] : ['code' => 'deleting', 'remaining' => max(1, $this->count('SELECT COUNT(*) AS n FROM {chat_sessions} WHERE site_id = ? AND bot_id = ?', [$siteId, $botId])), 'detached' => 0]; } /** Locks the assistant's row for the rest of the transaction (MySQL/MariaDB; SQLite serializes write transactions). @return array|null */ private function lock(int $siteId, int $botId): ?array { $lock = $this->db->driver() === 'mysql' ? ' FOR UPDATE' : ''; return $this->db->selectOne('SELECT id, status FROM {bots} WHERE id = ? AND site_id = ?' . $lock, [$botId, $siteId]); } /** Replaces the assistant's configuration with a content-free marker; the row (and its number) stays. */ private function tombstone(int $siteId, int $botId): void { $this->db->execute( 'UPDATE {bots} SET status = ?, name = ?, connection_id = NULL, model = ?, instruction = ?, is_public = 0, appearance = NULL, updated_at = ? WHERE id = ? AND site_id = ?', [BotRepository::DELETED, '[deleted]', 'deleted', '[deleted]', $this->clock->now(), $botId, $siteId] ); } private function safeRemaining(int $siteId, int $botId): int { try { return $this->count('SELECT COUNT(*) AS n FROM {chat_sessions} WHERE site_id = ? AND bot_id = ?', [$siteId, $botId]); } catch (\Throwable) { return 1; } } /** @param list $params */ private function count(string $sql, array $params): int { return (int) ($this->db->selectOne($sql, $params)['n'] ?? 0); } }