Files
c-relay-pg/admin/api/custom_backfill.php
T
Laan Tungir 50653dc86a v2.1.38 - Custom backfill feature: ad-hoc backfill jobs with arbitrary NIP-01 filters + admin UI isolation
- New caching_custom_backfill_jobs and caching_custom_backfill_batches tables
- Admin API (POST create/cancel, GET list) at admin/api/custom_backfill.php
- Admin UI form with preset buttons and job status table on Backfill page
- Caching daemon module (custom_backfill.c) processes jobs independently
- pg_inbox functions for job/batch claim, progress update, and cancel
- Three batch modes: author-batched, id-batched, time-window scan
- Fixed done_batches overcount on job completion
- Admin config now environment-variable driven (C_RELAY_DB_*)
- admin/serve.sh supports separate instances for different databases
- make_and_restart_relay.sh only kills relay on target port, not all relays
2026-08-07 14:49:13 -04:00

316 lines
13 KiB
PHP

<?php
/**
* admin/api/custom_backfill.php — Custom backfill job management.
*
* POST actions:
* - create: Submit a new custom backfill job with a NIP-01 filter.
* - cancel: Cancel a running/pending job.
*
* GET:
* - List recent jobs with progress.
*
* The caching daemon polls caching_custom_backfill_jobs for pending jobs
* and executes them independently of the followed-set backfill.
*/
require_once __DIR__ . '/../lib/helpers.php';
$pdo = db();
// ---------------------------------------------------------------------------
// POST: create or cancel
// ---------------------------------------------------------------------------
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$input = json_decode(file_get_contents('php://input'), true) ?: [];
$action = $input['action'] ?? '';
if ($action === 'cancel') {
$job_id = (int)($input['job_id'] ?? 0);
if ($job_id <= 0) {
json_response(['ok' => false, 'error' => 'Invalid job_id'], 400);
}
try {
$stmt = $pdo->prepare(
"UPDATE caching_custom_backfill_jobs
SET status = 'cancelled',
completed_at = EXTRACT(EPOCH FROM NOW())::BIGINT,
error_message = 'Cancelled by user'
WHERE job_id = ? AND status IN ('pending', 'running')"
);
$stmt->execute([$job_id]);
if ($stmt->rowCount() === 0) {
json_response(['ok' => false, 'error' => 'Job not found or not cancellable'], 404);
}
json_response(['ok' => true, 'message' => 'Job cancelled']);
} catch (PDOException $e) {
json_response(['ok' => false, 'error' => $e->getMessage()], 500);
}
}
if ($action === 'create') {
$label = trim($input['label'] ?? '');
$kinds = trim($input['kinds'] ?? '');
$authors_source = $input['authors_source'] ?? 'none';
$authors_list = trim($input['authors_list'] ?? '');
$authors_missing_kind0 = !empty($input['authors_missing_kind0']);
$ids_list = trim($input['ids_list'] ?? '');
$since_ts = $input['since_ts'] ?? null;
$until_ts = $input['until_ts'] ?? null;
$limit = (int)($input['limit'] ?? 0);
$tag_filters = trim($input['tag_filters'] ?? '');
$batch_size = max(1, (int)($input['batch_size'] ?? 100));
$relay_mode = $input['relay_mode'] ?? 'bootstrap';
$relay_list = trim($input['relay_list'] ?? '');
// --- Resolve authors ---
$authors_resolved = '';
$ids_resolved = '';
if ($authors_source === 'manual') {
// Parse manual list: one hex pubkey per line
$lines = preg_split('/[\s,]+/', $authors_list);
$pubkeys = [];
foreach ($lines as $line) {
$line = trim($line);
if (preg_match('/^[0-9a-fA-F]{64}$/', $line)) {
$pubkeys[] = strtolower($line);
}
}
$authors_resolved = implode(',', array_unique($pubkeys));
} elseif ($authors_source === 'all_in_db') {
$rows = $pdo->query("SELECT DISTINCT pubkey FROM events")->fetchAll(PDO::FETCH_COLUMN);
$authors_resolved = implode(',', $rows);
} elseif ($authors_source === 'kind1_authors') {
$rows = $pdo->query("SELECT DISTINCT pubkey FROM events WHERE kind = 1")->fetchAll(PDO::FETCH_COLUMN);
$authors_resolved = implode(',', $rows);
} elseif ($authors_source === 'followed') {
$rows = $pdo->query("SELECT pubkey FROM caching_followed_pubkeys")->fetchAll(PDO::FETCH_COLUMN);
$authors_resolved = implode(',', $rows);
}
// authors_source === 'none' → empty list (time-window mode)
// Apply "only missing kind-0" filter
if ($authors_missing_kind0 && $authors_resolved !== '') {
$pks = explode(',', $authors_resolved);
$placeholders = implode(',', array_fill(0, count($pks), '?'));
$sql = "SELECT pk FROM (VALUES " .
implode(',', array_map(function($i) {
return "(:pk$i)";
}, array_keys($pks))) . ") AS t(pk)
WHERE NOT EXISTS (
SELECT 1 FROM events WHERE kind = 0 AND pubkey = t.pk
)";
// Simpler: use a temp table approach via IN clause
$sql = "SELECT t.pk FROM (SELECT unnest(ARRAY[:pks]::text[]) AS pk) t
WHERE NOT EXISTS (SELECT 1 FROM events WHERE kind = 0 AND pubkey = t.pk)";
try {
$stmt = $pdo->prepare("SELECT pk FROM unnest(:pks::text[]) AS pk
WHERE NOT EXISTS (SELECT 1 FROM events WHERE kind = 0 AND pubkey = pk)");
$stmt->execute([':pks' => '{' . implode(',', $pks) . '}']);
$filtered = $stmt->fetchAll(PDO::FETCH_COLUMN);
$authors_resolved = implode(',', $filtered);
} catch (PDOException $e) {
// Fallback: use NOT IN with a subquery
$stmt = $pdo->prepare("SELECT DISTINCT pubkey FROM events
WHERE pubkey IN (" . str_repeat('?,', count($pks) - 1) . "?)
AND NOT EXISTS (SELECT 1 FROM events e2 WHERE e2.kind = 0 AND e2.pubkey = events.pubkey)");
$stmt->execute($pks);
$filtered = $stmt->fetchAll(PDO::FETCH_COLUMN);
$authors_resolved = implode(',', $filtered);
}
}
// --- Parse IDs ---
if ($ids_list !== '') {
$lines = preg_split('/[\s,]+/', $ids_list);
$ids = [];
foreach ($lines as $line) {
$line = trim($line);
if (preg_match('/^[0-9a-fA-F]{64}$/', $line)) {
$ids[] = strtolower($line);
}
}
$ids_resolved = implode(',', array_unique($ids));
}
// --- Build filter_json ---
$filter = [];
if ($kinds !== '') {
$kinds_arr = array_values(array_filter(explode(',', $kinds), 'is_numeric'));
if ($kinds_arr) $filter['kinds'] = array_map('intval', $kinds_arr);
}
if ($since_ts !== null && $since_ts !== '') {
$filter['since'] = (int)$since_ts;
}
if ($until_ts !== null && $until_ts !== '') {
$filter['until'] = (int)$until_ts;
}
if ($limit > 0) {
$filter['limit'] = $limit;
}
if ($tag_filters !== '') {
$tags = json_decode($tag_filters, true);
if (is_array($tags)) {
foreach ($tags as $key => $val) {
if (strpos($key, '#') === 0 && is_array($val)) {
$filter[$key] = $val;
}
}
}
}
// --- Determine batch mode ---
$batch_mode = 'none';
if ($authors_resolved !== '') {
$batch_mode = 'authors';
} elseif ($ids_resolved !== '') {
$batch_mode = 'ids';
}
// --- Resolve relay list ---
$relays = [];
if ($relay_mode === 'custom' && $relay_list !== '') {
$lines = preg_split('/[\s,]+/', $relay_list);
foreach ($lines as $line) {
$line = trim($line);
if ($line !== '' && preg_match('/^wss?:\/\//', $line)) {
$relays[] = $line;
}
}
} elseif ($relay_mode === 'bootstrap') {
// Get bootstrap relays from config
$cfg = $pdo->query("SELECT value FROM config WHERE key = 'caching_bootstrap_relays'")->fetchColumn();
if ($cfg) {
$relays = array_map('trim', explode(',', $cfg));
}
}
if (empty($relays)) {
// Fallback: use enabled relays from caching_relays
$rows = $pdo->query("SELECT relay_url FROM caching_relays WHERE live_enabled OR backfill_enabled")->fetchAll(PDO::FETCH_COLUMN);
$relays = $rows ?: ['wss://relay.damus.io', 'wss://nos.lol'];
}
// --- Create batches ---
$batch_items_list = [];
if ($batch_mode === 'authors') {
$items = explode(',', $authors_resolved);
$batch_items_list = array_chunk($items, $batch_size);
} elseif ($batch_mode === 'ids') {
$items = explode(',', $ids_resolved);
$batch_items_list = array_chunk($items, $batch_size);
} else {
// Time-window mode: one "batch" per relay (empty batch_items)
$batch_items_list = array_fill(0, count($relays), '');
}
// For author/id-batched mode, create one batch per chunk per relay
$batches = [];
if ($batch_mode === 'none') {
foreach ($relays as $relay) {
$batches[] = ['items' => '', 'relay' => $relay];
}
} else {
foreach ($batch_items_list as $chunk) {
foreach ($relays as $relay) {
$batches[] = ['items' => implode(',', $chunk), 'relay' => $relay];
}
}
}
$filter_json = json_encode($filter);
$total_batches = count($batches);
if ($total_batches === 0) {
json_response(['ok' => false, 'error' => 'No batches to process (empty filter or no relays)'], 400);
}
try {
$pdo->beginTransaction();
$stmt = $pdo->prepare(
"INSERT INTO caching_custom_backfill_jobs
(label, filter_json, authors_source, authors_resolved, ids_resolved,
authors_missing_kind0, batch_mode, batch_size, relay_mode, relay_list,
status, total_batches, created_by)
VALUES
(:label, :filter_json::jsonb, :authors_source, :authors_resolved, :ids_resolved,
:authors_missing_kind0, :batch_mode, :batch_size, :relay_mode, :relay_list,
'pending', :total_batches, 'admin')
RETURNING job_id"
);
$stmt->execute([
':label' => $label,
':filter_json' => $filter_json,
':authors_source' => $authors_source,
':authors_resolved' => $authors_resolved,
':ids_resolved' => $ids_resolved,
':authors_missing_kind0' => $authors_missing_kind0 ? 1 : 0,
':batch_mode' => $batch_mode,
':batch_size' => $batch_size,
':relay_mode' => $relay_mode,
':relay_list' => $relay_list,
':total_batches' => $total_batches,
]);
$job_id = $stmt->fetchColumn();
// Insert batches
$batch_stmt = $pdo->prepare(
"INSERT INTO caching_custom_backfill_batches
(job_id, batch_index, batch_items, relay_url, status)
VALUES (?, ?, ?, ?, 'pending')"
);
foreach ($batches as $i => $batch) {
$batch_stmt->execute([$job_id, $i, $batch['items'], $batch['relay']]);
}
$pdo->commit();
json_response([
'ok' => true,
'job_id' => (int)$job_id,
'total_batches' => $total_batches,
'batch_mode' => $batch_mode,
'message' => "Job created with $total_batches batches"
]);
} catch (PDOException $e) {
$pdo->rollBack();
json_response(['ok' => false, 'error' => $e->getMessage()], 500);
}
}
json_response(['ok' => false, 'error' => 'Unknown action'], 400);
}
// ---------------------------------------------------------------------------
// GET: list jobs
// ---------------------------------------------------------------------------
$limit = min(50, max(1, (int)($_GET['limit'] ?? 20)));
try {
$jobs = $pdo->prepare(
"SELECT job_id, label, filter_json, authors_source, batch_mode,
batch_size, relay_mode, status, total_batches, done_batches,
events_fetched, error_message,
created_at, started_at, completed_at
FROM caching_custom_backfill_jobs
ORDER BY created_at DESC
LIMIT ?"
);
$jobs->execute([$limit]);
$jobs = $jobs->fetchAll();
// Include batch details for running jobs
foreach ($jobs as &$job) {
$job['filter_json'] = json_decode($job['filter_json'], true);
if ($job['status'] === 'running') {
$bstmt = $pdo->prepare(
"SELECT batch_index, relay_url, status, events_fetched, error_message
FROM caching_custom_backfill_batches
WHERE job_id = ?
ORDER BY batch_index"
);
$bstmt->execute([$job['job_id']]);
$job['batches'] = $bstmt->fetchAll();
}
}
json_response(['ok' => true, 'jobs' => $jobs]);
} catch (PDOException $e) {
json_response(['ok' => false, 'error' => $e->getMessage()], 500);
}