Files
c-relay-pg/admin/api/cleanup.php
T

311 lines
12 KiB
PHP

<?php
/**
* admin2/api/cleanup.php — Event Cleanup: Preview & Execute.
*
* GET — Preview a cleanup query (count + size estimate, no delete).
* POST — Execute a cleanup query (dry_run=true for preview, false for delete).
*/
require_once __DIR__ . '/../lib/helpers.php';
$pdo = db();
$method = $_SERVER['REQUEST_METHOD'];
// ── Shared filter builder ──────────────────────────────────────────────
// Returns [sql_conditions[], params[]] for the WHERE clauses.
function build_filters(array $opts): array {
$conds = [];
$params = [];
// Kinds filter
if (!empty($opts['kinds'])) {
$kinds = is_array($opts['kinds']) ? $opts['kinds'] : explode(',', $opts['kinds']);
$kinds = array_map('intval', $kinds);
$kinds = array_filter($kinds, fn($v) => $v > 0);
if (!empty($kinds)) {
$placeholders = [];
foreach ($kinds as $i => $k) {
$key = ':kind_' . $i;
$placeholders[] = $key;
$params[$key] = $k;
}
$conds[] = 'e.kind IN (' . implode(',', $placeholders) . ')';
}
}
// Date/time range filter (supports YYYY-MM-DD or YYYY-MM-DD HH:MM)
$from_date = $opts['from_date'] ?? '';
$to_date = $opts['to_date'] ?? '';
if ($from_date !== '') {
if (preg_match('/^\d{4}-\d{2}-\d{2}$/', $from_date)) {
$conds[] = 'e.created_at >= EXTRACT(EPOCH FROM :from_date::date)::BIGINT';
$params[':from_date'] = $from_date;
} elseif (preg_match('/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}$/', $from_date)) {
$conds[] = 'e.created_at >= EXTRACT(EPOCH FROM :from_date::timestamp)::BIGINT';
$params[':from_date'] = $from_date . ':00';
}
}
if ($to_date !== '') {
if (preg_match('/^\d{4}-\d{2}-\d{2}$/', $to_date)) {
$conds[] = 'e.created_at < (EXTRACT(EPOCH FROM :to_date::date)::BIGINT + 86400)';
$params[':to_date'] = $to_date;
} elseif (preg_match('/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}$/', $to_date)) {
$conds[] = 'e.created_at < EXTRACT(EPOCH FROM :to_date::timestamp)::BIGINT';
$params[':to_date'] = $to_date . ':00';
}
}
// Follows filter
$follows_filter = $opts['follows_filter'] ?? 'all';
if ($follows_filter === 'follows') {
$conds[] = 'e.pubkey IN (SELECT pubkey FROM caching_followed_pubkeys)';
} elseif ($follows_filter === 'non_follows') {
$conds[] = 'e.pubkey NOT IN (SELECT pubkey FROM caching_followed_pubkeys)';
}
return [$conds, $params];
}
// ── Build a WHERE clause string from conditions ────────────────────────
function where_clause(array $conds): string {
if (empty($conds)) return '';
return 'AND ' . implode("\n AND ", $conds);
}
// ── Format bytes as human-readable ─────────────────────────────────────
function format_bytes(int $bytes): string {
if ($bytes < 1024) return $bytes . ' B';
if ($bytes < 1048576) return round($bytes / 1024, 1) . ' KB';
if ($bytes < 1073741824) return round($bytes / 1048576, 1) . ' MB';
return round($bytes / 1073741824, 2) . ' GB';
}
// ── GET: Preview ───────────────────────────────────────────────────────
if ($method === 'GET') {
$opts = [
'follows_filter' => $_GET['follows_filter'] ?? 'all',
'kinds' => $_GET['kinds'] ?? '',
'from_date' => $_GET['from_date'] ?? '',
'to_date' => $_GET['to_date'] ?? '',
'max_events' => intval($_GET['max_events'] ?? 0),
];
list($conds, $params) = build_filters($opts);
$where = where_clause($conds);
// Preview SQL (for display)
$sql_preview = "SELECT COUNT(*) AS match_count,\n"
. " COALESCE(SUM(pg_column_size(event_json)), 0) AS total_size_bytes\n"
. "FROM events e\n"
. "WHERE 1=1\n"
. ($where ? " $where\n" : '');
// Count + size query
$count_sql = "SELECT COUNT(*) AS match_count,\n"
. " COALESCE(SUM(pg_column_size(event_json)), 0) AS total_size_bytes\n"
. "FROM events e\n"
. "WHERE 1=1 $where";
try {
$stmt = $pdo->prepare($count_sql);
$stmt->execute($params);
$row = $stmt->fetch();
$match_count = intval($row['match_count'] ?? 0);
$total_size_bytes = intval($row['total_size_bytes'] ?? 0);
} catch (PDOException $e) {
json_response(['error' => 'Preview query failed: ' . $e->getMessage()]);
exit;
}
// Kind breakdown
$breakdown = [];
if ($match_count > 0) {
$breakdown_sql = "SELECT e.kind,\n"
. " COUNT(*) AS count,\n"
. " COALESCE(SUM(pg_column_size(e.event_json)), 0) AS size_bytes\n"
. "FROM events e\n"
. "WHERE 1=1 $where\n"
. "GROUP BY e.kind\n"
. "ORDER BY count DESC\n"
. "LIMIT 50";
try {
$stmt = $pdo->prepare($breakdown_sql);
$stmt->execute($params);
$rows = $stmt->fetchAll();
foreach ($rows as $r) {
$breakdown[] = [
'kind' => intval($r['kind']),
'count' => intval($r['count']),
'size_bytes' => intval($r['size_bytes']),
];
}
} catch (PDOException $e) {
// Breakdown is non-critical
}
}
// If a query_id was provided, update last_preview_count and last_preview_size_bytes
$query_id = intval($_GET['query_id'] ?? 0);
if ($query_id > 0) {
try {
$pdo->prepare(
"UPDATE cleanup_saved_queries\n"
. " SET last_preview_count = :count,\n"
. " last_preview_size_bytes = :size\n"
. " WHERE id = :id"
)->execute([
':id' => $query_id,
':count' => $match_count,
':size' => $total_size_bytes,
]);
} catch (PDOException $e) {}
}
json_response([
'match_count' => $match_count,
'total_size_bytes' => $total_size_bytes,
'total_size_human' => format_bytes($total_size_bytes),
'avg_size_per_event' => $match_count > 0 ? intval($total_size_bytes / $match_count) : 0,
'kinds_breakdown' => $breakdown,
'sql_preview' => $sql_preview,
]);
exit;
}
// ── POST: Execute (or dry-run) ─────────────────────────────────────────
if ($method === 'POST') {
$body = json_decode(file_get_contents('php://input'), true);
if (!$body) {
json_response(['error' => 'Invalid JSON body']);
exit;
}
$dry_run = !empty($body['dry_run']);
$opts = [
'follows_filter' => $body['follows_filter'] ?? 'all',
'kinds' => $body['kinds'] ?? [],
'from_date' => $body['from_date'] ?? '',
'to_date' => $body['to_date'] ?? '',
'max_events' => intval($body['max_events'] ?? 0),
];
list($conds, $params) = build_filters($opts);
$where = where_clause($conds);
// If dry_run, return preview (same as GET)
if ($dry_run) {
$count_sql = "SELECT COUNT(*) AS match_count,\n"
. " COALESCE(SUM(pg_column_size(event_json)), 0) AS total_size_bytes\n"
. "FROM events e\n"
. "WHERE 1=1 $where";
try {
$stmt = $pdo->prepare($count_sql);
$stmt->execute($params);
$row = $stmt->fetch();
$match_count = intval($row['match_count'] ?? 0);
$total_size_bytes = intval($row['total_size_bytes'] ?? 0);
} catch (PDOException $e) {
json_response(['error' => 'Preview query failed: ' . $e->getMessage()]);
exit;
}
// Kind breakdown
$breakdown = [];
if ($match_count > 0) {
$breakdown_sql = "SELECT e.kind, COUNT(*) AS count,\n"
. " COALESCE(SUM(pg_column_size(e.event_json)), 0) AS size_bytes\n"
. "FROM events e\n"
. "WHERE 1=1 $where\n"
. "GROUP BY e.kind\n"
. "ORDER BY count DESC\n"
. "LIMIT 50";
try {
$stmt = $pdo->prepare($breakdown_sql);
$stmt->execute($params);
foreach ($stmt->fetchAll() as $r) {
$breakdown[] = [
'kind' => intval($r['kind']),
'count' => intval($r['count']),
'size_bytes' => intval($r['size_bytes']),
];
}
} catch (PDOException $e) {}
}
json_response([
'match_count' => $match_count,
'total_size_bytes' => $total_size_bytes,
'total_size_human' => format_bytes($total_size_bytes),
'avg_size_per_event' => $match_count > 0 ? intval($total_size_bytes / $match_count) : 0,
'kinds_breakdown' => $breakdown,
'dry_run' => true,
]);
exit;
}
// ── Actual DELETE ──────────────────────────────────────────────────
$max_events = intval($body['max_events'] ?? 0);
$limit_clause = $max_events > 0 ? 'LIMIT :max_events' : '';
// First, get the count and size of what will be deleted
$preview_sql = "SELECT COUNT(*) AS match_count,\n"
. " COALESCE(SUM(pg_column_size(event_json)), 0) AS total_size_bytes\n"
. "FROM events e\n"
. "WHERE 1=1 $where";
try {
$stmt = $pdo->prepare($preview_sql);
$stmt->execute($params);
$row = $stmt->fetch();
$expected_count = intval($row['match_count'] ?? 0);
$expected_bytes = intval($row['total_size_bytes'] ?? 0);
} catch (PDOException $e) {
json_response(['error' => 'Pre-delete count failed: ' . $e->getMessage()]);
exit;
}
// Build the DELETE using id IN (subquery) for safe LIMIT + ORDER BY
$delete_sql = "DELETE FROM events e\n"
. "WHERE e.id IN (\n"
. " SELECT e2.id FROM events e2\n"
. " WHERE 1=1 $where\n"
. " ORDER BY e2.created_at ASC\n"
. " $limit_clause\n"
. ")";
$start_time = microtime(true);
try {
$stmt = $pdo->prepare($delete_sql);
if ($max_events > 0) {
$params[':max_events'] = $max_events;
}
$stmt->execute($params);
$deleted_count = $stmt->rowCount();
} catch (PDOException $e) {
json_response(['error' => 'Delete query failed: ' . $e->getMessage()]);
exit;
}
$duration_ms = round((microtime(true) - $start_time) * 1000);
// If we deleted fewer than expected, the actual freed bytes are proportional
$freed_bytes = $expected_count > 0
? intval($expected_bytes * ($deleted_count / $expected_count))
: 0;
// Clear stats cache so the dashboard reflects changes immediately
$cache_dir = __DIR__ . '/../cache';
foreach (['stats_kinds.json', 'stats_pubkeys.json'] as $cache_file) {
$path = $cache_dir . '/' . $cache_file;
if (is_file($path)) @unlink($path);
}
json_response([
'deleted_count' => $deleted_count,
'freed_bytes' => $freed_bytes,
'freed_human' => format_bytes($freed_bytes),
'duration_ms' => $duration_ms,
]);
exit;
}
// Unsupported method
json_response(['error' => 'Method not allowed']);