12 KiB
Backfill v2: Relay-by-Relay Per-Author Until-Cursor Drain
Overview
Refine the per-author until-cursor backfill to query each relay one at a time with per-relay cursor tracking, and pick up new follows immediately when the admin's kind-3 contact list changes.
Problem with the current implementation
The current backfill (caching/src/backfill.c) calls nostr_relay_pool_query_sync
with all relay URLs at once. This function:
- Sends the same REQ to all connected relays
- Collects and deduplicates events from all relays into one merged set
- Waits until ALL relays send EOSE, or the timeout (now 30s) expires
- Returns the merged set — but does not tell the caller whether it exited via all-EOSE or timeout
If one relay is slow (e.g. nos.lol streaming 500+ events), the timeout can expire before that relay sends EOSE. The result is a partial event set, but the backfill code can't distinguish "partial due to timeout" from "complete because the author only has 225 events." This causes premature completion marking.
Additionally, the current design has a single until_cursor per author in
caching_followed_pubkeys, which doesn't track per-relay progress. And new
follows are only discovered on the 10-minute follow-graph refresh cycle.
Solution
1. Per-relay cursor tracking
Add a new table to track the backfill cursor for each author on each relay independently:
CREATE TABLE IF NOT EXISTS caching_backfill_relay_progress (
author_pubkey TEXT NOT NULL,
relay_url TEXT NOT NULL,
until_cursor BIGINT NOT NULL DEFAULT 0,
complete BOOLEAN NOT NULL DEFAULT FALSE,
events_fetched INTEGER NOT NULL DEFAULT 0,
updated_at BIGINT NOT NULL DEFAULT EXTRACT(EPOCH FROM NOW())::BIGINT,
PRIMARY KEY (author_pubkey, relay_url)
);
CREATE INDEX IF NOT EXISTS idx_caching_relay_progress_incomplete
ON caching_backfill_relay_progress(author_pubkey)
WHERE complete = FALSE;
until_cursor: theuntiltimestamp for the next query to this specific relay for this author.0means "not started" (treat asnow).complete: set true when this relay returns < page_size events for the current cursor range (this relay is drained for this author).events_fetched: cumulative count for diagnostics.
The existing caching_followed_pubkeys.backfill_complete becomes a derived
value: an author is complete when all their relay progress rows are complete
(or when they have no relay progress rows at all — no relays to query).
2. Relay-by-relay querying
Instead of one query_sync call with N relays, make N query_sync calls
with 1 relay each. Each call waits for that single relay's EOSE (or timeout).
No merge/dedup needed — publish every event directly to the inbox; the
database handles deduplication (ON CONFLICT DO NOTHING).
3. Immediate new-follow detection
When the admin publishes a new kind-3 contact list, the live subscriber's
on_event callback receives it in real-time. We detect this and trigger an
immediate follow-graph refresh, which:
- Resolves the new follow set
- Syncs new pubkeys to
caching_followed_pubkeys - Creates relay progress rows for new follows (cursor=0, complete=false)
- The backfill loop picks them up on the next tick
Algorithm
Per backfill tick:
1. Pick next author that has at least one incomplete relay progress row
(round-robin from caching_followed_pubkeys WHERE backfill_complete=FALSE)
2. Get incomplete relays for this author:
SELECT relay_url, until_cursor FROM caching_backfill_relay_progress
WHERE author_pubkey = $1 AND complete = FALSE
3. For each incomplete relay (one at a time):
a. cursor = until_cursor (0 = now)
b. query_sync(pool, &relay_url, 1, filter, &ev_count, 30000)
filter = {authors: [pk], kinds: [configured], since: 0,
until: cursor, limit: 500}
c. Publish every event directly to inbox (cr_sink_publish)
d. Find oldest_ts in this batch
e. If ev_count == 0:
- Mark this relay complete for this author
(UPDATE caching_backfill_relay_progress SET complete=TRUE)
f. If ev_count >= page_size (saturated):
- Advance this relay's cursor: cursor = oldest_ts - 1
(UPDATE caching_backfill_relay_progress SET until_cursor = oldest-1)
g. If ev_count < page_size but > 0:
- This relay is drained for this cursor range
- Advance cursor to oldest_ts - 1 AND mark complete
(the next query at this lower cursor would return 0 anyway,
but we mark complete to avoid an extra round-trip)
4. After querying all incomplete relays for this author:
Check if ALL relays for this author are now complete:
SELECT COUNT(*) WHERE author_pubkey=$1 AND complete=FALSE
If 0: mark author complete in caching_followed_pubkeys
Why per-relay cursors are better
-
Relay A has 500+ events, relay B has 50: Relay A gets paginated across multiple ticks (cursor walks back 500 at a time). Relay B is drained in one query and marked complete. The author stays incomplete until relay A is also drained. With a single shared cursor, we'd advance the cursor based on the oldest across both relays, potentially skipping events on relay A.
-
Resume after restart: each relay's cursor is persisted independently. On restart, we resume each relay exactly where it left off.
-
Relay goes offline: if a relay is unreachable, its progress row stays incomplete. The author stays incomplete. When the relay comes back, we resume querying it. No events are lost.
Completion criteria
An author is marked backfill_complete = TRUE in caching_followed_pubkeys
when ALL their relay progress rows in caching_backfill_relay_progress are
marked complete = TRUE. This means every known relay has been queried down
to the beginning of the author's history.
New follow detection
In caching/src/live_subscriber.c, the live_on_event callback currently
just publishes events. We add a check:
static void live_on_event(cJSON *event, const char *relay_url, void *user_data) {
cr_live_ctx_t *ctx = (cr_live_ctx_t *)user_data;
ctx->live->events_received++;
cr_sink_publish(ctx->sink, event);
/* Detect admin kind-3 (contact list) changes → trigger follow refresh */
cJSON *kind = cJSON_GetObjectItem(event, "kind");
cJSON *pubkey = cJSON_GetObjectItem(event, "pubkey");
if (kind && cJSON_IsNumber(kind) && kind->valuedouble == 3 &&
pubkey && cJSON_IsString(pubkey)) {
if (cr_follow_is_root(ctx->cfg, cJSON_GetStringValue(pubkey))) {
ctx->live->follow_graph_changed = 1; /* signal to main loop */
}
}
}
The main loop checks this flag each iteration:
if (live.follow_graph_changed) {
live.follow_graph_changed = 0;
/* Immediate follow-graph refresh */
cr_pubkey_set_t new_followed;
cr_pubkey_set_init(&new_followed);
if (cr_follow_resolve(&cfg, upstream, &new_followed) == 0) {
/* Sync to DB — new follows get relay progress rows automatically */
pg_inbox_sync_followed_pubkeys(...);
/* Create relay progress rows for new follows */
pg_inbox_init_relay_progress_for_new_follows(&new_followed, &relay_map);
/* Resubscribe live with new follow set */
cr_live_resubscribe(&live, &cfg, upstream, &new_followed, &sink);
cr_pubkey_set_free(&followed);
followed = new_followed;
}
}
Relay progress row creation
When a new follow is discovered, we need to create relay progress rows for it. The relays to query are determined by the relay discovery phase (NIP-65 outbox relays + bootstrap relays). New function:
int pg_inbox_init_relay_progress_for_author(const char *pk,
const char **relay_urls,
int relay_count);
For each relay URL, insert a row with until_cursor=0, complete=FALSE (if
not already present). This is called:
- After follow-graph resolution for new follows
- After relay discovery for newly discovered outbox relays
Database schema changes
New table: caching_backfill_relay_progress
(see SQL above)
caching_followed_pubkeys — unchanged
The until_cursor and backfill_complete columns remain. until_cursor
becomes less important (per-relay cursors are the source of truth), but we
keep it for backward compatibility and as a quick "has this author started"
flag. backfill_complete is updated when all relay progress rows are
complete.
New PG inbox functions
caching/src/pg_inbox.c + .h
/* Initialize relay progress rows for an author. Creates one row per relay
* with until_cursor=0, complete=FALSE. Skips rows that already exist. */
int pg_inbox_init_relay_progress_for_author(const char *pk,
const char **relay_urls,
int relay_count);
/* Pick the next author that has at least one incomplete relay progress row.
* Round-robin via round_cursor. Returns 0 on success, -1 if none. */
int pg_inbox_pick_next_author_with_incomplete_relays(char *out_pk, int pk_len,
int *round_cursor);
/* Get incomplete relays for an author. Returns a JSON array of
* {relay_url, until_cursor} objects. Caller frees. */
cJSON *pg_inbox_get_incomplete_relays(const char *pk);
/* Update relay progress: advance cursor and/or mark complete. */
int pg_inbox_update_relay_progress(const char *pk, const char *relay_url,
long until_cursor, int complete,
int events_this_page);
/* Check if all relays for an author are complete. Returns 1 if all complete,
* 0 if some incomplete, -1 on error. */
int pg_inbox_check_author_relays_complete(const char *pk);
/* Mark author complete in caching_followed_pubkeys if all relay progress
* rows are complete. Called after each relay update. */
int pg_inbox_check_and_mark_author_complete(const char *pk);
Changes
1. src/pg_schema.sql + src/pg_schema.h
Add the caching_backfill_relay_progress table and index.
2. caching/src/pg_inbox.c + .h
Add the new relay progress functions listed above.
3. caching/src/backfill.c — rewrite cr_backfill_tick
Replace the single query_author call with a relay-by-relay loop using
per-relay cursors from the database.
4. caching/src/live_subscriber.c — detect admin kind-3 changes
Add follow_graph_changed flag to cr_live_t. Set it in live_on_event
when a root npub publishes a kind-3.
5. caching/src/live_subscriber.h — add flag to struct
Add int follow_graph_changed; to cr_live_t.
6. caching/src/main.c — handle follow_graph_changed signal
Check live.follow_graph_changed each main loop iteration. If set, trigger
immediate follow-graph refresh + relay progress initialization for new
follows.
7. caching/src/main.c — create relay progress rows after relay discovery
After cr_relay_discovery_run(), call pg_inbox_init_relay_progress_for_author
for each followed pubkey with its discovered outbox relays + bootstrap relays.
Implementation order
- Schema: add
caching_backfill_relay_progresstable - PG inbox functions: relay progress CRUD
- Backfill rewrite: relay-by-relay loop with per-relay cursors
- Live subscriber: kind-3 detection +
follow_graph_changedflag - Main loop: handle
follow_graph_changed, create relay progress rows - Build and test
Testing
- Rebuild:
cd caching && make - Reset backfill: restart relay with
--reset-backfill - Monitor relay progress:
SELECT author_pubkey, relay_url, until_cursor, complete, events_fetched FROM caching_backfill_relay_progress ORDER BY author_pubkey, relay_url; - Monitor author completion:
SELECT pubkey, backfill_complete FROM caching_followed_pubkeys ORDER BY pubkey; - Compare coverage:
nak req -k 1 -a <pk> ws://localhost:7777vs upstream - Test new-follow detection: follow a new pubkey from the admin account,
verify it appears in
caching_followed_pubkeysand gets backfilled within seconds (not 10 minutes)