- 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
18 KiB
Custom Backfill Feature Plan
Overview
A fully general ad-hoc backfill system that lets the admin define a backfill job using any combination of NIP-01 filter fields — kinds, authors, event IDs, time range, tag filters, and limit — plus relay selection. The caching daemon executes the job, batching as needed, and inserts results through the existing inbox pipeline.
The kind-0 profile backfill ("cache all kind-0 for these authors") and the time-window scan ("cache all kind-0 from date X to date Y") are both just presets of this one general feature.
Motivation
The current backfill only covers the followed set (688 pubkeys). There are 23,257 pubkeys in the local DB missing kind-0 profiles, and the user wants a general way to backfill arbitrary filters. Use cases:
- "Cache all kind-0 from date X to date Y" — time-window scan, no authors
- "Backfill missing profiles for kind-1 authors" — author-batched, kind-0
- "Backfill kind-10002 relay lists for all pubkeys in DB" — author-batched
- "Fetch all events from pubkey X" — single author, all kinds
- "Fetch events matching #t:nostr from last 30 days" — tag filter + time
- "Re-fetch a specific event by ID" — IDs filter
- "Backfill kind-3 contact lists for the followed set" — author-batched
NIP-01 Filter Coverage
The feature supports every standard NIP-01 filter field:
| Field | UI Input | Batching Strategy |
|---|---|---|
kinds |
CSV text (e.g., 0 or 0,1,3) |
Included in every batch REQ |
authors |
Source selector + manual list | Author-batched: split into groups of N per REQ |
ids |
Textarea, one hex ID per line | ID-batched: split into groups of N per REQ |
since |
datetime-local picker | Included in every batch REQ |
until |
datetime-local picker | Included in every batch REQ |
limit |
number (optional) | Included in every batch REQ |
#e, #p, #t, #r, etc. |
JSON textarea (advanced) | Included in every batch REQ |
Batching logic
The daemon picks the batching strategy based on which array fields are populated:
authorspopulated → split authors into batches ofbatch_size(default 100). Each batch REQ includes the full kinds/since/until/tags/limit.idspopulated (and no authors) → split IDs into batches ofbatch_size. Each batch REQ includes kinds/since/until/tags/limit.- Neither
authorsnorids→ one REQ per relay (time-window scan mode). No batching; the filter is sent as-is to each selected relay. This is the "cache all kind-0 from date X to date Y" case.
flowchart TD
A[Submit job] --> B{authors or ids populated?}
B -->|yes| C[Split into batches of N]
C --> D[One REQ per batch per relay]
B -->|no| E[One REQ per relay - time window scan]
D --> F[Collect events until EOSE/timeout]
E --> F
F --> G[Insert into caching_event_inbox]
G --> H{More batches?}
H -->|yes| D
H -->|no| I[Mark job complete]
G --> J[Relay inbox poller stores events]
J --> K[Triggers update profiles table etc]
Database Schema
caching_custom_backfill_jobs
One row per submitted job. Stores the raw filter JSON plus metadata for the UI. The daemon reconstructs the NIP-01 filter from this.
CREATE TABLE IF NOT EXISTS caching_custom_backfill_jobs (
job_id BIGSERIAL PRIMARY KEY,
label TEXT NOT NULL DEFAULT '',
-- The complete NIP-01 filter as JSON. This is the source of truth;
-- the daemon builds REQ filters directly from this.
-- Example: {"kinds":[0],"since":1700000000,"until":1700086400}
-- Example: {"kinds":[0],"authors":["abc...","def..."]}
-- Example: {"kinds":[1],"#t":["nostr"],"since":1700000000}
filter_json JSONB NOT NULL,
-- Author/ID source metadata (for UI display and batch planning).
-- 'manual' | 'all_in_db' | 'kind1_authors' | 'followed' | 'none'
authors_source TEXT NOT NULL DEFAULT 'none',
-- When source produces a derived list (all_in_db, kind1_authors, etc.),
-- the resolved author list is stored here so the daemon doesn't need
-- to re-derive it. CSV of hex pubkeys.
authors_resolved TEXT NOT NULL DEFAULT '',
-- Same for IDs (if the job is ID-based). CSV of hex IDs.
ids_resolved TEXT NOT NULL DEFAULT '',
-- Optional filter: only include authors missing a kind-0 event.
authors_missing_kind0 INTEGER NOT NULL DEFAULT 0,
-- Batching
-- 'authors' | 'ids' | 'none' (none = one REQ per relay)
batch_mode TEXT NOT NULL DEFAULT 'none',
batch_size INTEGER NOT NULL DEFAULT 100,
-- Relay selection
-- 'bootstrap' | 'outbox' | 'custom'
relay_mode TEXT NOT NULL DEFAULT 'bootstrap',
relay_list TEXT NOT NULL DEFAULT '', -- CSV when mode=custom
-- State
status TEXT NOT NULL DEFAULT 'pending',
-- 'pending' | 'running' | 'complete' | 'failed' | 'cancelled'
total_batches INTEGER NOT NULL DEFAULT 0,
done_batches INTEGER NOT NULL DEFAULT 0,
events_fetched INTEGER NOT NULL DEFAULT 0,
error_message TEXT,
created_at BIGINT NOT NULL DEFAULT EXTRACT(EPOCH FROM NOW())::BIGINT,
started_at BIGINT,
completed_at BIGINT,
created_by TEXT NOT NULL DEFAULT 'admin'
);
CREATE INDEX IF NOT EXISTS idx_custom_backfill_jobs_status
ON caching_custom_backfill_jobs(status) WHERE status IN ('pending', 'running');
caching_custom_backfill_batches
One row per batch. For time-window mode (batch_mode='none'), there is one
batch per relay.
CREATE TABLE IF NOT EXISTS caching_custom_backfill_batches (
batch_id BIGSERIAL PRIMARY KEY,
job_id BIGINT NOT NULL REFERENCES caching_custom_backfill_jobs(job_id) ON DELETE CASCADE,
batch_index INTEGER NOT NULL,
-- For author-batched: CSV of hex pubkeys in this batch
-- For id-batched: CSV of hex IDs in this batch
-- For time-window: empty (filter_json has everything)
batch_items TEXT NOT NULL DEFAULT '',
-- Relay this batch is assigned to
relay_url TEXT NOT NULL,
-- State
status TEXT NOT NULL DEFAULT 'pending',
-- 'pending' | 'running' | 'complete' | 'failed'
events_fetched INTEGER NOT NULL DEFAULT 0,
error_message TEXT,
started_at BIGINT,
completed_at BIGINT,
UNIQUE(job_id, batch_index)
);
CREATE INDEX IF NOT EXISTS idx_custom_backfill_batches_pending
ON caching_custom_backfill_batches(job_id, status) WHERE status = 'pending';
Admin UI
Add a new card to the existing Backfill section in
admin/index.php, below the "Re-run All Backfill"
button.
Form fields
| Field | Type | Description |
|---|---|---|
| Label | text | User-friendly name (e.g., "Profile backfill Jan 2024") |
| Kinds | text (CSV) | Comma-separated kinds (e.g., 0 or 0,1,3). Help text lists common kinds. |
| Authors source | select | none / manual list / all pubkeys in DB / kind-1 authors only / followed set |
| Authors list | textarea | Hex pubkeys, one per line (only when source=manual) |
| Only missing kind-0 | checkbox | Filter author list to those without a kind-0 event |
| Event IDs | textarea | Hex event IDs, one per line (optional, alternative to authors) |
| Since | datetime-local | Lower bound (optional) |
| Until | datetime-local | Upper bound (optional) |
| Limit | number | Max events per REQ (optional, 0 = no limit) |
| Tag filters | text (JSON) | e.g., {"#t":["nostr"],"#p":["abc..."]} (optional, advanced) |
| Batch size | number | Authors/IDs per REQ (default 100, ignored for time-window mode) |
| Relay mode | select | bootstrap relays / outbox relays / custom list |
| Custom relays | textarea | Relay URLs, one per line (only when mode=custom) |
Preset buttons
Quick-fill the form for common scenarios:
- "Backfill missing profiles (kind-1 authors)" — kinds=
0, authors source=kind-1 authors only, only-missing-kind-0=checked, relay mode=bootstrap - "Backfill missing profiles (all pubkeys)" — kinds=
0, authors source=all pubkeys in DB, only-missing-kind-0=checked, relay mode=bootstrap - "Cache all kind-0 from date range" — kinds=
0, authors source=none, since/until = user picks, relay mode=bootstrap - "Backfill relay lists" — kinds=
10002, authors source=all pubkeys in DB, relay mode=bootstrap - "Backfill contact lists" — kinds=
3, authors source=followed set, relay mode=bootstrap
Job status table
Below the form, a table showing recent jobs:
| Label | Filter | Batches | Status | Events | Started | Actions |
|---|---|---|---|---|---|---|
| Missing profiles | kinds:0 authors:9178 | 92 | running 45/92 | 4,012 | 2m ago | Cancel |
| Kind-0 Jan 2024 | kinds:0 since:.. until:.. | 4 | complete 4/4 | 1,203 | 1h ago | — |
| Relay lists | kinds:10002 authors:31994 | 320 | complete 320/320 | 28,103 | 3h ago | — |
Admin API
admin/api/custom_backfill.php
POST — Create a new job:
{
"action": "create",
"label": "Missing profiles",
"kinds": "0",
"authors_source": "kind1_authors",
"authors_list": "",
"authors_missing_kind0": true,
"ids_list": "",
"since_ts": null,
"until_ts": null,
"limit": 0,
"tag_filters": "",
"batch_size": 100,
"relay_mode": "bootstrap",
"relay_list": ""
}
The API:
- Resolves
authors_sourceto a concrete author list:all_in_db→SELECT DISTINCT pubkey FROM eventskind1_authors→SELECT DISTINCT pubkey FROM events WHERE kind = 1followed→SELECT pubkey FROM caching_followed_pubkeysmanual→ parseauthors_listnone→ empty (time-window mode)
- If
authors_missing_kind0, filter to:NOT EXISTS (SELECT 1 FROM events WHERE kind = 0 AND pubkey = ...) - Builds the
filter_jsonfrom kinds/since/until/limit/tag_filters. - Determines
batch_mode:- If authors resolved →
authors - Else if IDs provided →
ids - Else →
none(time-window)
- If authors resolved →
- Creates batch rows:
authors/idsmode → split into groups ofbatch_size, one batch per group per relaynonemode → one batch per selected relay
- Inserts job + batches, returns
{"ok": true, "job_id": 42}.
POST — Cancel a job:
{"action": "cancel", "job_id": 42}
Sets status to cancelled; the daemon checks status between batches.
GET — List jobs (for the status table):
GET /api/custom_backfill.php?limit=20
Returns recent jobs with progress + batch counts.
Caching Daemon
New module: caching/src/custom_backfill.c / .h
typedef struct {
int has_pending;
long active_job_id;
int active_batch_index;
time_t last_tick;
} cr_custom_backfill_t;
void cr_custom_backfill_init(cr_custom_backfill_t *cb);
int cr_custom_backfill_tick(cr_custom_backfill_t *cb,
nostr_relay_pool_t *upstream,
cr_sink_t *sink);
Main loop integration
In caching/src/main.c main loop, add a tick
after the existing backfill tick:
/* Custom backfill tick — runs independently of the followed-set backfill. */
cr_custom_backfill_tick(&custom_bf, upstream, &sink);
Tick logic
- If no active job, poll
caching_custom_backfill_jobsfor apendingjob. - If found, atomically claim it (set status=
running,started_at=now). - Pick the next
pendingbatch for this job. - Build the NIP-01 REQ filter:
- Start from the job's
filter_json(kinds, since, until, limit, tags). - If
batch_mode='authors': add"authors": [batch_items] - If
batch_mode='ids': add"ids": [batch_items] - If
batch_mode='none': filter is already complete (time-window scan)
- Start from the job's
- Resolve relays based on
relay_mode:bootstrap→ use the upstream pool's relaysoutbox→ look up each author's kind-10002 outbox relays (only for author-batched mode; falls back to bootstrap if no outbox found)custom→ use the job'srelay_list
- Send
REQto the selected relay(s), collect events until EOSE or timeout. - Insert each event via
pg_inbox_insert_event()withsource_class="backfill". - Update batch row:
events_fetched,status=complete. - Update job row: increment
done_batches, add toevents_fetched. - If all batches done, set job
status=complete,completed_at=now. - Throttle: one batch per
caching_backfill_tick_interval_ms(reuse existing config).
New pg_inbox functions
/* Poll for a pending custom backfill job. Atomically claims it by setting
* status='running'. Returns 0 on success, -1 if none available or error. */
int pg_inbox_claim_custom_backfill_job(long *out_job_id);
/* Get job parameters for a claimed job. Returns 0 on success, -1 on error.
* Caller must free returned strings. filter_json is the raw JSONB string. */
int pg_inbox_get_custom_backfill_job(long job_id,
char **out_filter_json,
char **out_batch_mode,
int *out_batch_size,
char **out_relay_mode,
char **out_relay_list);
/* Pick the next pending batch for a job. Atomically sets status='running'.
* Returns 0 on success, -1 if no more batches. */
int pg_inbox_claim_custom_backfill_batch(long job_id,
int *out_batch_index,
char **out_batch_items,
char **out_relay_url);
/* Update batch progress after a REQ completes. */
int pg_inbox_update_custom_backfill_batch(long job_id, int batch_index,
int events_fetched,
const char *status,
const char *error);
/* Update job aggregate progress and optionally mark complete. */
int pg_inbox_update_custom_backfill_job(long job_id,
int events_fetched,
const char *status,
const char *error);
/* Cancel a job (set status='cancelled'). Batches will be skipped. */
int pg_inbox_cancel_custom_backfill_job(long job_id);
Relay Inbox Poller
No changes needed. The existing
caching_inbox_poller already consumes
caching_event_inbox rows and inserts them into the events table. The
sync_profile_from_event() trigger auto-populates
the profiles table for kind-0 events. Custom backfill events flow through
the same pipeline.
Implementation Steps
-
Schema migration — Add
caching_custom_backfill_jobsandcaching_custom_backfill_batchestables tosrc/pg_schema.sql. -
Admin API — Create
admin/api/custom_backfill.phpwith POST (create/cancel) and GET (list) handlers. The create handler resolves author sources, buildsfilter_json, determines batch mode, and creates batch rows. -
Admin UI — Add the custom backfill form + preset buttons + job status table to the Backfill section in
admin/index.phpand handlers inadmin/assets/app.js. -
pg_inbox functions — Add the claim/progress/update functions to
caching/src/pg_inbox.candcaching/src/pg_inbox.h. -
Custom backfill module — Create
caching/src/custom_backfill.cand.hwith the tick logic. Handles all three batch modes (authors/ids/none). -
Main loop integration — Add the custom backfill tick to
caching/src/main.c. -
Build & test — Rebuild with
./build_static.sh, start relay, submit jobs via the UI:- Test "Backfill missing profiles" (author-batched)
- Test "Cache all kind-0 from date range" (time-window)
- Verify profiles table populates and job status updates
Safety Considerations
- Throttle: Custom backfill respects the existing
caching_backfill_tick_interval_ms(5s default) to avoid hammering upstream relays. - One job at a time: Only one custom backfill job runs at a time;
others stay
pendinguntil the active one completes. - Cancellation: The daemon checks job status between batches; a cancelled job stops within one batch.
- Dedup: The
caching_event_inboxunique constraint onevent_idprevents duplicate inserts. Theeventstable primary key does the same at the relay level. - Author list size: For
all_in_db(31,994 pubkeys), batching at 100 authors/REQ = 320 batches × 5s = ~27 minutes. Acceptable for a one-time job. - Time-window mode: One REQ per relay. For 4 bootstrap relays = 4 batches × 5s = 20 seconds. Very fast.
- Relay limits: Some relays cap
authorsarray length. The batch size (default 100) stays under common limits. UI warns if batch size > 500. - Large time windows: A time-window scan with no limit could return
huge result sets. The UI should warn if no limit is set and kinds include
high-volume kinds (like 1). The daemon enforces a
caching_max_event_json_bytescheck per event (existing config).
Future Extensions
- Scheduled jobs: Run a job on a recurring schedule (e.g., "backfill
missing profiles every hour"). Add a
schedule_croncolumn. - Job templates: Save a job configuration as a reusable template.
- Dry run: Show the resolved author list, batch count, and estimated time before submitting.
- Progress streaming: WebSocket or SSE for real-time batch progress instead of polling.
- Per-relay parallelism: Run batches against different relays concurrently instead of sequentially.