Compare commits

...
16 Commits
Author SHA1 Message Date
Laan Tungir a766f08738 v2.1.23 - Caching service: descriptive error reporting, retry limit, inbox poller fix, config reload fix, PHP admin page 2026-07-28 16:37:08 -04:00
Laan Tungir 23c37abe3a Update nostr_core_lib: increase receive timeout to 1000ms 2026-07-28 10:14:47 -04:00
Laan Tungir 5b3ccfd1f4 Update nostr_core_lib: handle NOTICE/CLOSED in synchronous_query 2026-07-28 09:39:22 -04:00
Laan Tungir 38a027e626 Update nostr_core_lib submodule: increase receive buffer to 256KB 2026-07-28 07:52:08 -04:00
Laan Tungir a042aca524 v2.1.22 - Added --start-caching and --reset-backfill CLI options, redirected caching_relay output to log file, increased backfill page size to 200 and max cap to 1000 for better completeness 2026-07-26 18:35:35 -04:00
Laan Tungir 20902e1c5d v2.1.21 - Fixed caching_service_binary_path to ./caching_relay (relative to relay CWD), added caching_relay build step to make_and_restart_relay.sh, added default caching config values (admin npub, 4 bootstrap relays, kinds 0 and 10002) 2026-07-26 14:56:16 -04:00
Laan Tungir 7233818da9 v2.1.20 - Added default caching config values: admin npub as root, 4 bootstrap relays, kinds 0 and 10002 added to default kinds list 2026-07-25 18:21:28 -04:00
Laan Tungir 86cbf7ff16 v2.1.19 - Consolidated caching_relay code into caching/ directory, switched launcher to PostgreSQL mode (-p pg_conn) so all config comes from the config table instead of a JSON config file 2026-07-25 16:27:56 -04:00
Laan Tungir 3035e71ca5 v2.1.18 - Fixed caching service integration: aligned caching_status response schema, fixed launcher to use -c config_path instead of --pg-conn, added caching service config fields to UI, replaced misleading not-implemented placeholder with accurate auth-failure messaging 2026-07-25 11:19:37 -04:00
Laan Tungir 179b160cf3 v2.1.17 - Verified api-worker LISTEN/NOTIFY monitoring on v2.1.16 relay (port 7777): subscriber-cadence, NOTIFY-reactive, and zero-overhead idle tests all pass 2026-07-25 10:17:30 -04:00
Laan Tungir aedd22436e v2.1.16 - Implemented api-worker timer + LISTEN/NOTIFY monitoring (Phases 1,2,5) and fixed thread-connection binding bug that stopped dashboard updates 2026-07-25 10:07:08 -04:00
Laan Tungir 8fdc362a8b v2.1.15 - Fix PG addressable d-tag derivation and replaceable/addressable transactional replace semantics 2026-05-01 19:01:06 -04:00
Laan Tungir 67cf3973c5 v2.1.14 - Fix PostgreSQL replaceable/addressable upsert semantics with NIP-01 conflict ordering 2026-05-01 14:38:57 -04:00
Laan Tungir c02ae2be5b v2.1.13 - Fix PostgreSQL startup detection for existing relays and make admin_pubkey parsing whitespace-tolerant 2026-04-03 16:36:56 -04:00
Laan Tungir fd3efdd01f v2.1.12 - Implement multi-admin auth cache and checks, add admin management commands, and add multi-admin integration test 2026-04-03 15:46:33 -04:00
Laan Tungir 232f93c16f v2.1.11 - Fix api-worker DB lifecycle and NIP test reliability: add dedicated worker DB connection, guard PG expiration cast in REQ path, correct NIP-45 baseline-aware expectation, and harden run_all_tests skip handling 2026-04-03 07:32:14 -04:00
120 changed files with 18186 additions and 13770 deletions
-3
View File
@@ -1,6 +1,3 @@
[submodule "nostr_core_lib"]
path = nostr_core_lib
url = https://git.laantungir.net/laantungir/nostr_core_lib.git
[submodule "c_utils_lib"]
path = c_utils_lib
url = ssh://git@git.laantungir.net:2222/laantungir/c_utils_lib.git
+2
View File
@@ -0,0 +1,2 @@
ADMIN_PUBKEY='6a04ab98d9e4774ad806e302dddeb63bea16b5cb5f223ee77478e861bb583eb3'
SERVER_PRIVKEY='1111111111111111111111111111111111111111111111111111111111111111'
View File
+4 -2
View File
@@ -91,12 +91,13 @@ RUN cd c_utils_lib && \
# Build nostr_core_lib with required NIPs (cached unless nostr_core_lib changes)
# Disable fortification in build.sh to prevent __*_chk symbol issues
# NIPs: 001(Basic), 006(Keys), 013(PoW), 017(DMs), 019(Bech32), 044(Encryption), 059(Gift Wrap - required by NIP-17)
# NIPs: 001(Basic), 004(Encryption), 006(Keys), 013(PoW), 017(DMs), 019(Bech32), 044(Encryption), 059(Gift Wrap - required by NIP-17)
# NIP-04 is required by nostr_signer.c (NIP-46 bunker signer uses NIP-04 encryption)
RUN cd nostr_core_lib && \
chmod +x build.sh && \
sed -i 's/CFLAGS="-Wall -Wextra -std=c99 -fPIC -O2"/CFLAGS="-U_FORTIFY_SOURCE -D_FORTIFY_SOURCE=0 -Wall -Wextra -std=c99 -fPIC -O2"/' build.sh && \
rm -f *.o *.a 2>/dev/null || true && \
./build.sh --nips=1,6,13,17,19,44,59
./build.sh --nips=1,4,6,13,17,19,44,59
# Copy c-relay-pg source files LAST (only this layer rebuilds on source changes)
COPY src/ /build/src/
@@ -130,6 +131,7 @@ RUN if [ "$DEBUG_BUILD" = "true" ]; then \
src/main.c src/config.c src/dm_admin.c src/request_validator.c \
src/nip009.c src/nip011.c src/nip013.c src/nip040.c src/nip042.c \
src/websockets.c src/subscriptions.c src/api.c src/embedded_web_content.c src/ip_ban.c \
src/caching_inbox_poller.c src/caching_service_launcher.c \
src/db_ops.c src/db_ops_sqlite.c src/db_ops_postgres.c src/thread_pool.c \
-o /build/c_relay_pg_static \
c_utils_lib/libc_utils.a \
+2 -2
View File
@@ -2,7 +2,7 @@
CC = gcc
CFLAGS = -Wall -Wextra -std=c99 -g -O2
INCLUDES = -I. -Ic_utils_lib/src -Inostr_core_lib -Inostr_core_lib/nostr_core -Inostr_core_lib/cjson -Inostr_core_lib/nostr_websocket
INCLUDES = -I. -Ic_utils_lib/src -Inostr_core_lib -Inostr_core_lib/nostr_core -Inostr_core_lib/cjson -Inostr_core_lib/nostr_websocket -I/usr/include/postgresql
LIBS = -lsqlite3 -lwebsockets -lz -ldl -lpthread -lm -L/usr/local/lib -lsecp256k1 -lssl -lcrypto -L/usr/local/lib -lcurl -Lc_utils_lib -lc_utils
DB_BACKEND ?= sqlite
@@ -11,7 +11,7 @@ DB_BACKEND ?= sqlite
BUILD_DIR = build
# Source files
MAIN_SRC = src/main.c src/config.c src/dm_admin.c src/request_validator.c src/nip009.c src/nip011.c src/nip013.c src/nip040.c src/nip042.c src/websockets.c src/subscriptions.c src/api.c src/embedded_web_content.c src/ip_ban.c src/thread_pool.c
MAIN_SRC = src/main.c src/config.c src/dm_admin.c src/request_validator.c src/nip009.c src/nip011.c src/nip013.c src/nip040.c src/nip042.c src/websockets.c src/subscriptions.c src/api.c src/embedded_web_content.c src/ip_ban.c src/thread_pool.c src/caching_inbox_poller.c src/caching_service_launcher.c
DB_OPS_SRC = src/db_ops.c
ifeq ($(DB_BACKEND),postgres)
+133
View File
@@ -0,0 +1,133 @@
# C-Relay-PG PHP Admin Page
A traditional PHP admin interface for the caching service that connects
directly to PostgreSQL, bypassing the NIP-44 64KB encryption limit of the
Nostr admin API. Handles thousands of followed pubkeys via server-side
pagination.
## Quick Start
### 1. Install PHP + PHP-FPM + PostgreSQL extension
```bash
# Debian/Ubuntu
sudo apt install php-fpm php-pgsql
# RHEL/Fedora/Alma
sudo dnf install php-fpm php-pgsql
```
### 2. Copy admin files to the server
```bash
sudo mkdir -p /opt/c-relay-pg/admin
sudo cp -r admin/* /opt/c-relay-pg/admin/
```
### 3. Configure database credentials
Edit `/opt/c-relay-pg/admin/lib/config.php` and set the PostgreSQL
connection parameters to match your relay's `--db-*` flags:
```php
return [
'db_host' => 'localhost',
'db_port' => '5432',
'db_name' => 'crelay',
'db_user' => 'crelay',
'db_password' => 'crelay',
// ...
];
```
### 4. Set up HTTP Basic Auth
```bash
sudo htpasswd -c /opt/c-relay-pg/admin/.htpasswd admin
# Enter a password when prompted
```
### 5. Add nginx location block
Add this to your existing nginx server block (the one that proxies to
the relay on port 8888):
```nginx
# PHP admin page for caching service
location /admin/ {
alias /opt/c-relay-pg/admin/;
index index.php;
auth_basic "Relay Admin";
auth_basic_user_file /opt/c-relay-pg/admin/.htpasswd;
location ~ \.php$ {
fastcgi_pass unix:/run/php/php-fpm.sock;
fastcgi_index index.php;
include fastcgi_params;
fastcgi_param SCRIPT_FILENAME $request_filename;
}
# Deny access to the lib/ directory (contains DB credentials)
location ^~ /admin/lib/ {
deny all;
}
}
```
> **Note:** The `fastcgi_pass` socket path varies by distro:
> - Debian/Ubuntu: `unix:/run/php/php8.2-fpm.sock`
> - RHEL/Fedora: `unix:/run/php-fpm/www.sock`
>
> Check with: `ls /run/php/` or `ls /run/php-fpm/`
### 6. Reload nginx
```bash
sudo nginx -t && sudo systemctl reload nginx
```
### 7. Visit the admin page
```
https://relay.yourdomain.com/admin/
```
Enter the HTTP Basic Auth credentials you set in step 4.
## Pages
| Page | URL | Description |
|------|-----|-------------|
| Dashboard | `/admin/` | Service state, backfill progress, error summary, active target |
| Follows | `/admin/follows.php` | Paginated followed-pubkey table with names, event counts, relay status |
| Relays | `/admin/relays.php` | Per (author, relay) backfill progress with descriptive error statuses |
| Config | `/admin/config-edit.php` | Read/edit caching config values; bumps config generation on save |
| Inbox | `/admin/inbox.php` | Queue depth monitor for `caching_event_inbox` |
## Security
- **HTTP Basic Auth** protects all pages (nginx `auth_basic`)
- **`lib/` directory denied** via nginx (contains DB credentials)
- **PDO prepared statements** everywhere (no SQL injection)
- **HTTPS** via existing nginx SSL config
- **No CORS needed** — same origin as the relay
## How It Works
The PHP pages query PostgreSQL directly using PDO with the same
`crelay` database user the relay uses. The dashboard auto-refreshes
every 10 seconds via AJAX polling of `api/status.php`. Paginated tables
(follows, relays) use server-side pagination with `LIMIT`/`OFFSET`.
The config editor writes to the `config` table and bumps
`caching_config_generation`, which the caching service detects and
hot-reloads — no relay restart needed.
## Upgrading to Real-Time (Future)
If you later need true push updates (sub-second), you can add a small
Node.js WebSocket server alongside the PHP pages that uses PostgreSQL
`LISTEN/NOTIFY` to push updates to connected browsers. The PHP pages
and JSON endpoints are reusable — the WebSocket server would just
replace the polling `fetch()` calls in the JavaScript.
+69
View File
@@ -0,0 +1,69 @@
<?php
/**
* C-Relay-PG Admin — JSON status endpoint for dashboard polling.
*
* Returns all dashboard stats in a single JSON object.
* Called by index.php's JavaScript every 10 seconds.
*/
require_once __DIR__ . '/../lib/helpers.php';
$pdo = db();
// Service state
$state = $pdo->query("SELECT * FROM caching_service_state WHERE id = 1")->fetch();
// Active backfill target (table may not exist yet on fresh start)
$active = ['active_pubkey' => '', 'active_relay' => ''];
try {
$active = $pdo->query("SELECT active_pubkey, active_relay FROM caching_backfill_active WHERE id = 1")->fetch() ?: $active;
} catch (PDOException $e) { /* table doesn't exist yet */ }
// Error relay summary (consecutive_errors column may not exist yet on fresh start)
$error_stats = ['auto_completed' => 0, 'error_count' => 0, 'timeout_count' => 0];
try {
$error_stats = $pdo->query("
SELECT
COUNT(*) FILTER (WHERE consecutive_errors >= 3) AS auto_completed,
COUNT(*) FILTER (WHERE last_status LIKE 'error%') AS error_count,
COUNT(*) FILTER (WHERE last_status = 'timeout') AS timeout_count
FROM caching_backfill_relay_progress
")->fetch() ?: $error_stats;
} catch (PDOException $e) { /* column/table doesn't exist yet */ }
// Inbox stats
$inbox = $pdo->query("
SELECT
COUNT(*) AS pending,
COUNT(*) FILTER (WHERE source_class = 'live') AS live,
COUNT(*) FILTER (WHERE source_class = 'backfill') AS backfill
FROM caching_event_inbox
")->fetch();
// Event count
$events = $pdo->query("SELECT COUNT(*) AS total FROM events")->fetch();
// Heartbeat age
$hb_age = intval($state['heartbeat_at'] ?? 0);
$hb_ago = $hb_age > 0 ? time() - $hb_age : 0;
json_response([
'service_state' => $state['service_state'] ?? 'unknown',
'heartbeat_ago' => $hb_ago > 0 ? $hb_ago . 's ago' : 'never',
'config_generation' => intval($state['config_generation'] ?? 0),
'followed_count' => intval($state['followed_author_count'] ?? 0),
'selected_relays' => intval($state['selected_relay_count'] ?? 0),
'connected_relays' => intval($state['connected_relay_count'] ?? 0),
'bf_complete' => intval($state['backfill_authors_complete'] ?? 0),
'bf_total' => intval($state['backfill_authors_total'] ?? 0),
'events_fetched' => intval($state['events_fetched'] ?? 0),
'inbox_inserts' => intval($state['inbox_inserts'] ?? 0),
'db_events' => intval($events['total'] ?? 0),
'inbox_pending' => intval($inbox['pending'] ?? 0),
'inbox_live' => intval($inbox['live'] ?? 0),
'inbox_backfill' => intval($inbox['backfill'] ?? 0),
'error_count' => intval($error_stats['error_count'] ?? 0),
'timeout_count' => intval($error_stats['timeout_count'] ?? 0),
'auto_completed' => intval($error_stats['auto_completed'] ?? 0),
'active_pubkey' => $active['active_pubkey'] ?? '',
'active_relay' => $active['active_relay'] ?? '',
]);
+419
View File
@@ -0,0 +1,419 @@
/*
* C-Relay-PG Admin — PHP caching service admin pages.
* Styled to match the original api/index.css Nostr admin interface.
* Courier New monospace, black/white/red color scheme, dark mode by default.
*/
:root {
/* Core Variables — matches api/index.css light mode (default) */
--primary-color: #000000;
--secondary-color: #ffffff;
--accent-color: #ff0000;
--muted-color: #dddddd;
--border-color: var(--muted-color);
--font-family: "Courier New", Courier, monospace;
--border-radius: 5px;
--border-width: 1px;
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: var(--font-family);
background-color: var(--secondary-color);
color: var(--primary-color);
padding: 0;
max-width: 1200px;
margin: 0 auto;
}
a {
color: var(--primary-color);
text-decoration: none;
}
a:hover { color: var(--accent-color); }
/* ================================
SIDE NAVIGATION (matches original)
================================ */
.side-nav {
position: fixed;
top: 0;
left: -300px;
width: 280px;
height: 100vh;
background: var(--secondary-color);
border-right: var(--border-width) solid var(--border-color);
z-index: 1000;
transition: left 0.3s ease;
overflow-y: auto;
padding-top: 80px;
}
.side-nav.open { left: 0; }
.side-nav-overlay {
position: fixed;
top: 0; left: 0; width: 100%; height: 100%;
background: rgba(0, 0, 0, 0.5);
z-index: 999;
display: none;
}
.side-nav-overlay.show { display: block; }
.nav-menu { list-style: none; padding: 0; margin: 0; }
.nav-menu li { border-bottom: var(--border-width) solid var(--muted-color); }
.nav-menu li:last-child { border-bottom: none; }
.nav-item {
display: block;
padding: 15px 20px;
color: var(--primary-color);
text-decoration: none;
font-family: var(--font-family);
font-size: 16px;
font-weight: bold;
transition: all 0.2s ease;
cursor: pointer;
border: 2px solid var(--secondary-color);
background: none;
width: 100%;
text-align: left;
}
.nav-item:hover {
background: var(--muted-color);
color: var(--accent-color);
}
.nav-item.active {
text-decoration: underline;
padding-left: 16px;
}
.nav-footer {
position: absolute;
bottom: 20px; left: 0; right: 0;
padding: 0 20px;
}
.nav-footer-btn {
display: block;
width: 100%;
padding: 12px 20px;
margin-bottom: 8px;
color: var(--primary-color);
border: 1px solid var(--border-color);
border-radius: 4px;
font-family: var(--font-family);
font-size: 14px;
font-weight: bold;
cursor: pointer;
transition: all 0.2s ease;
background: none;
}
.nav-footer-btn:hover {
background: var(--muted-color);
border-color: var(--accent-color);
}
/* ================================
HEADER (matches original)
================================ */
.main-header {
background-color: var(--secondary-color);
padding: 15px 20px;
z-index: 100;
max-width: 1200px;
margin: 0 auto;
}
.header-content {
display: flex;
justify-content: space-between;
align-items: center;
position: relative;
}
.header-title {
margin: 0;
font-size: 24px;
font-weight: bolder;
color: var(--primary-color);
cursor: pointer;
transition: all 0.2s ease;
}
.header-title:hover { opacity: 0.8; }
.header-title .relay-letter { display: inline-block; }
.menu-btn {
background: none;
border: var(--border-width) solid var(--border-color);
border-radius: var(--border-radius);
color: var(--primary-color);
font-family: var(--font-family);
font-size: 20px;
padding: 5px 12px;
cursor: pointer;
width: auto;
margin: 0;
}
.menu-btn:hover { border-color: var(--accent-color); }
/* ================================
SECTIONS (matches original)
================================ */
.section {
background: var(--secondary-color);
border: var(--border-width) solid var(--border-color);
border-radius: var(--border-radius);
padding: 20px;
margin-bottom: 20px;
margin-left: 5px;
margin-right: 5px;
}
.section-header {
display: flex;
justify-content: center;
align-items: center;
padding-bottom: 15px;
font-size: 16px;
font-weight: normal;
font-family: var(--font-family);
color: var(--primary-color);
}
/* ================================
TABLES (matches .config-table)
================================ */
.config-table {
border: 1px solid var(--border-color);
border-radius: var(--border-radius);
width: 100%;
border-collapse: separate;
border-spacing: 0;
margin: 10px 0;
overflow: hidden;
}
.config-table th,
.config-table td {
border: 0.1px solid var(--muted-color);
padding: 4px 8px;
text-align: left;
font-family: var(--font-family);
font-size: 10px;
}
.config-table th {
font-weight: bold;
height: 24px;
line-height: 24px;
}
.config-table tbody tr:hover {
background-color: rgba(0, 0, 0, 0.05);
}
.config-table-container {
overflow-x: auto;
max-width: 100%;
}
/* ================================
INPUTS / BUTTONS (matches original)
================================ */
input, textarea, select {
width: 100%;
padding: 8px;
background: var(--secondary-color);
color: var(--primary-color);
border: var(--border-width) solid var(--border-color);
border-radius: var(--border-radius);
font-family: var(--font-family);
font-size: 14px;
box-sizing: border-box;
transition: all 0.2s ease;
}
input:focus, textarea:focus, select:focus {
border-color: var(--accent-color);
outline: none;
}
button {
width: 100%;
padding: 8px;
background: var(--secondary-color);
color: var(--primary-color);
border: var(--border-width) solid var(--border-color);
border-radius: var(--border-radius);
font-family: var(--font-family);
font-size: 14px;
cursor: pointer;
margin: 5px 0;
font-weight: bold;
transition: all 0.2s ease;
}
button:hover { border-color: var(--accent-color); }
button:active {
background: var(--accent-color);
color: var(--secondary-color);
}
/* ================================
STAT CARDS (dashboard)
================================ */
.cards {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
gap: 10px;
margin-bottom: 20px;
}
.card {
background: var(--secondary-color);
border: var(--border-width) solid var(--border-color);
border-radius: var(--border-radius);
padding: 12px;
}
.card .label {
color: var(--muted-color);
font-size: 10px;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.card .value {
font-size: 20px;
font-weight: bold;
margin-top: 4px;
}
.card .sub {
color: var(--muted-color);
font-size: 10px;
margin-top: 4px;
}
/* ================================
STATUS BADGES
================================ */
.badge {
display: inline-block;
padding: 2px 6px;
border-radius: 3px;
font-size: 10px;
font-weight: bold;
border: var(--border-width) solid var(--border-color);
}
.badge-success { border-color: #4caf50; color: #4caf50; }
.badge-error { border-color: var(--accent-color); color: var(--accent-color); }
.badge-warning { border-color: #ff9800; color: #ff9800; }
.badge-muted { border-color: var(--muted-color); color: var(--muted-color); }
/* ================================
STATUS INDICATORS
================================ */
.status-working { color: var(--accent-color); }
.status-complete { color: #4caf50; }
.status-error { color: var(--accent-color); }
/* ================================
PAGINATION
================================ */
.pagination {
display: flex;
justify-content: space-between;
align-items: center;
padding: 12px 0;
gap: 16px;
}
.pagination a {
padding: 6px 14px;
background: var(--secondary-color);
border: var(--border-width) solid var(--border-color);
border-radius: var(--border-radius);
color: var(--primary-color);
font-size: 14px;
width: auto;
}
.pagination a:hover {
border-color: var(--accent-color);
color: var(--accent-color);
text-decoration: none;
}
.pagination span { color: var(--muted-color); font-size: 12px; }
/* ================================
FILTERS BAR
================================ */
.filters {
display: flex;
gap: 10px;
margin-bottom: 16px;
flex-wrap: wrap;
align-items: center;
}
.filters select, .filters input {
width: auto;
min-width: 120px;
}
.filters input[type="text"] { min-width: 250px; }
.filters button {
width: auto;
margin: 0;
min-width: 80px;
}
/* ================================
CONFIG EDITOR FORM
================================ */
.config-form .row {
display: grid;
grid-template-columns: 250px 1fr;
gap: 12px;
padding: 12px 0;
border-bottom: 0.1px solid var(--muted-color);
align-items: start;
}
.config-form .row label {
font-weight: bold;
color: var(--primary-color);
font-size: 12px;
}
.config-form .row .hint {
color: var(--muted-color);
font-size: 10px;
margin-top: 4px;
}
.config-form button {
width: auto;
margin-top: 16px;
padding: 10px 24px;
}
/* ================================
MISC
================================ */
.pubkey {
font-family: var(--font-family);
font-size: 10px;
color: var(--muted-color);
}
.npub-link {
font-family: var(--font-family);
font-size: 10px;
color: var(--primary-color);
text-decoration: none;
}
.npub-link:hover { color: var(--accent-color); }
h2 {
font-weight: normal;
text-align: center;
font-size: 16px;
font-family: var(--font-family);
color: var(--primary-color);
margin-bottom: 16px;
}
/* ================================
RESPONSIVE
================================ */
@media (max-width: 768px) {
.cards { grid-template-columns: 1fr 1fr; }
.config-form .row { grid-template-columns: 1fr; }
.filters { flex-direction: column; align-items: stretch; }
.filters select, .filters input, .filters button { width: 100%; }
}
+99
View File
@@ -0,0 +1,99 @@
<?php
/**
* C-Relay-PG Admin — Caching Config Editor.
* On save, bumps caching_config_generation so the caching service hot-reloads.
*/
require_once __DIR__ . '/lib/helpers.php';
$pdo = db();
$message = '';
$error = '';
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$fields = [
'caching_root_npubs', 'caching_bootstrap_relays', 'caching_kinds',
'caching_admin_kinds', 'caching_live_enabled', 'caching_backfill_enabled',
'caching_backfill_page_size', 'caching_backfill_tick_interval_ms',
'caching_follow_graph_refresh_seconds', 'caching_relay_discovery_refresh_seconds',
'caching_max_followed_pubkeys', 'caching_max_upstream_relays',
'caching_max_relays_per_pubkey', 'caching_query_timeout_ms',
'caching_live_resubscribe_seconds',
];
try {
$pdo->beginTransaction();
foreach ($fields as $key) {
$val = $_POST[$key] ?? null;
if ($val === null) continue;
$pdo->prepare("UPDATE config SET value = ? WHERE key = ?")->execute([$val, $key]);
}
$gen = $pdo->query("SELECT value FROM config WHERE key = 'caching_config_generation'")->fetchColumn();
$new_gen = intval($gen) + 1;
$pdo->prepare("UPDATE config SET value = ? WHERE key = 'caching_config_generation'")->execute([$new_gen]);
$pdo->commit();
$message = "Configuration saved. Config generation bumped to $new_gen. The caching service will reload automatically.";
} catch (Exception $e) {
$pdo->rollBack();
$error = 'Failed to save: ' . $e->getMessage();
}
}
$config_keys = [
'caching_root_npubs' => 'Root npubs (comma-separated npub... values)',
'caching_bootstrap_relays' => 'Bootstrap relays (comma-separated wss://... URLs)',
'caching_kinds' => 'Kinds to cache (comma-separated integers)',
'caching_admin_kinds' => 'Admin kinds (* for all, or comma-separated)',
'caching_live_enabled' => 'Live subscription enabled (true/false)',
'caching_backfill_enabled' => 'Backfill enabled (true/false)',
'caching_backfill_page_size' => 'Backfill page size (events per tick)',
'caching_backfill_tick_interval_ms' => 'Backfill tick interval (ms)',
'caching_follow_graph_refresh_seconds' => 'Follow graph refresh interval (seconds)',
'caching_relay_discovery_refresh_seconds' => 'Relay discovery refresh (seconds)',
'caching_max_followed_pubkeys' => 'Max followed pubkeys',
'caching_max_upstream_relays' => 'Max upstream relays',
'caching_max_relays_per_pubkey' => 'Max relays per pubkey',
'caching_query_timeout_ms' => 'Query timeout (ms)',
'caching_live_resubscribe_seconds' => 'Live resubscribe interval (seconds)',
];
$values = [];
foreach (array_keys($config_keys) as $key) {
$stmt = $pdo->prepare("SELECT value FROM config WHERE key = ?");
$stmt->execute([$key]);
$values[$key] = $stmt->fetchColumn() ?: '';
}
admin_header('config', 'C-Relay-PG Admin — Config');
?>
<div class="section">
<div class="section-header">CACHING CONFIGURATION</div>
<?php if ($message): ?>
<p class="status-complete" style="padding:10px;border:1px solid #4caf50;border-radius:5px;margin-bottom:16px">✓ <?= e($message) ?></p>
<?php endif; ?>
<?php if ($error): ?>
<p class="status-error" style="padding:10px;border:1px solid var(--accent-color);border-radius:5px;margin-bottom:16px">✗ <?= e($error) ?></p>
<?php endif; ?>
<form class="config-form" method="post">
<?php foreach ($config_keys as $key => $label): ?>
<div class="row">
<div>
<label for="<?= e($key) ?>"><?= e($label) ?></label>
<div class="hint"><?= e($key) ?></div>
</div>
<div>
<input type="text" id="<?= e($key) ?>" name="<?= e($key) ?>" value="<?= e($values[$key]) ?>">
</div>
</div>
<?php endforeach; ?>
<button type="submit">Save & Apply (bumps config generation)</button>
</form>
<p style="margin-top:16px;color:var(--muted-color);font-size:10px">
Saving bumps <code>caching_config_generation</code> so the caching service
detects the change and hot-reloads. No relay restart needed.
</p>
</div>
<?php admin_footer(); ?>
+153
View File
@@ -0,0 +1,153 @@
<?php
/**
* C-Relay-PG Admin — Followed Pubkeys (paginated).
* Replaces the NIP-44-encrypted caching_follows_status admin command.
*/
require_once __DIR__ . '/lib/helpers.php';
$pdo = db();
$page = current_page();
$per = cfg('follows_per_page', 50);
$offset = ($page - 1) * $per;
$filter = query_param('filter', 'all');
$search = query_param('search', '');
$sort = query_param('sort', 'events');
$where = [];
$params = [];
if ($filter === 'incomplete') {
$where[] = 'fp.backfill_complete = false';
} elseif ($filter === 'root') {
$where[] = 'fp.is_root = true';
} elseif ($filter === 'error') {
$where[] = "EXISTS (SELECT 1 FROM caching_backfill_relay_progress rp WHERE rp.author_pubkey = fp.pubkey AND rp.last_status LIKE 'error%')";
}
if ($search) {
$where[] = '(fp.pubkey ILIKE ? OR e.content::json->>\'name\' ILIKE ? OR e.content::json->>\'display_name\' ILIKE ?)';
$params[] = "%$search%"; $params[] = "%$search%"; $params[] = "%$search%";
}
$where_sql = $where ? 'WHERE ' . implode(' AND ', $where) : '';
$sort_map = [
'events' => 'fp.events_fetched DESC',
'name' => "COALESCE(e.content::json->>'display_name', e.content::json->>'name') ASC NULLS LAST",
'recent' => 'fp.last_seen DESC',
'complete' => 'fp.backfill_complete ASC, fp.events_fetched DESC',
];
$sort_sql = $sort_map[$sort] ?? $sort_map['events'];
$stmt = $pdo->prepare("SELECT COUNT(*) FROM caching_followed_pubkeys fp LEFT JOIN LATERAL (SELECT content FROM events WHERE pubkey = fp.pubkey AND kind = 0 ORDER BY created_at DESC LIMIT 1) e ON true $where_sql");
$stmt->execute($params);
$total = intval($stmt->fetchColumn());
$sql = "
SELECT fp.pubkey, fp.is_root, fp.backfill_complete, fp.events_fetched,
fp.first_seen, fp.last_seen,
e.content::json->>'name' AS name,
e.content::json->>'display_name' AS display_name,
e.content::json->>'picture' AS picture,
e.content::json->>'nip05' AS nip05,
(SELECT COUNT(*) FROM events WHERE pubkey = fp.pubkey) AS total_events
FROM caching_followed_pubkeys fp
LEFT JOIN LATERAL (SELECT content FROM events WHERE pubkey = fp.pubkey AND kind = 0 ORDER BY created_at DESC LIMIT 1) e ON true
$where_sql
ORDER BY $sort_sql
LIMIT $per OFFSET $offset
";
$stmt = $pdo->prepare($sql);
$stmt->execute($params);
$follows = $stmt->fetchAll();
admin_header('follows', 'C-Relay-PG Admin — Follows');
?>
<div class="section">
<div class="section-header">FOLLOWED PUBKEYS (<?= number_format($total) ?> total)</div>
<form class="filters" method="get">
<select name="filter" onchange="this.form.submit()">
<option value="all" <?= $filter==='all'?'selected':'' ?>>All</option>
<option value="incomplete" <?= $filter==='incomplete'?'selected':'' ?>>Incomplete only</option>
<option value="root" <?= $filter==='root'?'selected':'' ?>>Root only</option>
<option value="error" <?= $filter==='error'?'selected':'' ?>>Has error relays</option>
</select>
<select name="sort" onchange="this.form.submit()">
<option value="events" <?= $sort==='events'?'selected':'' ?>>Sort: Events fetched</option>
<option value="name" <?= $sort==='name'?'selected':'' ?>>Sort: Name</option>
<option value="recent" <?= $sort==='recent'?'selected':'' ?>>Sort: Recently seen</option>
<option value="complete" <?= $sort==='complete'?'selected':'' ?>>Sort: Backfill status</option>
</select>
<input type="text" name="search" value="<?= e($search) ?>" placeholder="Search name or pubkey…">
<button type="submit">Search</button>
<?php if ($filter !== 'all' || $search): ?>
<a href="follows.php" style="font-size:12px">Clear</a>
<?php endif; ?>
</form>
<div class="config-table-container">
<table class="config-table">
<thead>
<tr>
<th>Name</th>
<th>npub</th>
<th>Root?</th>
<th>Events in DB</th>
<th>Backfill</th>
<th>Last Seen</th>
<th>Relays</th>
</tr>
</thead>
<tbody>
<?php foreach ($follows as $f):
$npub = hex_to_npub($f['pubkey']);
$name = $f['display_name'] ?: $f['name'];
$rc = ['total' => 0, 'incomplete' => 0, 'errors' => 0];
try {
$relay_count = $pdo->prepare("SELECT COUNT(*) AS total, COUNT(*) FILTER (WHERE complete = false) AS incomplete, COUNT(*) FILTER (WHERE last_status LIKE 'error%') AS errors FROM caching_backfill_relay_progress WHERE author_pubkey = ?");
$relay_count->execute([$f['pubkey']]);
$rc = $relay_count->fetch() ?: $rc;
} catch (PDOException $ex) {}
?>
<tr>
<td>
<?php if ($name): ?>
<strong><?= e($name) ?></strong>
<?php if ($f['nip05']): ?>
<br><span style="color:var(--muted-color)">✓ <?= e($f['nip05']) ?></span>
<?php endif; ?>
<?php else: ?>
<span style="color:var(--muted-color);font-style:italic">unknown</span>
<?php endif; ?>
</td>
<td><a href="https://njump.me/<?= e($npub) ?>" target="_blank" class="npub-link"><?= e(trunc($npub, 22)) ?></a></td>
<td><?= $f['is_root'] ? '<span class="badge badge-warning">root</span>' : '' ?></td>
<td><?= number_format(intval($f['total_events'])) ?></td>
<td><?= $f['backfill_complete']
? '<span class="badge badge-success">✓ complete</span>'
: '<span class="badge badge-warning">in progress</span>' ?></td>
<td><?= time_ago(intval($f['last_seen'])) ?></td>
<td>
<?php if (intval($rc['total']) > 0): ?>
<?= intval($rc['total']) ?> relays (<?= intval($rc['incomplete']) ?> incomplete
<?php if (intval($rc['errors']) > 0): ?>
, <span class="status-error"><?= intval($rc['errors']) ?> errors</span>
<?php endif; ?>)
<br><a href="relays.php?pubkey=<?= e($f['pubkey']) ?>" style="font-size:10px">View relay details →</a>
<?php else: ?>
<span style="color:var(--muted-color)">—</span>
<?php endif; ?>
</td>
</tr>
<?php endforeach; ?>
<?php if (empty($follows)): ?>
<tr><td colspan="7" style="text-align:center;color:var(--muted-color);padding:20px">No followed pubkeys found.</td></tr>
<?php endif; ?>
</tbody>
</table>
</div>
<?= pagination($page, $per, $total, 'follows.php' . ($filter !== 'all' ? '?filter=' . e($filter) : '') . ($search ? '&search=' . e($search) : '') . ($sort !== 'events' ? '&sort=' . e($sort) : '')) ?>
</div>
<?php admin_footer(); ?>
+111
View File
@@ -0,0 +1,111 @@
<?php
/**
* C-Relay-PG Admin — Inbox Queue Monitor.
*/
require_once __DIR__ . '/lib/helpers.php';
$pdo = db();
$stats = $pdo->query("
SELECT
COUNT(*) AS pending,
COUNT(*) FILTER (WHERE source_class = 'live') AS live,
COUNT(*) FILTER (WHERE source_class = 'backfill') AS backfill,
COUNT(*) FILTER (WHERE source_class = 'discovery') AS discovery,
COUNT(*) FILTER (WHERE priority = 0) AS high_priority,
COALESCE(EXTRACT(EPOCH FROM NOW())::BIGINT - MIN(received_at), 0) AS oldest_age
FROM caching_event_inbox
")->fetch() ?: ['pending' => 0, 'live' => 0, 'backfill' => 0, 'discovery' => 0, 'high_priority' => 0, 'oldest_age' => 0];
$recent = $pdo->query("
SELECT event_id, event_json->>'pubkey' AS pubkey, event_json->>'kind' AS kind,
source_relay, source_class, priority, received_at
FROM caching_event_inbox
ORDER BY received_at DESC
LIMIT 20
")->fetchAll();
admin_header('inbox', 'C-Relay-PG Admin — Inbox');
?>
<div class="section">
<div class="section-header">INBOX QUEUE MONITOR</div>
<div class="cards">
<div class="card">
<div class="label">Pending Events</div>
<div class="value" id="inbox-pending"><?= number_format(intval($stats['pending'])) ?></div>
<div class="sub" id="inbox-detail"><?= intval($stats['live']) ?> live, <?= intval($stats['backfill']) ?> backfill, <?= intval($stats['discovery']) ?> discovery</div>
</div>
<div class="card">
<div class="label">High Priority</div>
<div class="value"><?= number_format(intval($stats['high_priority'])) ?></div>
<div class="sub">priority = 0 (live events)</div>
</div>
<div class="card">
<div class="label">Oldest Pending</div>
<div class="value" id="inbox-oldest"><?= intval($stats['oldest_age']) ?>s</div>
<div class="sub">age of oldest event in queue</div>
</div>
</div>
<?php if (intval($stats['oldest_age']) > 300): ?>
<p class="status-error" style="padding:10px;border:1px solid var(--accent-color);border-radius:5px;margin-bottom:16px">
⚠ Oldest pending event is <?= intval($stats['oldest_age']) ?>s old — the inbox poller may not be keeping up.
Check that <code>caching_inbox_enabled = true</code> in the config table and the relay is running.
</p>
<?php endif; ?>
</div>
<div class="section">
<div class="section-header">RECENT EVENTS IN QUEUE (last 20)</div>
<div class="config-table-container">
<table class="config-table">
<thead>
<tr>
<th>Event ID</th>
<th>Pubkey</th>
<th>Kind</th>
<th>Source Relay</th>
<th>Class</th>
<th>Priority</th>
<th>Received</th>
</tr>
</thead>
<tbody>
<?php foreach ($recent as $r): ?>
<tr>
<td class="pubkey"><?= e(trunc($r['event_id'], 16)) ?></td>
<td class="pubkey"><?= e(trunc($r['pubkey'], 16)) ?></td>
<td><?= e($r['kind']) ?></td>
<td><?= e($r['source_relay'] ?? '—') ?></td>
<td><span class="badge badge-muted"><?= e($r['source_class']) ?></span></td>
<td><?= intval($r['priority']) ?></td>
<td><?= time_ago(intval($r['received_at'])) ?></td>
</tr>
<?php endforeach; ?>
<?php if (empty($recent)): ?>
<tr><td colspan="7" style="text-align:center;color:var(--muted-color);padding:20px">Inbox is empty — all events have been dequeued.</td></tr>
<?php endif; ?>
</tbody>
</table>
</div>
</div>
<script>
async function refreshInbox() {
try {
const res = await fetch('api/status.php');
if (!res.ok) return;
const d = await res.json();
console.log('[inbox] refreshed at', new Date().toLocaleTimeString(), '— pending:', d.inbox_pending);
const p = document.getElementById('inbox-pending');
const det = document.getElementById('inbox-detail');
if (p) p.textContent = (d.inbox_pending ?? 0).toLocaleString();
if (det) det.textContent = (d.inbox_live ?? 0) + ' live, ' + (d.inbox_backfill ?? 0) + ' backfill';
} catch (e) {}
}
setInterval(refreshInbox, <?= cfg('refresh_ms', 10000) ?>);
</script>
<?php admin_footer(); ?>
+158
View File
@@ -0,0 +1,158 @@
<?php
/**
* C-Relay-PG Admin — Dashboard.
* Auto-refreshes every 10 seconds via AJAX polling of api/status.php.
*/
require_once __DIR__ . '/lib/helpers.php';
$pdo = db();
// Service state
$state = $pdo->query("SELECT * FROM caching_service_state WHERE id = 1")->fetch() ?: [];
// Active backfill target (table may not exist yet on fresh start)
$active = ['active_pubkey' => '', 'active_relay' => ''];
try {
$active = $pdo->query("SELECT active_pubkey, active_relay FROM caching_backfill_active WHERE id = 1")->fetch() ?: $active;
} catch (PDOException $e) {}
// Error relay summary
$error_stats = ['auto_completed' => 0, 'error_count' => 0, 'timeout_count' => 0, 'incomplete' => 0, 'total' => 0];
try {
$error_stats = $pdo->query("
SELECT
COUNT(*) FILTER (WHERE consecutive_errors >= 3) AS auto_completed,
COUNT(*) FILTER (WHERE last_status LIKE 'error%') AS error_count,
COUNT(*) FILTER (WHERE last_status = 'timeout') AS timeout_count,
COUNT(*) FILTER (WHERE complete = false) AS incomplete,
COUNT(*) AS total
FROM caching_backfill_relay_progress
")->fetch() ?: $error_stats;
} catch (PDOException $e) {}
// Inbox stats
$inbox = $pdo->query("
SELECT
COUNT(*) AS pending,
COUNT(*) FILTER (WHERE source_class = 'live') AS live,
COUNT(*) FILTER (WHERE source_class = 'backfill') AS backfill,
COALESCE(EXTRACT(EPOCH FROM NOW())::BIGINT - MIN(received_at), 0) AS oldest_age
FROM caching_event_inbox
")->fetch() ?: ['pending' => 0, 'live' => 0, 'backfill' => 0, 'oldest_age' => 0];
// Event counts
$event_stats = $pdo->query("SELECT COUNT(*) AS total, COUNT(DISTINCT pubkey) AS authors FROM events")->fetch() ?: ['total' => 0, 'authors' => 0];
$kind_stats = $pdo->query("SELECT kind, COUNT(*) AS cnt FROM events GROUP BY kind ORDER BY cnt DESC LIMIT 10")->fetchAll();
admin_header('dashboard', 'C-Relay-PG Admin — Dashboard');
?>
<div class="section">
<div class="section-header">CACHING SERVICE STATUS</div>
<div class="cards" id="dashboard-cards">
<div class="card">
<div class="label">Service State</div>
<div class="value" id="card-state"><?= e($state['service_state'] ?? 'unknown') ?></div>
<div class="sub" id="card-heartbeat">Heartbeat: <?= time_ago(intval($state['heartbeat_at'] ?? 0)) ?></div>
</div>
<div class="card">
<div class="label">Followed Authors</div>
<div class="value" id="card-follows"><?= intval($state['followed_author_count'] ?? 0) ?></div>
<div class="sub" id="card-relays"><?= intval($state['selected_relay_count'] ?? 0) ?> relays, <?= intval($state['connected_relay_count'] ?? 0) ?> connected</div>
</div>
<div class="card">
<div class="label">Backfill Progress</div>
<div class="value" id="card-backfill"><?= intval($state['backfill_authors_complete'] ?? 0) ?> / <?= intval($state['backfill_authors_total'] ?? 0) ?></div>
<div class="sub" id="card-backfill-pct"><?= ($state['backfill_authors_total'] ?? 0) > 0 ? round(intval($state['backfill_authors_complete'] ?? 0) / intval($state['backfill_authors_total']) * 100) : 0 ?>% complete</div>
</div>
<div class="card">
<div class="label">Events Fetched</div>
<div class="value" id="card-events"><?= number_format(intval($state['events_fetched'] ?? 0)) ?></div>
<div class="sub" id="card-inserts"><?= number_format(intval($state['inbox_inserts'] ?? 0)) ?> inbox inserts</div>
</div>
<div class="card">
<div class="label">Events in DB</div>
<div class="value" id="card-db-events"><?= number_format(intval($event_stats['total'] ?? 0)) ?></div>
<div class="sub"><?= number_format(intval($event_stats['authors'] ?? 0)) ?> authors</div>
</div>
<div class="card">
<div class="label">Inbox Pending</div>
<div class="value" id="card-inbox"><?= number_format(intval($inbox['pending'] ?? 0)) ?></div>
<div class="sub" id="card-inbox-detail"><?= intval($inbox['live'] ?? 0) ?> live, <?= intval($inbox['backfill'] ?? 0) ?> backfill</div>
</div>
<div class="card">
<div class="label">Error Relays</div>
<div class="value status-error" id="card-errors"><?= intval($error_stats['error_count'] ?? 0) ?></div>
<div class="sub" id="card-errors-detail"><?= intval($error_stats['auto_completed'] ?? 0) ?> auto-completed</div>
</div>
<div class="card">
<div class="label">Config Generation</div>
<div class="value" id="card-gen"><?= intval($state['config_generation'] ?? 0) ?></div>
<div class="sub">Updated <?= time_ago(intval($state['updated_at'] ?? 0)) ?></div>
</div>
</div>
</div>
<div class="section">
<div class="section-header">ACTIVE BACKFILL TARGET</div>
<div id="active-target" style="text-align:center;padding:10px">
<?php if (!empty($active['active_pubkey'])): ?>
<p class="status-working">⚡ Working on: <span class="pubkey"><?= e(trunc($active['active_pubkey'], 16)) ?></span> @ <?= e($active['active_relay'] ?? '') ?></p>
<?php else: ?>
<p class="status-complete">✓ Caching complete — no active backfill target</p>
<?php endif; ?>
</div>
</div>
<div class="section">
<div class="section-header">TOP EVENT KINDS</div>
<div class="config-table-container">
<table class="config-table">
<thead><tr><th>Kind</th><th>Count</th></tr></thead>
<tbody id="kind-table">
<?php foreach ($kind_stats as $ks): ?>
<tr><td><?= intval($ks['kind']) ?></td><td><?= number_format(intval($ks['cnt'])) ?></td></tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
</div>
<script>
const REFRESH_MS = <?= cfg('refresh_ms', 10000) ?>;
async function refreshDashboard() {
console.log('[dashboard] polling api/status.php at', new Date().toLocaleTimeString());
try {
const res = await fetch('api/status.php');
if (!res.ok) { console.warn('[dashboard] status.php returned', res.status); return; }
const d = await res.json();
console.log('[dashboard] refreshed at', new Date().toLocaleTimeString(), '— state:', d.service_state, 'follows:', d.followed_count, 'events:', d.db_events);
const set = (id, val) => { const el = document.getElementById(id); if (el) el.textContent = val; };
set('card-state', d.service_state || 'unknown');
set('card-heartbeat', 'Heartbeat: ' + (d.heartbeat_ago || 'never'));
set('card-follows', d.followed_count ?? 0);
set('card-relays', (d.selected_relays ?? 0) + ' relays, ' + (d.connected_relays ?? 0) + ' connected');
set('card-backfill', (d.bf_complete ?? 0) + ' / ' + (d.bf_total ?? 0));
set('card-backfill-pct', (d.bf_total > 0 ? Math.round(d.bf_complete / d.bf_total * 100) : 0) + '% complete');
set('card-events', (d.events_fetched ?? 0).toLocaleString());
set('card-inserts', (d.inbox_inserts ?? 0).toLocaleString() + ' inbox inserts');
set('card-db-events', (d.db_events ?? 0).toLocaleString());
set('card-inbox', (d.inbox_pending ?? 0).toLocaleString());
set('card-inbox-detail', (d.inbox_live ?? 0) + ' live, ' + (d.inbox_backfill ?? 0) + ' backfill');
set('card-errors', d.error_count ?? 0);
set('card-errors-detail', (d.auto_completed ?? 0) + ' auto-completed');
set('card-gen', d.config_generation ?? 0);
const atEl = document.getElementById('active-target');
if (d.active_pubkey && d.active_relay) {
atEl.innerHTML = '<p class="status-working">⚡ Working on: <span class="pubkey">' + d.active_pubkey.substring(0,16) + '…</span> @ ' + d.active_relay + '</p>';
} else if (d.active_pubkey) {
atEl.innerHTML = '<p class="status-working">⚡ Working on: <span class="pubkey">' + d.active_pubkey.substring(0,16) + '…</span></p>';
} else {
atEl.innerHTML = '<p class="status-complete">✓ Caching complete — no active backfill target</p>';
}
} catch (e) { console.error('[dashboard] refresh error:', e); }
}
setInterval(refreshDashboard, REFRESH_MS);
console.log('[dashboard] auto-refresh started, interval:', REFRESH_MS, 'ms');
</script>
<?php admin_footer(); ?>
+27
View File
@@ -0,0 +1,27 @@
<?php
/**
* C-Relay-PG Admin — Database connection configuration.
*
* This file is NOT web-accessible (denied by nginx location block).
* It contains the PostgreSQL connection string used by all admin pages.
*
* Edit these values to match your relay's PostgreSQL credentials.
*/
// PostgreSQL connection parameters.
// These should match the --db-* flags passed to the c-relay-pg binary
// or the connection string passed to caching_relay -p "...".
return [
'db_host' => 'localhost',
'db_port' => '5432',
'db_name' => 'crelay',
'db_user' => 'crelay',
'db_password' => 'crelay',
// Page sizes for paginated tables.
'follows_per_page' => 50,
'relays_per_page' => 50,
// Auto-refresh interval for dashboard polling (milliseconds).
'refresh_ms' => 10000,
];
+44
View File
@@ -0,0 +1,44 @@
<?php
/**
* C-Relay-PG Admin — PDO database connection helper.
*
* Provides a singleton PDO connection to the relay's PostgreSQL database.
* All admin pages use db() to get the connection.
*/
require_once __DIR__ . '/config.php';
function db(): PDO {
static $pdo = null;
if ($pdo === null) {
$cfg = require __DIR__ . '/config.php';
$dsn = sprintf(
'pgsql:host=%s;port=%d;dbname=%s;user=%s;password=%s',
$cfg['db_host'],
$cfg['db_port'],
$cfg['db_name'],
$cfg['db_user'],
$cfg['db_password']
);
try {
$pdo = new PDO($dsn, null, null, [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
PDO::ATTR_EMULATE_PREPARES => false,
]);
} catch (PDOException $e) {
http_response_code(500);
die('Database connection failed: ' . htmlspecialchars($e->getMessage()));
}
}
return $pdo;
}
/** Get a config value from the admin config file. */
function cfg(string $key, $default = null) {
static $cfg = null;
if ($cfg === null) {
$cfg = require __DIR__ . '/config.php';
}
return $cfg[$key] ?? $default;
}
+191
View File
@@ -0,0 +1,191 @@
<?php
/**
* C-Relay-PG Admin — Shared helper functions.
*/
require_once __DIR__ . '/db.php';
/** HTML-escape a string. */
function e(?string $s): string {
return htmlspecialchars($s ?? '', ENT_QUOTES, 'UTF-8');
}
/** Format a Unix timestamp as a human-readable relative time. */
function time_ago(int $ts): string {
if ($ts <= 0) return 'never';
$diff = time() - $ts;
if ($diff < 60) return $diff . 's ago';
if ($diff < 3600) return floor($diff / 60) . 'm ago';
if ($diff < 86400) return floor($diff / 3600) . 'h ago';
return floor($diff / 86400) . 'd ago';
}
/** Format a Unix timestamp as a date/time string. */
function fmt_date(int $ts): string {
if ($ts <= 0) return '-';
return date('Y-m-d H:i:s', $ts);
}
/**
* Convert a 64-char hex pubkey to npub (bech32).
* Uses the standard bech32 encoding (NIP-19).
*/
function hex_to_npub(string $hex): string {
if (!preg_match('/^[0-9a-fA-F]{64}$/', $hex)) return $hex;
$hex = strtolower($hex);
// Bech32 constants
$charset = 'qpzry9x8gf2tvdw0s3jn54khce6mua7l';
$data = [];
for ($i = 0; $i < strlen($hex); $i += 2) {
$data[] = intval(substr($hex, $i, 2), 16);
}
// Convert 8-bit groups to 5-bit groups
$conv = [];
$buffer = 0;
$bits = 0;
foreach ($data as $byte) {
$buffer = ($buffer << 8) | $byte;
$bits += 8;
while ($bits >= 5) {
$conv[] = ($buffer >> ($bits - 5)) & 31;
$bits -= 5;
}
}
if ($bits > 0) {
$conv[] = ($buffer << (5 - $bits)) & 31;
}
// Compute checksum — HRP values are positions of each char in the charset.
$values = array_merge(
array_map(function($c) {
return strpos('qpzry9x8gf2tvdw0s3jn54khce6mua7l', $c);
}, str_split('npub')),
$conv
);
$gen = [0x3b6a57b2, 0x26508e6d, 0x1ea119fa, 0x3d4233dd, 0x2a1462b3];
$chk = 1;
foreach ($values as $v) {
$top = $chk >> 25;
$chk = (($chk & 0x1ffffff) << 5) ^ $v;
for ($i = 0; $i < 5; $i++) {
if (($top >> $i) & 1) $chk ^= $gen[$i];
}
}
$chk ^= 1;
$checksum = [];
for ($i = 0; $i < 6; $i++) {
$checksum[] = ($chk >> (5 * (5 - $i))) & 31;
}
$result = 'npub1';
foreach (array_merge($conv, $checksum) as $v) {
$result .= $charset[$v];
}
return $result;
}
/** Truncate a string to $len chars with ellipsis. */
function trunc(string $s, int $len = 16): string {
if (strlen($s) <= $len) return $s;
return substr($s, 0, $len) . '…';
}
/** Build pagination links HTML. */
function pagination(int $page, int $per_page, int $total, string $base_url): string {
$total_pages = max(1, ceil($total / $per_page));
if ($total_pages <= 1) return '';
$html = '<div class="pagination">';
if ($page > 1) {
$html .= '<a href="' . e($base_url) . '?page=' . ($page - 1) . '">← Prev</a>';
}
$html .= '<span>Page ' . $page . ' of ' . $total_pages . ' (' . $total . ' total)</span>';
if ($page < $total_pages) {
$html .= '<a href="' . e($base_url) . '?page=' . ($page + 1) . '">Next →</a>';
}
$html .= '</div>';
return $html;
}
/** Get the current page number from the query string. */
function current_page(): int {
$p = intval($_GET['page'] ?? 1);
return max(1, $p);
}
/** JSON response helper for AJAX endpoints. */
function json_response($data): void {
header('Content-Type: application/json');
echo json_encode($data, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
exit;
}
/** Get a query parameter with a default. */
function query_param(string $key, $default = null) {
return $_GET[$key] ?? $default;
}
/**
* Render the shared HTML header with side navigation.
* Pass the active page name to highlight the current nav item.
*/
function admin_header(string $active = '', string $title = 'C-Relay-PG Admin'): void {
$pages = [
'dashboard' => ['index.php', 'Dashboard'],
'follows' => ['follows.php', 'Follows'],
'relays' => ['relays.php', 'Relays'],
'config' => ['config-edit.php', 'Config'],
'inbox' => ['inbox.php', 'Inbox'],
];
?>
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title><?= e($title) ?></title>
<link rel="stylesheet" href="assets/style.css">
</head>
<body>
<!-- Side Navigation -->
<nav class="side-nav" id="side-nav">
<ul class="nav-menu">
<?php foreach ($pages as $key => [$url, $label]): ?>
<li><a class="nav-item<?= $active === $key ? ' active' : '' ?>" href="<?= e($url) ?>"><?= e($label) ?></a></li>
<?php endforeach; ?>
</ul>
</nav>
<div class="side-nav-overlay" id="side-nav-overlay" onclick="closeNav()"></div>
<!-- Header -->
<div class="main-header">
<div class="header-content">
<button class="menu-btn" onclick="openNav()">☰</button>
<div class="header-title" onclick="location.href='index.php'">
<span class="relay-letter">R</span><span class="relay-letter">E</span><span class="relay-letter">L</span><span class="relay-letter">A</span><span class="relay-letter">Y</span>
</div>
<div style="width:40px"></div>
</div>
</div>
<script>
function openNav() {
document.getElementById('side-nav').classList.add('open');
document.getElementById('side-nav-overlay').classList.add('show');
}
function closeNav() {
document.getElementById('side-nav').classList.remove('open');
document.getElementById('side-nav-overlay').classList.remove('show');
}
</script>
<?php
}
/** Render the shared HTML footer. */
function admin_footer(): void {
?>
</body>
</html>
<?php
}
+132
View File
@@ -0,0 +1,132 @@
<?php
/**
* C-Relay-PG Admin — Per-Relay Backfill Progress.
*/
require_once __DIR__ . '/lib/helpers.php';
$pdo = db();
$page = current_page();
$per = cfg('relays_per_page', 50);
$offset = ($page - 1) * $per;
$filter = query_param('filter', 'all');
$pubkey = query_param('pubkey', '');
$where = [];
$params = [];
if ($filter === 'incomplete') {
$where[] = 'rp.complete = false';
} elseif ($filter === 'error') {
$where[] = "rp.last_status LIKE 'error%'";
} elseif ($filter === 'complete') {
$where[] = 'rp.complete = true';
}
if ($pubkey) {
$where[] = 'rp.author_pubkey = ?';
$params[] = $pubkey;
}
$where_sql = $where ? 'WHERE ' . implode(' AND ', $where) : '';
$total = 0;
$relays = [];
try {
$stmt = $pdo->prepare("SELECT COUNT(*) FROM caching_backfill_relay_progress rp $where_sql");
$stmt->execute($params);
$total = intval($stmt->fetchColumn());
$sql = "
SELECT rp.author_pubkey, rp.relay_url, rp.until_cursor, rp.complete,
rp.events_fetched,
COALESCE(rp.consecutive_errors, 0) AS consecutive_errors,
rp.last_status, rp.updated_at,
e.content::json->>'name' AS name,
e.content::json->>'display_name' AS display_name
FROM caching_backfill_relay_progress rp
LEFT JOIN LATERAL (SELECT content FROM events WHERE pubkey = rp.author_pubkey AND kind = 0 ORDER BY created_at DESC LIMIT 1) e ON true
$where_sql
ORDER BY rp.complete ASC, COALESCE(rp.consecutive_errors, 0) DESC, rp.updated_at DESC
LIMIT $per OFFSET $offset
";
$stmt = $pdo->prepare($sql);
$stmt->execute($params);
$relays = $stmt->fetchAll();
} catch (PDOException $ex) {}
function status_badge(string $status): string {
if ($status === 'eose') return '<span class="badge badge-success">eose</span>';
if ($status === 'timeout') return '<span class="badge badge-warning">timeout</span>';
if (str_starts_with($status, 'error')) return '<span class="badge badge-error">' . e($status) . '</span>';
if (str_starts_with($status, 'NOTICE')) return '<span class="badge badge-warning">' . e($status) . '</span>';
if (str_starts_with($status, 'CLOSED')) return '<span class="badge badge-warning">' . e($status) . '</span>';
if ($status === '') return '<span class="badge badge-muted">—</span>';
return '<span class="badge badge-muted">' . e($status) . '</span>';
}
admin_header('relays', 'C-Relay-PG Admin — Relay Progress');
?>
<div class="section">
<div class="section-header">PER-RELAY BACKFILL PROGRESS (<?= number_format($total) ?> total)</div>
<form class="filters" method="get">
<select name="filter" onchange="this.form.submit()">
<option value="all" <?= $filter==='all'?'selected':'' ?>>All</option>
<option value="incomplete" <?= $filter==='incomplete'?'selected':'' ?>>Incomplete only</option>
<option value="error" <?= $filter==='error'?'selected':'' ?>>Errors only</option>
<option value="complete" <?= $filter==='complete'?'selected':'' ?>>Complete only</option>
</select>
<?php if ($pubkey): ?>
<input type="hidden" name="pubkey" value="<?= e($pubkey) ?>">
<span style="font-size:10px;color:var(--muted-color)">Filtered by: <span class="pubkey"><?= e(trunc($pubkey, 16)) ?></span></span>
<a href="relays.php?filter=<?= e($filter) ?>" style="font-size:10px">Clear</a>
<?php endif; ?>
</form>
<div class="config-table-container">
<table class="config-table">
<thead>
<tr>
<th>Author</th>
<th>Relay URL</th>
<th>Complete</th>
<th>Events</th>
<th>Errors</th>
<th>Last Status</th>
<th>Updated</th>
</tr>
</thead>
<tbody>
<?php foreach ($relays as $r):
$name = $r['display_name'] ?: $r['name'];
$npub = hex_to_npub($r['author_pubkey']);
?>
<tr <?= intval($r['consecutive_errors']) >= 3 ? 'style="background:rgba(255,0,0,0.05)"' : '' ?>>
<td>
<?php if ($name): ?>
<strong><?= e($name) ?></strong>
<?php else: ?>
<span style="color:var(--muted-color);font-style:italic">unknown</span>
<?php endif; ?>
<br><a href="follows.php?search=<?= e($r['author_pubkey']) ?>" class="npub-link"><?= e(trunc($npub, 20)) ?></a>
</td>
<td><?= e($r['relay_url']) ?></td>
<td><?= $r['complete'] ? '<span class="badge badge-success">✓</span>' : '<span class="badge badge-warning">…</span>' ?></td>
<td><?= number_format(intval($r['events_fetched'])) ?></td>
<td><?= intval($r['consecutive_errors']) > 0
? '<span class="status-error">' . intval($r['consecutive_errors']) . '</span>'
: '0' ?></td>
<td><?= status_badge($r['last_status']) ?></td>
<td><?= time_ago(intval($r['updated_at'])) ?></td>
</tr>
<?php endforeach; ?>
<?php if (empty($relays)): ?>
<tr><td colspan="7" style="text-align:center;color:var(--muted-color);padding:20px">No relay progress rows found.</td></tr>
<?php endif; ?>
</tbody>
</table>
</div>
<?= pagination($page, $per, $total, 'relays.php' . ($filter !== 'all' ? '?filter=' . e($filter) : '') . ($pubkey ? '&pubkey=' . e($pubkey) : '')) ?>
</div>
<?php admin_footer(); ?>
+184 -1
View File
@@ -18,6 +18,7 @@
<li><button class="nav-item" data-page="authorization">Authorization</button></li>
<li><button class="nav-item" data-page="ip-bans">IP BANS</button></li>
<li><button class="nav-item" data-page="relay-events">Relay Events</button></li>
<li><button class="nav-item" data-page="caching">Caching</button></li>
<li><button class="nav-item" data-page="dm">DM</button></li>
<li><button class="nav-item" data-page="database">Database Query</button></li>
</ul>
@@ -203,6 +204,7 @@
<thead>
<tr>
<th>Rank</th>
<th>Name</th>
<th>Pubkey</th>
<th>Event Count</th>
<th>Percentage</th>
@@ -210,7 +212,7 @@
</thead>
<tbody id="stats-pubkeys-table-body">
<tr>
<td colspan="4" style="text-align: center; font-style: italic;">No data loaded</td>
<td colspan="5" style="text-align: center; font-style: italic;">No data loaded</td>
</tr>
</tbody>
</table>
@@ -496,6 +498,29 @@ WEB OF TRUST
RELAY EVENTS MANAGEMENT
</div>
<!-- Live Relay Event Feed -->
<div class="input-group">
<h3>Live Relay Event Feed (Normal Nostr Subscription)</h3>
<div class="config-table-container">
<table class="config-table" id="live-relay-events-table">
<thead>
<tr>
<th>Time</th>
<th>Kind</th>
<th>Pubkey</th>
<th>ID</th>
<th>Content Preview</th>
</tr>
</thead>
<tbody id="live-relay-events-table-body">
<tr>
<td colspan="5" style="text-align: center;">Waiting for live events...</td>
</tr>
</tbody>
</table>
</div>
</div>
<!-- Kind 0: User Metadata -->
<div class="input-group">
<h3>Kind 0: User Metadata</h3>
@@ -556,6 +581,164 @@ WEB OF TRUST
</div>
</div>
<!-- CACHING Section -->
<div class="section" id="cachingSection" style="display: none;">
<div class="section-header">CACHING</div>
<!-- Caching Service Status (read-only) -->
<div class="input-group">
<h3>Caching Service Status</h3>
<div id="caching-service-status" class="status-display">
<p>Service status will appear here after connecting.</p>
</div>
</div>
<!-- Relay Inbox Status (read-only) -->
<div class="input-group">
<h3>Relay Inbox Status</h3>
<div id="caching-inbox-status" class="status-display">
<p>Inbox status will appear here after connecting.</p>
</div>
</div>
<!-- Caching Configuration (editable) -->
<div class="input-group">
<h3>Caching Configuration</h3>
<div class="form-group">
<label for="caching-enabled">Enable Caching:</label>
<select id="caching-enabled">
<option value="false">Disabled</option>
<option value="true">Enabled</option>
</select>
</div>
<div class="form-group">
<label for="caching-inbox-enabled">Enable Inbox Consumer:</label>
<select id="caching-inbox-enabled">
<option value="false">Disabled</option>
<option value="true">Enabled</option>
</select>
</div>
<div class="form-group">
<label for="caching-root-npubs">Root Npubs (one per line):</label>
<textarea id="caching-root-npubs" rows="4" placeholder="npub1...">npub13lm5wf8dvsdnc2894pkhch9uf8phvw9varrv8zf4sc885hhdmc8q6lx7ks</textarea>
</div>
<div class="form-group">
<label for="caching-bootstrap-relays">Bootstrap Relays (one per line):</label>
<textarea id="caching-bootstrap-relays" rows="4" placeholder="wss://relay.damus.io">wss://relay.damus.io
wss://nos.lol
wss://relay.primal.net
wss://laantungir.net/relay</textarea>
</div>
<div class="form-group">
<label for="caching-kinds">Kinds (comma-separated):</label>
<input type="text" id="caching-kinds" placeholder="1,3,6,10000,30023" value="0,1,3,6,10000,10002,30023">
</div>
<div class="form-group">
<label for="caching-admin-kinds">Admin Kinds (comma-separated, * for all):</label>
<input type="text" id="caching-admin-kinds" placeholder="*">
</div>
<div class="form-group">
<label for="caching-live-enabled">Live Subscriptions:</label>
<select id="caching-live-enabled">
<option value="true">Enabled</option>
<option value="false">Disabled</option>
</select>
</div>
<div class="form-group">
<label for="caching-backfill-enabled">Backfill:</label>
<select id="caching-backfill-enabled">
<option value="true">Enabled</option>
<option value="false">Disabled</option>
</select>
</div>
<div class="form-group">
<label for="caching-backfill-windows">Backfill Windows (seconds, comma-separated):</label>
<input type="text" id="caching-backfill-windows" placeholder="86400,604800,2592000,7776000,31536000">
</div>
<div class="form-group">
<label for="caching-backfill-page-size">Backfill Page Size:</label>
<input type="number" id="caching-backfill-page-size" placeholder="50">
</div>
<div class="form-group">
<label for="caching-backfill-tick-interval">Backfill Tick Interval (ms):</label>
<input type="number" id="caching-backfill-tick-interval" placeholder="5000">
</div>
<div class="form-group">
<label for="caching-inbox-batch-size">Inbox Batch Size:</label>
<input type="number" id="caching-inbox-batch-size" placeholder="150">
</div>
<div class="form-group">
<label for="caching-inbox-active-poll">Inbox Active Poll (ms):</label>
<input type="number" id="caching-inbox-active-poll" placeholder="200">
</div>
<div class="form-group">
<label for="caching-inbox-idle-poll">Inbox Idle Poll (ms):</label>
<input type="number" id="caching-inbox-idle-poll" placeholder="5000">
</div>
<div class="form-group">
<label for="caching-service-binary-path">Caching Service Binary Path:</label>
<input type="text" id="caching-service-binary-path" placeholder="./caching_relay" value="./caching_relay">
</div>
<div class="form-group">
<label for="caching-service-pg-conn">Caching Service PG Connection:</label>
<input type="text" id="caching-service-pg-conn" placeholder="host=localhost port=5432 dbname=crelay user=crelay password=crelay" value="host=localhost port=5432 dbname=crelay user=crelay password=crelay">
</div>
<div class="inline-buttons">
<button type="button" id="caching-apply-btn">APPLY CONFIGURATION</button>
<button type="button" id="caching-reset-progress-btn">RESET BACKFILL PROGRESS</button>
</div>
<div id="caching-config-status" class="status-message"></div>
</div>
<div class="input-group">
<h3>Caching Service Control</h3>
<p>Start or stop the external caching service process. Set <code>caching_service_binary_path</code> and <code>caching_service_pg_conn</code> above and click APPLY CONFIGURATION before starting.</p>
<div class="inline-buttons">
<button type="button" id="caching-start-service-btn">START CACHING SERVICE</button>
<button type="button" id="caching-stop-service-btn">STOP CACHING SERVICE</button>
</div>
<div id="caching-service-control-status" class="status-message"></div>
</div>
<div class="input-group">
<h3>Followed Pubkeys</h3>
<p>Pubkeys being cached with their profile names, event counts, outbox relays, and backfill status.</p>
<div id="caching-follows-status" class="status-message"></div>
<table id="caching-follows-table" style="width: 100%; border-collapse: collapse; font-size: 13px;">
<thead>
<tr style="border-bottom: 2px solid var(--border-color, #444); text-align: left;">
<th style="padding: 6px 8px;">Name</th>
<th style="padding: 6px 8px;">npub</th>
<th style="padding: 6px 8px;">Root?</th>
<th style="padding: 6px 8px;">Events in DB</th>
<th style="padding: 6px 8px;">Backfill</th>
<th style="padding: 6px 8px; width: 40%;">Relays (status, events fetched)</th>
</tr>
</thead>
<tbody id="caching-follows-table-body">
<tr><td colspan="6" style="text-align: center; font-style: italic; padding: 12px;">Loading follows status...</td></tr>
</tbody>
</table>
</div>
</div>
<!-- SQL QUERY Section -->
<div class="section" id="sqlQuerySection" style="display: none;">
<div class="section-header">
+732 -3
View File
@@ -19,6 +19,8 @@ let nlLite = null;
let userPubkey = null;
let isLoggedIn = false;
let currentConfig = null;
// Caching page periodic refresh interval handle
let cachingRefreshInterval = null;
// Global subscription state
let relayPool = null;
let subscriptionId = null;
@@ -50,6 +52,7 @@ let pendingSqlQueries = new Map();
// Real-time event rate chart
let eventRateChart = null;
let previousTotalEvents = 0; // Track previous total for rate calculation
const liveRelayEventIds = new Set(); // Deduplicate live feed rows
// Temporary diagnostics toggle: enable deep SimplePool diagnostics in nostr.bundle.js
window.__SIMPLE_POOL_DEBUG__ = true;
@@ -1329,6 +1332,10 @@ async function subscribeToConfiguration() {
kinds: [0, 10050, 10002], // Relay events: metadata, DM relays, relay list
authors: [getRelayPubkey()], // Only listen to relay's own events
limit: 10
}, {
// Live feed uses a normal Nostr subscription, not monitoring events
since: Math.floor(Date.now() / 1000),
limit: 200
}], {
async onevent(event) {
// Simplified logging - one line per event
@@ -1434,6 +1441,11 @@ async function subscribeToConfiguration() {
if ([0, 10050, 10002].includes(event.kind)) {
handleRelayEventReceived(event);
}
// Real-time live feed via normal subscription (exclude monitoring/admin traffic)
if (event.kind !== 24567 && event.kind !== 23457 && event.kind !== 23456) {
addEventToLiveFeed(event);
}
},
oneose() {
console.log('EOSE received - End of stored events');
@@ -2066,6 +2078,25 @@ function handleSystemCommandResponse(responseData) {
handleWotStatusResponse(responseData);
}
// Handle caching status response
if (responseData.command === 'caching_status') {
handleCachingStatusResponse(responseData);
}
// Handle caching reset progress response
if (responseData.command === 'caching_reset_progress') {
if (responseData.status === 'success') {
log('Backfill progress reset successfully', 'INFO');
} else {
log(`Failed to reset backfill progress: ${responseData.message || 'Unknown error'}`, 'ERROR');
}
}
// Handle caching follows status response
if (responseData.command === 'caching_follows_status') {
handleCachingFollowsResponse(responseData);
}
if (typeof logTestEvent === 'function') {
logTestEvent('RECV', `System command response: ${responseData.command} - ${responseData.status}`, 'SYSTEM_CMD');
}
@@ -4823,7 +4854,7 @@ function populateStatsPubkeys(data) {
if (data.top_pubkeys.length === 0) {
const row = document.createElement('tr');
row.innerHTML = '<td colspan="4" style="text-align: center; font-style: italic;">No pubkey data</td>';
row.innerHTML = '<td colspan="5" style="text-align: center; font-style: italic;">No pubkey data</td>';
tableBody.appendChild(row);
return;
}
@@ -4842,8 +4873,12 @@ function populateStatsPubkeys(data) {
} catch (error) {
console.log('Failed to encode pubkey to npub:', error.message);
}
// Name from kind-0 metadata (enriched by backend)
const name = pubkey.name || '';
const nameDisplay = name ? escapeHtml(name) : '<span style="color: var(--text-muted, #888); font-style: italic;">unknown</span>';
row.innerHTML = `
<td>${index + 1}</td>
<td>${nameDisplay}</td>
<td style="font-family: 'Courier New', monospace; font-size: 12px; word-break: break-all;">${npubLink}</td>
<td>${pubkey.event_count}</td>
<td>${pubkey.percentage}%</td>
@@ -4861,7 +4896,7 @@ function populateStatsPubkeysFromMonitoring(pubkeysData, totalEvents) {
if (pubkeysData.length === 0) {
const row = document.createElement('tr');
row.innerHTML = '<td colspan="4" style="text-align: center; font-style: italic;">No pubkey data</td>';
row.innerHTML = '<td colspan="5" style="text-align: center; font-style: italic;">No pubkey data</td>';
tableBody.appendChild(row);
return;
}
@@ -4884,8 +4919,13 @@ function populateStatsPubkeysFromMonitoring(pubkeysData, totalEvents) {
// Calculate percentage using totalEvents parameter
const percentage = totalEvents > 0 ? ((pubkey.event_count / totalEvents) * 100).toFixed(1) : '0.0';
// Name from kind-0 metadata (enriched by backend)
const name = pubkey.name || '';
const nameDisplay = name ? escapeHtml(name) : '<span style="color: var(--text-muted, #888); font-style: italic;">unknown</span>';
row.innerHTML = `
<td>${index + 1}</td>
<td>${nameDisplay}</td>
<td style="font-family: 'Courier New', monospace; font-size: 12px; word-break: break-all;">${npubLink}</td>
<td>${pubkey.event_count}</td>
<td>${percentage}%</td>
@@ -5358,6 +5398,7 @@ function switchPage(pageName) {
'wotSection',
'ipBansSection',
'relayEventsSection',
'cachingSection',
'nip17DMSection',
'sqlQuerySection'
];
@@ -5376,6 +5417,7 @@ function switchPage(pageName) {
'configuration': 'div_config',
'ip-bans': 'ipBansSection',
'relay-events': 'relayEventsSection',
'caching': 'cachingSection',
'dm': 'nip17DMSection',
'database': 'sqlQuerySection'
};
@@ -5413,6 +5455,36 @@ function switchPage(pageName) {
});
}
// Special handling for caching page - fetch status and start periodic refresh
if (pageName === 'caching') {
fetchCachingStatus().catch(error => {
console.log('Failed to refresh caching status on page switch: ' + error.message);
});
fetchCachingFollowsStatus().catch(error => {
console.log('Failed to refresh caching follows status on page switch: ' + error.message);
});
// Start periodic refresh every 10 seconds while the caching page is visible
if (cachingRefreshInterval) {
clearInterval(cachingRefreshInterval);
}
cachingRefreshInterval = setInterval(() => {
if (currentPage === 'caching') {
fetchCachingStatus().catch(error => {
console.log('Periodic caching status refresh failed: ' + error.message);
});
fetchCachingFollowsStatus().catch(error => {
console.log('Periodic caching follows refresh failed: ' + error.message);
});
}
}, 10000);
} else {
// Clear periodic refresh when leaving the caching page
if (cachingRefreshInterval) {
clearInterval(cachingRefreshInterval);
cachingRefreshInterval = null;
}
}
// Close side navigation
closeSideNav();
@@ -6026,6 +6098,47 @@ function initializeToggleButtonsFromConfig(configData) {
// ================================
function addEventToLiveFeed(event) {
if (!event || !event.id) return;
if (liveRelayEventIds.has(event.id)) return;
liveRelayEventIds.add(event.id);
const tableBody = document.getElementById('live-relay-events-table-body');
if (!tableBody) return;
const preview = typeof event.content === 'string'
? event.content.slice(0, 120)
: JSON.stringify(event.content || '').slice(0, 120);
const row = document.createElement('tr');
row.innerHTML = `
<td>${new Date((event.created_at || Math.floor(Date.now() / 1000)) * 1000).toLocaleTimeString()}</td>
<td>${event.kind ?? '-'}</td>
<td title="${escapeHtml(event.pubkey || '')}">${escapeHtml((event.pubkey || '').slice(0, 16))}...</td>
<td title="${escapeHtml(event.id || '')}">${escapeHtml((event.id || '').slice(0, 16))}...</td>
<td>${escapeHtml(preview)}</td>
`;
const waitingRow = tableBody.querySelector('tr td[colspan="5"]');
if (waitingRow) {
tableBody.innerHTML = '';
}
tableBody.insertBefore(row, tableBody.firstChild);
while (tableBody.children.length > 100) {
const last = tableBody.lastChild;
if (last && last.querySelector('td:nth-child(4)')) {
const idText = last.querySelector('td:nth-child(4)').textContent || '';
if (idText.endsWith('...')) {
// keep set bounded even with truncated display
// actual id remains in Set until page reload (acceptable for UI scope)
}
}
tableBody.removeChild(last);
}
}
// Handle received relay events
function handleRelayEventReceived(event) {
console.log('Handling relay event:', event.kind, event);
@@ -6725,4 +6838,620 @@ function initIpBansEventListeners() {
}
// Initialize when DOM is ready
document.addEventListener('DOMContentLoaded', initIpBansEventListeners);
document.addEventListener('DOMContentLoaded', initIpBansEventListeners);
// ================================
// CACHING PAGE FUNCTIONS
// ================================
// Mapping of caching config keys to form field ids and metadata
const CACHING_CONFIG_FIELDS = [
{ key: 'caching_enabled', field: 'caching-enabled', type: 'select' },
{ key: 'caching_inbox_enabled', field: 'caching-inbox-enabled', type: 'select' },
{ key: 'caching_root_npubs', field: 'caching-root-npubs', type: 'textarea-list' },
{ key: 'caching_bootstrap_relays', field: 'caching-bootstrap-relays', type: 'textarea-list' },
{ key: 'caching_kinds', field: 'caching-kinds', type: 'string' },
{ key: 'caching_admin_kinds', field: 'caching-admin-kinds', type: 'string' },
{ key: 'caching_live_enabled', field: 'caching-live-enabled', type: 'select' },
{ key: 'caching_backfill_enabled', field: 'caching-backfill-enabled', type: 'select' },
{ key: 'caching_backfill_windows', field: 'caching-backfill-windows', type: 'string' },
{ key: 'caching_backfill_page_size', field: 'caching-backfill-page-size', type: 'integer' },
{ key: 'caching_backfill_tick_interval_ms',field: 'caching-backfill-tick-interval', type: 'integer' },
{ key: 'caching_inbox_batch_size', field: 'caching-inbox-batch-size', type: 'integer' },
{ key: 'caching_inbox_active_poll_ms', field: 'caching-inbox-active-poll', type: 'integer' },
{ key: 'caching_inbox_idle_poll_ms', field: 'caching-inbox-idle-poll', type: 'integer' },
{ key: 'caching_service_binary_path', field: 'caching-service-binary-path', type: 'string' },
{ key: 'caching_service_pg_conn', field: 'caching-service-pg-conn', type: 'string' }
];
// Helper: read a config value from currentConfig (which stores values in tags as [key, value])
function getCachingConfigValue(key) {
if (!currentConfig || !currentConfig.tags) return null;
for (const tag of currentConfig.tags) {
if (Array.isArray(tag) && tag.length >= 2 && tag[0] === key) {
return tag[1];
}
}
return null;
}
// Fetch caching status: populates form fields from config and queries service/inbox status
async function fetchCachingStatus() {
try {
if (!isLoggedIn || !userPubkey) {
throw new Error('Must be logged in to fetch caching status');
}
// Reuse the existing config query infrastructure to refresh currentConfig.
// fetchConfiguration() sends the config_query command but returns before
// the response arrives (the response updates currentConfig asynchronously
// via handleConfigQueryResponse -> displayConfiguration). We capture a
// marker so we can detect when currentConfig has been refreshed.
const prevConfigId = currentConfig ? (currentConfig.id || null) : null;
await fetchConfiguration();
// Wait for currentConfig to be refreshed by the async config_query response.
// Poll for up to 5 seconds for a new currentConfig (or a changed id).
let waited = 0;
while (waited < 5000) {
const curId = currentConfig ? (currentConfig.id || null) : null;
if (currentConfig && curId !== prevConfigId) {
break;
}
await new Promise(resolve => setTimeout(resolve, 200));
waited += 200;
}
// Populate form fields from currentConfig.
// IMPORTANT: skip fields the user is actively editing so the periodic
// auto-refresh (every 10s via cachingRefreshInterval) does not clobber
// in-progress edits before "Apply configuration" is pressed. A field is
// considered "being edited" if it currently has focus OR it has been
// modified by the user since the last populate (dirty). Dirty state is
// tracked by the input/change listeners installed in
// initCachingEventListeners() via markCachingFieldDirty().
CACHING_CONFIG_FIELDS.forEach(field => {
const value = getCachingConfigValue(field.key);
const el = document.getElementById(field.field);
if (!el) return;
if (value === null || value === undefined) {
return;
}
// Don't stomp on a field the user is currently focused on.
if (document.activeElement === el) {
return;
}
// Don't stomp on a field the user has edited but not yet applied.
if (el.dataset.cachingDirty === '1') {
return;
}
if (field.type === 'textarea-list') {
// Config stores comma-separated; display as newline-separated
el.value = String(value).split(',').map(s => s.trim()).filter(s => s.length > 0).join('\n');
} else {
el.value = String(value);
}
});
// Attempt to fetch service/inbox status via the caching_status system command.
// Show a neutral "loading" placeholder; handleCachingStatusResponse() will
// overwrite it when the response arrives. If the command fails (e.g. admin
// auth rejection), the catch block surfaces the real error.
const serviceStatusEl = document.getElementById('caching-service-status');
const inboxStatusEl = document.getElementById('caching-inbox-status');
if (serviceStatusEl) {
serviceStatusEl.innerHTML = '<p>Loading caching service status...</p>';
}
if (inboxStatusEl) {
inboxStatusEl.innerHTML = '<p>Loading relay inbox status...</p>';
}
// Best-effort: send caching_status system command. Response handling is in
// handleSystemCommandResponse() -> handleCachingStatusResponse() below.
try {
await sendAdminCommand(['system_command', 'caching_status']);
} catch (e) {
console.log('caching_status command failed: ' + e.message);
const errMsg = escapeHtml(e.message || 'Unknown error');
// Detect admin authorization failures and surface a clear message,
// since the relay rejects the kind 23456 event before the command
// handler ever runs.
const isAuthError = /unauthorized admin event attempt|invalid admin pubkey|not admin/i.test(e.message || '');
const authNote = isAuthError
? ' <em>(Your browser pubkey is not registered as an admin on this relay. Load the admin private key in your Nostr extension.)</em>'
: '';
if (serviceStatusEl) {
serviceStatusEl.innerHTML = `<p style="color:red">Failed to load service status: ${errMsg}${authNote}</p>`;
}
if (inboxStatusEl) {
inboxStatusEl.innerHTML = `<p style="color:red">Failed to load inbox status: ${errMsg}${authNote}</p>`;
}
}
} catch (error) {
console.error('Failed to fetch caching status:', error);
const statusEl = document.getElementById('caching-config-status');
if (statusEl) {
statusEl.innerHTML = `<span style="color:red">Failed to load caching status: ${escapeHtml(error.message)}</span>`;
}
}
}
// Apply caching configuration: collect form values and send config_update
async function applyCachingConfig() {
const statusEl = document.getElementById('caching-config-status');
try {
if (!isLoggedIn || !userPubkey) {
throw new Error('Must be logged in to apply caching configuration');
}
const configObjects = [];
CACHING_CONFIG_FIELDS.forEach(field => {
const el = document.getElementById(field.field);
if (!el) return;
let value = el.value;
if (field.type === 'textarea-list') {
// Convert newlines back to comma-separated for storage
value = value.split('\n').map(s => s.trim()).filter(s => s.length > 0).join(',');
} else if (field.type === 'integer') {
// Trim whitespace; leave empty for the skip check below.
// Do NOT coerce empty to '0' — the backend validates numeric
// ranges (e.g. caching_inbox_batch_size 1-500) and rejects 0,
// and config_update is atomic, so a single rejected field
// rolls back the entire batch (including unrelated boolean
// toggles like caching_enabled). Empty here means the form
// field was not populated, so we skip it and preserve the
// existing stored value.
value = String(value).trim();
} else {
value = String(value).trim();
}
// Skip empty values for optional string/textarea-list/integer fields.
// The backend (update_config_in_table) rejects empty strings to
// prevent accidental data loss, and config_update is atomic —
// one rejected field rolls back the entire batch. Fields like
// caching_root_npubs and caching_bootstrap_relays are optional
// and may legitimately be empty until configured. Integer fields
// are also skipped when empty so their existing DB value is
// preserved instead of being overwritten with an out-of-range 0.
if (value === '' && (field.type === 'textarea-list' || field.type === 'string' || field.type === 'integer')) {
return;
}
let dataType = 'string';
if (field.type === 'integer') {
dataType = 'integer';
} else if (field.type === 'select') {
dataType = 'boolean';
}
configObjects.push({
key: field.key,
value: value,
data_type: dataType,
category: 'caching'
});
});
if (statusEl) {
statusEl.innerHTML = '<span style="color:var(--accent-color,#ff0000)">Applying caching configuration...</span>';
}
// Send all caching config keys in a single config_update command
await sendConfigUpdateCommand(configObjects);
// Increment caching_config_generation so the caching service picks up changes
const currentGen = getCachingConfigValue('caching_config_generation');
let nextGen = 1;
if (currentGen !== null && !isNaN(parseInt(currentGen, 10))) {
nextGen = parseInt(currentGen, 10) + 1;
}
await sendConfigUpdateCommand([{
key: 'caching_config_generation',
value: String(nextGen),
data_type: 'integer',
category: 'caching'
}]);
if (statusEl) {
statusEl.innerHTML = '<span style="color:green">✓ Caching configuration applied successfully.</span>';
}
log('Caching configuration applied successfully', 'INFO');
// Clear dirty flags so the upcoming refresh can repopulate fields from
// the freshly-applied DB values without skipping user-edited fields.
clearCachingFieldsDirty();
// Refresh status to reflect applied values
setTimeout(() => fetchCachingStatus().catch(() => {}), 1500);
} catch (error) {
console.error('Failed to apply caching configuration:', error);
if (statusEl) {
statusEl.innerHTML = `<span style="color:red">Failed to apply configuration: ${escapeHtml(error.message)}</span>`;
}
log('Failed to apply caching configuration: ' + error.message, 'ERROR');
}
}
// Reset backfill progress via system_command
async function resetBackfillProgress() {
const statusEl = document.getElementById('caching-config-status');
try {
if (!isLoggedIn || !userPubkey) {
throw new Error('Must be logged in to reset backfill progress');
}
const confirmed = confirm('Reset all backfill progress? This will cause the caching service to re-fetch events for all backfill windows.');
if (!confirmed) {
return;
}
if (statusEl) {
statusEl.innerHTML = '<span style="color:var(--accent-color,#ff0000)">Resetting backfill progress...</span>';
}
await sendAdminCommand(['system_command', 'caching_reset_progress']);
if (statusEl) {
statusEl.innerHTML = '<span style="color:green">✓ Backfill progress reset command sent.</span>';
}
log('Backfill progress reset command sent', 'INFO');
} catch (error) {
console.error('Failed to reset backfill progress:', error);
if (statusEl) {
statusEl.innerHTML = `<span style="color:red">Failed to reset backfill progress: ${escapeHtml(error.message)}</span>`;
}
log('Failed to reset backfill progress: ' + error.message, 'ERROR');
}
}
// Handle caching_status system command responses (service + inbox status)
function handleCachingStatusResponse(responseData) {
const serviceStatusEl = document.getElementById('caching-service-status');
const inboxStatusEl = document.getElementById('caching-inbox-status');
if (responseData.status !== 'success') {
const msg = responseData.message || 'Unknown error';
if (serviceStatusEl) {
serviceStatusEl.innerHTML = `<p style="color:red">Service status error: ${escapeHtml(msg)}</p>`;
}
if (inboxStatusEl) {
inboxStatusEl.innerHTML = `<p style="color:red">Inbox status error: ${escapeHtml(msg)}</p>`;
}
return;
}
const data = responseData.data || responseData;
// Helper: pick the first defined value from a list of candidate keys/paths.
function pick(...vals) {
for (const v of vals) {
if (v !== undefined && v !== null) return v;
}
return null;
}
// Service status block (external caching service process)
if (serviceStatusEl) {
const service = data.service || data.caching_service || {};
// Backward-compat: fall back to top-level flat fields if nested object absent.
const enabled = pick(service.enabled, data.caching_enabled, data.enabled);
const running = pick(service.running, data.running);
const connectedRelays = pick(service.connected_relays, data.connected_relays);
const eventsCached = pick(service.events_cached, data.events_cached);
// Per-author until-cursor drain backfill progress.
const bfComplete = pick(service.backfill_authors_complete, data.backfill_authors_complete);
const bfTotal = pick(service.backfill_authors_total, data.backfill_authors_total);
let html = '<ul style="list-style:none;padding:0;margin:0;">';
if (enabled !== null) html += `<li><strong>Enabled:</strong> ${escapeHtml(String(enabled))}</li>`;
if (running !== null) html += `<li><strong>Running:</strong> ${escapeHtml(String(running))}</li>`;
if (connectedRelays !== null) html += `<li><strong>Connected Relays:</strong> ${escapeHtml(String(connectedRelays))}</li>`;
if (eventsCached !== null) html += `<li><strong>Events Cached:</strong> ${escapeHtml(String(eventsCached))}</li>`;
if (bfComplete !== null && bfTotal !== null) {
html += `<li><strong>Backfill:</strong> ${escapeHtml(String(bfComplete))}/${escapeHtml(String(bfTotal))} authors complete</li>`;
}
html += '</ul>';
serviceStatusEl.innerHTML = html;
}
// Inbox status block (relay-owned inbox poller)
if (inboxStatusEl) {
const inbox = data.inbox || data.caching_inbox || {};
// Backward-compat: derive from flat top-level inbox_* fields if nested object absent.
const inboxEnabled = pick(inbox.enabled, data.caching_inbox_enabled);
const inboxRunning = pick(inbox.running);
const queueDepth = pick(inbox.queue_depth,
(data.inbox_pending_live !== undefined && data.inbox_pending_backfill !== undefined)
? (Number(data.inbox_pending_live) + Number(data.inbox_pending_backfill))
: undefined);
const lastPoll = pick(inbox.last_poll);
const totalDequeued = pick(inbox.total_dequeued, data.inbox_total_dequeued);
const totalAccepted = pick(inbox.total_accepted, data.inbox_total_accepted);
const totalRejected = pick(inbox.total_rejected, data.inbox_total_rejected);
const totalDuplicates = pick(inbox.total_duplicates, data.inbox_total_duplicates);
const lastBatchSize = pick(inbox.last_batch_size, data.inbox_last_batch_size);
const pendingLive = pick(inbox.pending_live, data.inbox_pending_live);
const pendingBackfill = pick(inbox.pending_backfill, data.inbox_pending_backfill);
const oldestAge = pick(inbox.oldest_age_seconds, data.inbox_oldest_age_seconds);
let html = '<ul style="list-style:none;padding:0;margin:0;">';
if (inboxEnabled !== null) html += `<li><strong>Inbox Enabled:</strong> ${escapeHtml(String(inboxEnabled))}</li>`;
if (inboxRunning !== null) html += `<li><strong>Inbox Running:</strong> ${escapeHtml(String(inboxRunning))}</li>`;
if (queueDepth !== null) html += `<li><strong>Queue Depth:</strong> ${escapeHtml(String(queueDepth))}</li>`;
if (lastPoll !== null) html += `<li><strong>Last Poll:</strong> ${escapeHtml(String(lastPoll))}</li>`;
if (totalDequeued !== null) html += `<li><strong>Total Dequeued:</strong> ${escapeHtml(String(totalDequeued))}</li>`;
if (totalAccepted !== null) html += `<li><strong>Total Accepted:</strong> ${escapeHtml(String(totalAccepted))}</li>`;
if (totalRejected !== null) html += `<li><strong>Total Rejected:</strong> ${escapeHtml(String(totalRejected))}</li>`;
if (totalDuplicates !== null) html += `<li><strong>Total Duplicates:</strong> ${escapeHtml(String(totalDuplicates))}</li>`;
if (lastBatchSize !== null) html += `<li><strong>Last Batch Size:</strong> ${escapeHtml(String(lastBatchSize))}</li>`;
if (pendingLive !== null) html += `<li><strong>Pending (Live):</strong> ${escapeHtml(String(pendingLive))}</li>`;
if (pendingBackfill !== null) html += `<li><strong>Pending (Backfill):</strong> ${escapeHtml(String(pendingBackfill))}</li>`;
if (oldestAge !== null) html += `<li><strong>Oldest Pending Age:</strong> ${escapeHtml(String(oldestAge))}s</li>`;
html += '</ul>';
inboxStatusEl.innerHTML = html;
}
}
// Fetch caching follows status (per-follow detail with usernames, event counts, relays)
async function fetchCachingFollowsStatus() {
try {
if (!isLoggedIn || !userPubkey) {
throw new Error('Must be logged in to fetch caching follows status');
}
await sendAdminCommand(['system_command', 'caching_follows_status']);
} catch (e) {
console.log('caching_follows_status command failed: ' + e.message);
const tbody = document.getElementById('caching-follows-table-body');
if (tbody) {
tbody.innerHTML = `<tr><td colspan="7" style="text-align: center; color: red; padding: 8px;">Failed to load: ${escapeHtml(e.message || 'Unknown error')}</td></tr>`;
}
}
}
// Handle caching_follows_status response and render the follows table
function handleCachingFollowsResponse(responseData) {
const tbody = document.getElementById('caching-follows-table-body');
const statusEl = document.getElementById('caching-follows-status');
if (!tbody) return;
if (responseData.status !== 'success') {
tbody.innerHTML = `<tr><td colspan="7" style="text-align: center; color: red; padding: 8px;">Error: ${escapeHtml(responseData.message || 'Unknown error')}</td></tr>`;
return;
}
const follows = responseData.follows || [];
const totalFollows = responseData.total_follows || follows.length;
const totalEvents = responseData.total_events_in_db || 0;
// Active backfill target (written by the caching_relay process to PG).
const activePk = responseData.active_backfill_pubkey || '';
const activeRelay = responseData.active_backfill_relay || '';
if (statusEl) {
let html = `<span style="color: var(--text-muted, #888);">${totalFollows} follows, ${totalEvents} total events in DB</span>`;
if (activePk && activeRelay) {
html += `<br><span style="color: var(--accent-color, #ff0);">⚡ Working on: ${escapeHtml(activeRelay)}</span>`;
} else if (activePk) {
html += `<br><span style="color: var(--accent-color, #ff0);">⚡ Working on: ${escapeHtml(activePk.substring(0,12))}...</span>`;
} else {
// No active backfill target — check if all follows are complete.
const allComplete = follows.length > 0 && follows.every(f => f.backfill_complete);
if (allComplete) {
html += `<br><span style="color: green;">✓ Caching complete</span>`;
}
}
statusEl.innerHTML = html;
}
if (follows.length === 0) {
tbody.innerHTML = '<tr><td colspan="6" style="text-align: center; font-style: italic; padding: 12px;">No followed pubkeys found.</td></tr>';
return;
}
// Sort by total_events_in_db descending (most active first)
follows.sort((a, b) => (b.total_events_in_db || 0) - (a.total_events_in_db || 0));
tbody.innerHTML = '';
follows.forEach((follow, index) => {
const row = document.createElement('tr');
row.style.borderBottom = '1px solid var(--border-color, #333)';
// Highlight the row if this is the currently-active backfill target.
const isActiveAuthor = activePk && follow.pubkey === activePk;
if (isActiveAuthor) {
row.style.borderLeft = '4px solid red';
row.style.borderRight = '4px solid red';
row.style.borderTop = '2px solid red';
row.style.borderBottom = '2px solid red';
}
// Name column
const name = follow.name || '';
const nameDisplay = name ? escapeHtml(name) : '<span style="color: var(--text-muted, #888); font-style: italic;">unknown</span>';
// npub column (convert hex to npub)
let npubLink = follow.pubkey ? follow.pubkey.substring(0, 12) + '...' : '-';
try {
if (follow.pubkey && follow.pubkey.length === 64 && /^[0-9a-fA-F]+$/.test(follow.pubkey)) {
const npub = window.NostrTools.nip19.npubEncode(follow.pubkey);
npubLink = `<a href="https://njump.me/${npub}" target="_blank" class="npub-link" style="font-family: 'Courier New', monospace; font-size: 11px;">${npub.substring(0, 20)}...</a>`;
}
} catch (e) { /* keep short hex */ }
// Root badge
const rootBadge = follow.is_root
? '<span style="color: var(--accent-color, #ff0); font-weight: bold;">★ Root</span>'
: '<span style="color: var(--text-muted, #888);">—</span>';
// Events in DB
const totalInDb = follow.total_events_in_db || 0;
// Backfill status
const relayProgress = follow.relay_progress || [];
const doneRelays = relayProgress.filter(r => r.complete).length;
const totalRelays = relayProgress.length;
const bfStatus = follow.backfill_complete
? `<span style="color: green;">✓ Complete</span>`
: `<span style="color: orange;">${doneRelays}/${totalRelays} relays</span>`;
// Outbox relays with per-relay event counts from relay_progress.
// Normalize URLs by stripping trailing slashes for dedup.
const normUrl = u => u ? u.replace(/\/+$/, '') : u;
const relayProgressMap = {};
(follow.relay_progress || []).forEach(rp => {
relayProgressMap[normUrl(rp.relay_url)] = rp;
});
const outboxRelays = (follow.outbox_relays || []).map(normUrl);
// Merge outbox relays with relay_progress relays, deduplicating by
// normalized URL. Relays in relay_progress that aren't in outbox_relays
// are bootstrap relays that were queried but aren't outbox relays.
const allRelays = [...new Set([...outboxRelays, ...Object.keys(relayProgressMap)])];
const outboxDisplay = allRelays.length > 0
? allRelays.map(r => {
const short = escapeHtml(r.replace('wss://', '').replace('https://', '').replace('ws://', ''));
const rp = relayProgressMap[r];
// Highlight if this is the relay currently being queried.
const isActiveRelay = isActiveAuthor && activeRelay && r === normUrl(activeRelay);
const activePrefix = isActiveRelay ? '⚡ ' : '';
if (rp) {
const evCount = rp.events_fetched || 0;
const done = rp.complete ? '✓' : '⏳';
const status = rp.last_status || '';
const statusDisplay = status ? ` <span style="color: var(--text-muted, #888); font-size: 10px;">[${escapeHtml(status)}]</span>` : '';
const color = isActiveRelay
? 'color: red; font-weight: bold;'
: (evCount > 0 ? 'color: green;' : 'color: var(--text-muted, #888);');
return `<span style="${color}">${activePrefix}${done} ${short} (${evCount})${statusDisplay}</span>`;
}
const color = isActiveRelay
? 'color: red; font-weight: bold;'
: 'color: var(--text-muted, #888);';
return `<span style="${color}">${activePrefix}${short}</span>`;
}).join('<br>')
: '<span style="color: var(--text-muted, #888); font-style: italic;">none</span>';
row.innerHTML = `
<td style="padding: 6px 8px;">${nameDisplay}</td>
<td style="padding: 6px 8px;">${npubLink}</td>
<td style="padding: 6px 8px; text-align: center;">${rootBadge}</td>
<td style="padding: 6px 8px; text-align: right;">${totalInDb}</td>
<td style="padding: 6px 8px;">${bfStatus}</td>
<td style="padding: 6px 8px; font-size: 11px;">${outboxDisplay}</td>
`;
tbody.appendChild(row);
});
}
// Start the external caching service
async function startCachingService() {
const statusDiv = document.getElementById('caching-service-control-status');
if (!statusDiv) return;
if (!isLoggedIn || !userPubkey) {
statusDiv.innerHTML = '<span style="color:red">Must be logged in to start caching service</span>';
return;
}
statusDiv.innerHTML = '<span style="color:blue">Starting caching service...</span>';
try {
const command_array = ["system_command", "caching_start_service"];
await sendAdminCommand(command_array);
statusDiv.innerHTML = '<span style="color:green">✓ Caching service start initiated</span>';
log('Caching service start initiated', 'INFO');
} catch (error) {
statusDiv.innerHTML = `<span style="color:red">✗ Failed to start caching service: ${error.message}</span>`;
log(`Failed to start caching service: ${error.message}`, 'ERROR');
}
}
// Stop the external caching service
async function stopCachingService() {
const statusDiv = document.getElementById('caching-service-control-status');
if (!statusDiv) return;
if (!isLoggedIn || !userPubkey) {
statusDiv.innerHTML = '<span style="color:red">Must be logged in to stop caching service</span>';
return;
}
if (!confirm('Are you sure you want to stop the caching service?')) {
return;
}
statusDiv.innerHTML = '<span style="color:blue">Stopping caching service...</span>';
try {
const command_array = ["system_command", "caching_stop_service"];
await sendAdminCommand(command_array);
statusDiv.innerHTML = '<span style="color:green">✓ Caching service stop initiated</span>';
log('Caching service stop initiated', 'INFO');
} catch (error) {
statusDiv.innerHTML = `<span style="color:red">✗ Failed to stop caching service: ${error.message}</span>`;
log(`Failed to stop caching service: ${error.message}`, 'ERROR');
}
}
// Mark a caching config field as dirty (user-modified) so the periodic
// fetchCachingStatus() refresh does not overwrite in-progress edits.
function markCachingFieldDirty() {
this.dataset.cachingDirty = '1';
}
// Clear the dirty flag on all caching config fields. Called after a successful
// apply so subsequent refreshes can repopulate fields from the DB again.
function clearCachingFieldsDirty() {
CACHING_CONFIG_FIELDS.forEach(field => {
const el = document.getElementById(field.field);
if (el) delete el.dataset.cachingDirty;
});
}
// Initialize Caching page event listeners
function initCachingEventListeners() {
const applyBtn = document.getElementById('caching-apply-btn');
const resetBtn = document.getElementById('caching-reset-progress-btn');
const startServiceBtn = document.getElementById('caching-start-service-btn');
const stopServiceBtn = document.getElementById('caching-stop-service-btn');
if (applyBtn) {
applyBtn.addEventListener('click', applyCachingConfig);
}
if (resetBtn) {
resetBtn.addEventListener('click', resetBackfillProgress);
}
if (startServiceBtn) {
startServiceBtn.addEventListener('click', startCachingService);
}
if (stopServiceBtn) {
stopServiceBtn.addEventListener('click', stopCachingService);
}
// Track user edits to caching config fields so the 10s auto-refresh
// (fetchCachingStatus -> populate form) does not clobber in-progress
// edits before "Apply configuration" is pressed. Both 'input' (fires
// on every keystroke) and 'change' (fires on blur/commit for selects
// and number inputs) are tracked.
CACHING_CONFIG_FIELDS.forEach(field => {
const el = document.getElementById(field.field);
if (!el) return;
el.addEventListener('input', markCachingFieldDirty);
el.addEventListener('change', markCachingFieldDirty);
});
console.log('Caching page event listeners initialized');
}
// Initialize caching event listeners when DOM is ready
document.addEventListener('DOMContentLoaded', initCachingEventListeners);
+813
View File
@@ -0,0 +1,813 @@
<!DOCTYPE html>
<?xml version="1.0" encoding="UTF-8"?>
<html lang="en" dir="ltr">
<head>
<meta charset="utf-8" />
<title>TEMPLATE</title>
<link rel="stylesheet" href="./css/client.css" />
<!-- Initialize theme BEFORE any components load -->
<script>
(function () {
const savedTheme = localStorage.getItem('theme');
if (savedTheme === 'dark') {
document.documentElement.classList.add('dark-mode');
if (document.body) {
document.body.classList.add('dark-mode');
}
}
})();
</script>
<link rel="shortcut icon" type="image/x-icon" href="./favicon/favicon-dots2.ico" />
<!-- SVG.js library (required by HamburgerMorphing) -->
<script src="https://cdn.jsdelivr.net/npm/@svgdotjs/svg.js@3.0/dist/svg.min.js"></script>
</head>
<body>
<!-- ================================================================
HAMBURGER BUTTON (Fixed, separate from header)
================================================================
The hamburger button is a fixed element outside the header
to ensure it stays visible above the sidenav (z-index: 10 > 3).
================================================================ -->
<div id="divSvgHam" class="divHeaderButtons">
<!-- HamburgerMorphing will be injected here -->
</div>
<!-- ================================================================
HEADER
================================================================
Standard header with title (center).
================================================================ -->
<div id="divHeader">
<div id="divHeaderFlexLeft">
<!-- Hamburger is now separate fixed element -->
</div>
<div id="divHeaderFlexCenter">
<div class="divHeaderText"></div>
</div>
<div id="divHeaderFlexRight">
<!-- No button in header right - logout is in sidenav footer -->
</div>
</div>
<!-- ================================================================
BODY
================================================================
Main content area. Add your page-specific content here.
================================================================ -->
<div id="divBody">
</div>
<!-- ================================================================
FOOTER
================================================================
Three-section footer layout:
- Left: Relay status animations (HamburgerMorphing instances)
- Center: General status information
- Right: Additional information
================================================================ -->
<div id="divFooter">
<div id="divFooterLeft" class="divFooterBox"></div>
<div id="divFooterCenter" class="divFooterBox"></div>
<div id="divFooterRight" class="divFooterBox"></div>
<div id="divFooterBalance" class="divFooterBox">0 sats</div>
</div>
<!-- ================================================================
SIDENAV
================================================================
Slide-out navigation panel. Opens from left when hamburger clicked.
Uses flexbox layout to pin version bar to bottom.
Includes a version bar footer with theme toggle and logout buttons.
================================================================ -->
<div id="divSideNav">
<div id="divSideNavHeader">
<!-- No close button - use main hamburger to close -->
</div>
<div id="divSideNavBody">
<div id="divFiles"></div>
</div>
<div id="divAiSection" class="sidenavSection">
<div id="divAiSectionTitle" class="sidenavSectionTitle">AI</div>
<div id="divAiList" class="sidenavSectionList">
<div id="divAiProvidersList">No saved providers yet.</div>
</div>
</div>
<div id="divRelaySection">
<div id="divRelaySectionTitle">
リレー
</div>
<div id="divRelayList">
Loading relays...
</div>
</div>
<div id="divBlossomSection">
<div id="divBlossomSectionTitle">ブロッサム</div>
<div id="divBlossomList">Loading blossom servers...</div>
</div>
<div id="divVersionBar">
<span id="versionDisplay">v0.0.1</span>
<div id="divVersionBarButtons">
<button id="themeToggleButton" title="Toggle Dark/Light Mode">
<div id="themeToggleHamburgerContainer"></div>
</button>
<button id="logoutButton" title="Logout">
<div id="logoutHamburgerContainer"></div>
</button>
</div>
</div>
</div>
<!-- ================================================================
REQUIRED SCRIPTS
================================================================
These scripts must be loaded in this order:
1. nostr.bundle.js - Nostr tools library
2. nostr-lite.js - Authentication modal (nostr-login-lite)
================================================================ -->
<script src="./nostr.bundle.js"></script>
<script src="./nostr-lite.js"></script>
<script type="module">
/* ================================================================
IMPORTS
================================================================
Import shared NDK functionality from init-ndk.mjs:
- initNDKPage() - Initialize authentication and worker
- getPubkey() - Get current user's pubkey
- subscribe() - Create NDK subscriptions
- publishEvent() - Publish events via NDK
- disconnect() - Disconnect from worker
- getRelayData() - Get relay connection data
- getRelayStats() - Get relay activity statistics
Import HamburgerMorphing for animated icons
================================================================ */
import {
initNDKPage,
getPubkey, injectHeaderAvatar,
subscribe,
publishEvent,
disconnect,
getVersion,
updateVersionDisplay,
getUserSettings,
patchUserSettings,
onUserSettings
} from './js/init-ndk.mjs';
import { HamburgerMorphing } from "./hamburger_morphing/hamburger.mjs";
import { initFooterRelayStatus, updateFooterRelayStatus, initSidenavRelaySection, updateSidenavRelaySection, setRelayActivityState } from './js/relay-ui.mjs';
import { initBlossomSection, updateBlossomSection } from './js/blossom-ui.mjs';
import { initAiSectionWithLocalConfig } from './js/ai-ui.mjs';
// Version will be loaded asynchronously
const versionInfo = await getVersion();
const VERSION = versionInfo.VERSION;
console.log(`[template.html ${VERSION}] Loading...`);
/* ================================================================
GLOBAL VARIABLES
================================================================
Track state for hamburger menu, relay status, and theme.
================================================================ */
let updateIntervalId = null;
let currentPubkey = null;
/*
AUTH STATE MODEL (Template reference)
------------------------------------------------------------------
This template now demonstrates three auth modes for standalone pages:
- required (default):
Behaves like existing pages: login is required immediately.
- optional:
Page can render public/read-only data without login, but can still
prompt login later for user actions (publish, settings, etc).
- none:
Never auto-login on load (pure public page).
URL behavior in this template:
- If ?auth=required|optional|none is present, it wins.
- Otherwise, if URL includes ?npub=... or ?pubkey=..., mode defaults
to optional because pages with explicit profile targets are commonly
public-readable.
- Otherwise, mode defaults to required.
*/
let isAuthenticated = false;
let authMode = 'required';
let authedPageInitialized = false;
let relayActivityListenersBound = false;
// Hamburger menu
let hamburgerInstance = null;
let isNavOpen = false;
// Version bar buttons
let logoutHamburger = null;
let themeToggleHamburger = null;
let isDarkMode = false;
// App-wide user settings (NIP-78 kind 30078, d:user-settings)
let pageSettings = {};
let unsubscribeUserSettings = null;
/* ================================================================
DOM VARIABLES
================================================================
Cache DOM element references for better performance.
================================================================ */
const divBody = document.getElementById("divBody");
const divSideNav = document.getElementById("divSideNav");
const divSideNavBody = document.getElementById("divSideNavBody");
const divFooterCenter = document.getElementById("divFooterCenter");
const divFooterRight = document.getElementById("divFooterRight");
/* ================================================================
HAMBURGER MENU
================================================================
Initialize and control the animated hamburger menu.
================================================================ */
function initHamburgerMenu() {
hamburgerInstance = new HamburgerMorphing('#divSvgHam', {
foreground: 'var(--primary-color)',
background: 'var(--secondary-color)',
hover: 'var(--accent-color)'
});
hamburgerInstance.animateTo('burger');
}
/* ================================================================
SIDENAV FUNCTIONS
================================================================
Open/close sidenav with hamburger morphing animation.
================================================================ */
function openNav() {
divSideNav.style.zIndex = 3;
divSideNav.style.width = "clamp(400px, 50vw, 600px)";
isNavOpen = true;
if (hamburgerInstance) {
hamburgerInstance.animateTo('arrow_left');
}
// Initialize version bar buttons when sidenav opens (lazy load)
if (!logoutHamburger) {
logoutHamburger = new HamburgerMorphing('#logoutHamburgerContainer', {
size: 24,
foreground: 'var(--primary-color)',
background: 'var(--secondary-color)',
hover: 'var(--accent-color)'
});
logoutHamburger.animateTo('x');
}
if (!themeToggleHamburger) {
themeToggleHamburger = new HamburgerMorphing('#themeToggleHamburgerContainer', {
size: 24,
foreground: 'var(--primary-color)',
background: 'var(--secondary-color)',
hover: 'var(--accent-color)'
});
// Determine current theme
const savedTheme = localStorage.getItem('theme');
isDarkMode = savedTheme === 'dark' || document.body.classList.contains('dark-mode');
const initialShape = isDarkMode ? 'moon' : 'circle';
themeToggleHamburger.animateTo(initialShape);
}
}
function closeNav() {
divSideNav.style.width = "0vw";
divSideNav.style.zIndex = -1;
isNavOpen = false;
if (hamburgerInstance) {
hamburgerInstance.animateTo('burger');
}
}
function toggleNav() {
if (isNavOpen) {
closeNav();
} else {
openNav();
}
}
/* ================================================================
AUTH MODE HELPERS
================================================================
These functions are meant as reusable guidance for future pages.
================================================================ */
function hasTargetPubkeyInUrl() {
const params = new URLSearchParams(window.location.search || '');
const npub = String(params.get('npub') || '').trim();
const pubkey = String(params.get('pubkey') || '').trim();
return Boolean(npub || pubkey);
}
function resolveAuthModeFromUrl() {
const params = new URLSearchParams(window.location.search || '');
const explicitAuth = String(params.get('auth') || '').trim().toLowerCase();
if (explicitAuth === 'required' || explicitAuth === 'optional' || explicitAuth === 'none') {
return explicitAuth;
}
// Convention: explicit target profiles are public-readable by default.
if (hasTargetPubkeyInUrl()) {
return 'optional';
}
return 'required';
}
function isAuthRequiredError(error) {
const message = String(error?.message || error || '').toLowerCase();
return message.includes('authentication required');
}
async function initializeAuthentication(mode) {
// required: existing behavior, throw if auth fails.
if (mode === 'required') {
await initNDKPage();
currentPubkey = await getPubkey();
isAuthenticated = true;
return;
}
// none: public page, no login attempt on load.
if (mode === 'none') {
isAuthenticated = false;
currentPubkey = null;
return;
}
// optional: try silent/normal init; if auth required, continue public.
try {
await initNDKPage();
currentPubkey = await getPubkey();
isAuthenticated = true;
} catch (error) {
if (isAuthRequiredError(error)) {
console.log('[template.html] Optional auth mode: continuing unauthenticated');
isAuthenticated = false;
currentPubkey = null;
return;
}
throw error;
}
}
async function initializeAuthenticatedPageFeatures() {
if (!isAuthenticated || authedPageInitialized) return;
await injectHeaderAvatar(currentPubkey);
console.log('[template.html] Authenticated as:', currentPubkey);
// Hydrate app-wide user settings for this page
try {
pageSettings = await getUserSettings();
} catch (error) {
console.warn('[template.html] getUserSettings failed:', error);
pageSettings = {};
}
// Subscribe to live user settings updates (cross-tab + publish echoes)
if (!unsubscribeUserSettings) {
unsubscribeUserSettings = onUserSettings((settings) => {
pageSettings = settings || {};
// TODO: Re-render page-specific UI from pageSettings here.
});
}
// Initialize relay-dependent UI only once authenticated.
initFooterRelayStatus();
initSidenavRelaySection();
await initBlossomSection();
initAiSectionWithLocalConfig();
await UpdateFooter();
if (!updateIntervalId) {
updateIntervalId = setInterval(UpdateFooter, 1000);
}
// Relay activity listeners only matter after worker init/auth.
if (!relayActivityListenersBound) {
window.addEventListener('ndkRelayActivity', (event) => {
const { relayUrl, activity, stats } = event.detail;
console.log(`[template.html] Relay activity: ${relayUrl} - ${activity}`, stats);
setRelayActivityState(relayUrl, activity);
});
window.addEventListener('message', (event) => {
if (event.data && event.data.type === 'relayActivity') {
const { relayUrl, activity } = event.data;
console.log(`[template.html] Relay activity: ${relayUrl} - ${activity}`);
setRelayActivityState(relayUrl, activity);
}
});
relayActivityListenersBound = true;
}
authedPageInitialized = true;
}
async function promptLoginIfNeeded() {
if (isAuthenticated) return true;
await initNDKPage();
currentPubkey = await getPubkey();
isAuthenticated = true;
await initializeAuthenticatedPageFeatures();
return true;
}
/* ================================================================
UPDATE FOOTER
================================================================
Update footer sections with relay status, pubkey, and other info.
Called periodically by update loop.
================================================================ */
const UpdateFooter = async () => {
try {
// Update relay status visuals in footer and sidenav
await updateFooterRelayStatus();
await updateSidenavRelaySection();
await updateBlossomSection();
// Clear center and right sections
divFooterCenter.innerHTML = '';
divFooterRight.innerHTML = '';
} catch (error) {
console.error('[template.html] Error updating footer:', error);
}
};
/* ================================================================
LOGOUT
================================================================
Complete logout process:
1. Stop update loop
2. Disconnect from NDK worker
3. Logout from nostr-login-lite
4. Clear all storage (localStorage, sessionStorage, IndexedDB)
5. Reload page
================================================================ */
const Logout = async () => {
console.log("[template.html] Starting logout process...");
// Stop the update loop
if (updateIntervalId) {
clearInterval(updateIntervalId);
updateIntervalId = null;
}
// Disconnect from worker
disconnect();
// Logout from nostr-login-lite
if (window.NOSTR_LOGIN_LITE && window.NOSTR_LOGIN_LITE.logout) {
await window.NOSTR_LOGIN_LITE.logout();
}
// Clear all storage
localStorage.clear();
sessionStorage.clear();
// Clear IndexedDB
if (window.indexedDB) {
const databases = await window.indexedDB.databases();
for (const db of databases) {
if (db.name) {
window.indexedDB.deleteDatabase(db.name);
}
}
}
console.log("[template.html] Logged out, reloading page");
location.reload(true);
};
/* ================================================================
EVENT LISTENERS
================================================================
Wire up UI interactions.
Main hamburger button click handler is set up in main() after initialization.
================================================================ */
/* ================================================================
SUBSCRIPTION EXAMPLE
================================================================
Example of how to subscribe to Nostr events:
const sub = subscribe(
{ kinds: [1], authors: [pubkey], limit: 10 },
{ closeOnEose: false, cacheUsage: 'CACHE_FIRST' }
);
Cache usage options:
- 'CACHE_FIRST' - Check cache first, then relays
- 'ONLY_RELAY' - Only query relays
- 'ONLY_CACHE' - Only query cache
- 'PARALLEL' - Query cache and relays simultaneously
Listen for events via window events:
window.addEventListener('ndkEvent', (event) => {
const evt = event.detail;
console.log('Received event:', evt);
});
================================================================ */
/* ================================================================
PUBLISH EXAMPLE
================================================================
Example of how to publish a Nostr event:
const event = {
created_at: Math.floor(Date.now() / 1000),
kind: 1,
tags: [],
content: "Hello, Nostr!"
};
try {
const result = await publishEvent(event);
console.log("✅ Published to:", result.relayResults.successful);
console.log("❌ Failed:", result.relayResults.failed);
console.log("Total relays:", result.totalRelays);
} catch (error) {
console.error("Publish error:", error);
}
Note: Events are automatically signed by the NDK worker using
the message-based signer (which calls window.nostr.signEvent).
================================================================ */
/* ================================================================
USER SETTINGS EXAMPLE (NIP-78)
================================================================
Read/subscribe/write helper pattern for all pages:
// Read latest merged settings (cache + relay hydrated by worker)
const settings = await getUserSettings();
// Subscribe to cross-tab updates
const unsubscribe = onUserSettings((nextSettings) => {
// Re-render page from nextSettings
});
// Patch only your feature namespace
await patchUserSettings({
myFeature: {
someFlag: true
}
});
// On page teardown (if applicable)
// unsubscribe();
================================================================ */
/* ================================================================
INITIALIZATION
================================================================
Main initialization sequence:
1. Initialize hamburger menu
2. Set up hamburger click handler
3. Resolve auth mode from URL/query policy
4. Initialize authentication based on mode
5. Initialize authenticated-only features (if signed in)
6. Set up version bar button listeners
7. Restore sidenav state
8. Update version display
Notes:
- required mode = existing behavior (prompt login on load)
- optional mode = allow public load, login later on demand
- none mode = no auto-login on load
================================================================ */
(async function main() {
console.log("[template.html] Starting initialization...");
try {
// Initialize hamburger menu first
initHamburgerMenu();
// Add click handler to hamburger
const divSvgHam = document.getElementById('divSvgHam');
if (divSvgHam) {
divSvgHam.addEventListener('click', toggleNav);
}
// Initialize version bar buttons
const themeToggleButton = document.getElementById('themeToggleButton');
const logoutButton = document.getElementById('logoutButton');
if (themeToggleButton) {
themeToggleButton.addEventListener('click', () => {
isDarkMode = !isDarkMode;
if (isDarkMode) {
localStorage.setItem('theme', 'dark');
} else {
localStorage.setItem('theme', 'light');
}
// Save sidenav state before reload
localStorage.setItem('sidenavWasOpen', isNavOpen ? 'true' : 'false');
window.location.reload();
});
}
if (logoutButton) {
logoutButton.addEventListener('click', async () => {
try {
// In optional/none modes this doubles as a "Sign in" entry point.
if (!isAuthenticated) {
await promptLoginIfNeeded();
return;
}
await Logout();
} catch (error) {
console.error('Logout/login action failed:', error);
}
});
}
// Resolve and initialize page auth policy.
authMode = resolveAuthModeFromUrl();
console.log('[template.html] Resolved auth mode:', authMode);
await initializeAuthentication(authMode);
// Initialize authenticated features only when signed in.
await initializeAuthenticatedPageFeatures();
/* ============================================================
EXAMPLE: Subscribe to events
============================================================
Uncomment to subscribe to user's notes:
const notesSub = subscribe(
{ kinds: [1], authors: [currentPubkey], limit: 10 },
{ closeOnEose: false, cacheUsage: 'CACHE_FIRST' }
);
console.log("[template.html] Subscribed to kind 1");
============================================================ */
/* ============================================================
EXAMPLE: Listen for events from worker
============================================================
Uncomment to handle incoming events:
window.addEventListener('ndkEvent', (event) => {
const evt = event.detail;
console.log("[template.html] Received event:", evt.kind, evt.pubkey);
if (evt.pubkey === currentPubkey) {
if (evt.kind === 1) {
// Handle note event
console.log("Note:", evt.content);
}
}
});
============================================================ */
/* ============================================================
EXAMPLE: Listen for cached profile
============================================================
Uncomment to handle cached profile data:
window.addEventListener('ndkProfile', (event) => {
console.log("[template.html] Cached profile:", event.detail);
// event.detail contains profile object (name, about, etc.)
});
============================================================ */
// Restore sidenav state if it was open before theme toggle
const sidenavWasOpen = localStorage.getItem('sidenavWasOpen');
if (sidenavWasOpen === 'true') {
localStorage.removeItem('sidenavWasOpen');
openNav();
}
// Optional UX note for public mode pages.
if (!isAuthenticated && (authMode === 'optional' || authMode === 'none')) {
divFooterCenter.textContent = 'Public mode';
divFooterRight.textContent = 'Sign in from side menu for private features';
}
// Update version display
await updateVersionDisplay();
console.log('[template.html] Initialization complete');
} catch (error) {
console.error('[template.html] Initialization failed:', error);
divBody.innerHTML = `<div style="text-align: center; padding: 50px;">
<div style="font-size: 24px; margin-bottom: 20px; color: red;">❌ Authentication Error</div>
<div style="font-size: 16px; color: #666;">${error.message}</div>
<div style="margin-top: 20px;">
<button onclick="location.reload()" style="padding: 10px 20px; font-size: 16px;">Retry</button>
</div>
</div>`;
}
})();
/* ================================================================
WORKER MESSAGE TYPES
================================================================
The NDK worker can send these message types:
1. 'response' - Response to init/subscribe/publish requests
- data.profile - User profile (from init)
- data.relays - User relays (from init)
- data.success - Publish success status
- data.relayResults - Relay publish results
2. 'event' - Nostr event from subscription
- Dispatched as 'ndkEvent' window event
- event.detail contains the Nostr event
3. 'eose' - End of stored events for subscription
- Dispatched as 'ndkEose' window event
- event.detail.subId contains subscription ID
4. 'signRequest' - Request to sign event/encrypt/decrypt
- Handled automatically by init-ndk.mjs
- Calls window.nostr methods and sends response
5. 'error' - Error from worker
- Logged to console automatically
6. 'relayActivity' - Relay read/write activity notification
- Dispatched as 'ndkRelayActivity' window event
- Used to animate relay status icons in footer
================================================================ */
/* ================================================================
DISTRIBUTED ARCHITECTURE NOTES
================================================================
Each page is independently accessible and self-contained:
1. Authentication persists via nostr-login-lite localStorage
- Login once on any page
- All other pages automatically authenticated
2. NDK SharedWorker is shared across all tabs/pages
- Single NDK instance manages all connections
- Subscriptions from all pages handled by one worker
- Events broadcast to all connected pages
3. Dexie cache is shared across all pages
- IndexedDB persists across sessions
- Cache-first queries are fast
- Reduces relay load
4. User settings are centralized and shared
- Worker hydrates kind 30078 (`d:user-settings`) on init
- Pages read via getUserSettings()
- Pages patch via patchUserSettings({ featureNamespace: ... })
- Pages subscribe via onUserSettings() for live updates
5. Each page can be distributed independently
- Copy template.html and customize
- No dependencies on other pages
- Works standalone or as part of suite
6. Message-based signer bridges worker and page
- Worker's NDK uses MessageBasedSigner
- Signer sends sign requests to page
- Page calls window.nostr.signEvent()
- Response sent back to worker
- NDK completes signing and publishing
7. Relay status visualization
- Footer left section shows connected relays
- Each relay has animated icon (HamburgerMorphing)
- Icons morph based on activity (read/write)
- Temporary animations show real-time activity
================================================================ */
</script>
</body>
</html>
+31
View File
@@ -0,0 +1,31 @@
# Database files (from c-relay running in this directory)
*.db
*.db-shm
*.db-wal
# Build artifacts
*.o
*.a
# Binary (built into root, not version-controlled)
caching_relay
caching_relay_static_*
# Logs
*.log
crelay.log
# Test configs (not for version control)
test_config*.jsonc
# Note: caching_relay_config.jsonc IS version-controlled (it's the default
# config + state store). Don't ignore it.
# Docker build context temp (copied nostr_core_lib during static build)
nostr_core_lib/
# Editor
*.swp
*.swo
*~
.vscode/
+125
View File
@@ -0,0 +1,125 @@
# Alpine-based MUSL static binary builder for caching_relay
# Produces a truly portable binary with zero runtime dependencies.
# Adapted from c-relay's Dockerfile.alpine-musl.
ARG DEBUG_BUILD=false
FROM alpine:3.19 AS builder
ARG DEBUG_BUILD=false
# Install build dependencies
RUN apk add --no-cache \
build-base \
musl-dev \
git \
cmake \
pkgconfig \
autoconf \
automake \
libtool \
openssl-dev \
openssl-libs-static \
zlib-dev \
zlib-static \
curl-dev \
curl-static \
sqlite-dev \
linux-headers \
wget \
bash
WORKDIR /build
# Build libsecp256k1 static
RUN cd /tmp && \
git clone https://github.com/bitcoin-core/secp256k1.git && \
cd secp256k1 && \
./autogen.sh && \
./configure --enable-static --disable-shared --prefix=/usr \
CFLAGS="-fPIC" && \
make -j$(nproc) && \
make install && \
rm -rf /tmp/secp256k1
# Build libwebsockets static with minimal features
RUN cd /tmp && \
git clone --depth 1 --branch v4.3.3 https://github.com/warmcat/libwebsockets.git && \
cd libwebsockets && \
mkdir build && cd build && \
cmake .. \
-DLWS_WITH_STATIC=ON \
-DLWS_WITH_SHARED=OFF \
-DLWS_WITH_SSL=ON \
-DLWS_WITHOUT_TESTAPPS=ON \
-DLWS_WITHOUT_TEST_SERVER=ON \
-DLWS_WITHOUT_TEST_CLIENT=ON \
-DLWS_WITHOUT_TEST_PING=ON \
-DLWS_WITH_HTTP2=OFF \
-DLWS_WITH_LIBUV=OFF \
-DLWS_WITH_LIBEVENT=OFF \
-DLWS_IPV6=ON \
-DCMAKE_BUILD_TYPE=Release \
-DCMAKE_INSTALL_PREFIX=/usr \
-DCMAKE_C_FLAGS="-fPIC" && \
make -j$(nproc) && \
make install && \
rm -rf /tmp/libwebsockets
# Copy nostr_core_lib source (sibling project)
COPY nostr_core_lib /build/nostr_core_lib/
# Build nostr_core_lib with the NIPs needed by the relay pool + signer:
# 1 (basic), 6 (keys), 19 (npub bech32), 4 (legacy encryption, signer dep),
# 42 (auth, relay pool dep), 44 (modern encryption, signer dep)
RUN cd nostr_core_lib && \
chmod +x build.sh && \
sed -i 's/CFLAGS="-Wall -Wextra -std=c99 -fPIC -O2"/CFLAGS="-U_FORTIFY_SOURCE -D_FORTIFY_SOURCE=0 -Wall -Wextra -std=c99 -fPIC -O2"/' build.sh && \
rm -f *.o *.a 2>/dev/null || true && \
./build.sh --nips=1,4,6,19,42,44 && \
if [ -f libnostr_core_arm64.a ]; then \
cp libnostr_core_arm64.a libnostr_core.a; \
elif [ -f libnostr_core_x64.a ]; then \
cp libnostr_core_x64.a libnostr_core.a; \
else \
echo "ERROR: No supported nostr_core static library produced"; \
ls -la *.a 2>/dev/null || true; \
exit 1; \
fi
# Copy caching_relay source LAST (only this layer rebuilds on source changes)
COPY src/ /build/src/
# Build caching_relay with full static linking.
# No sqlite (the daemon doesn't use it), no c_utils (not needed).
RUN if [ "$DEBUG_BUILD" = "true" ]; then \
CFLAGS="-g -O2 -DDEBUG -fno-omit-frame-pointer"; \
STRIP_CMD="echo 'Keeping debug symbols'"; \
else \
CFLAGS="-O2"; \
STRIP_CMD="strip /build/caching_relay_static"; \
fi && \
gcc -static $CFLAGS -Wall -Wextra -std=c99 \
-U_FORTIFY_SOURCE -D_FORTIFY_SOURCE=0 \
-I. -Isrc -Inostr_core_lib -Inostr_core_lib/nostr_core \
-Inostr_core_lib/cjson -Inostr_core_lib/nostr_websocket \
src/main.c src/debug.c src/jsonc_strip.c src/config.c src/state.c \
src/follow_graph.c src/relay_sink.c src/live_subscriber.c \
src/backfill.c src/relay_discovery.c \
-o /build/caching_relay_static \
nostr_core_lib/libnostr_core.a \
-lwebsockets -lssl -lcrypto -lsecp256k1 \
-lcurl -lz -lpthread -lm -ldl && \
eval "$STRIP_CMD"
# Verify it's truly static
RUN echo "=== Binary Information ===" && \
file /build/caching_relay_static && \
ls -lh /build/caching_relay_static && \
echo "=== Checking for dynamic dependencies ===" && \
(ldd /build/caching_relay_static 2>&1 || echo "Binary is static") && \
echo "=== Build complete ==="
# Output stage - just the binary
FROM scratch AS output
COPY --from=builder /build/caching_relay_static /caching_relay_static
+67
View File
@@ -0,0 +1,67 @@
# caching_relay Makefile - statically linked C99 binary, c-relay style
# Use bash for reliable glob expansion in recipes (dash handles globs differently).
SHELL := /bin/bash
CC = gcc
CFLAGS = -Wall -Wextra -std=c99 -g -O2
# nostr_core_lib is inside the c-relay-pg project root (one dir up from caching/).
NOSTR_CORE_DIR = ../nostr_core_lib
INCLUDES = -I. -Isrc -I$(NOSTR_CORE_DIR) -I$(NOSTR_CORE_DIR)/nostr_core \
-I$(NOSTR_CORE_DIR)/cjson -I$(NOSTR_CORE_DIR)/nostr_websocket \
-I/usr/include/postgresql
# -lsqlite3: nostr_core_lib's request_validator.x64.o references SQLite symbols;
# we use --whole-archive so the linker pulls in all objects (needed for NIP-42
# cross-references within the archive), which means SQLite must be satisfied.
# No c_utils_lib: nostr_core_lib does not depend on it for the NIPs we use.
# -lpq: PostgreSQL client library for the caching_event_inbox integration.
LIBS = -lwebsockets -lssl -lcrypto -lsecp256k1 -lcurl -lz -ldl -lpthread -lm -lpq -lsqlite3
MAIN_SRC = src/main.c src/debug.c src/jsonc_strip.c src/config.c src/state.c \
src/follow_graph.c src/relay_sink.c src/live_subscriber.c \
src/backfill.c src/relay_discovery.c \
src/pg_inbox.c src/pg_config.c
# Architecture detection
ARCH = $(shell uname -m)
ifeq ($(ARCH),x86_64)
NOSTR_CORE_LIB = $(NOSTR_CORE_DIR)/libnostr_core_x64.a
else ifeq ($(ARCH),aarch64)
NOSTR_CORE_LIB = $(NOSTR_CORE_DIR)/libnostr_core_arm64.a
else ifeq ($(ARCH),arm64)
NOSTR_CORE_LIB = $(NOSTR_CORE_DIR)/libnostr_core_arm64.a
else
NOSTR_CORE_LIB = $(NOSTR_CORE_DIR)/libnostr_core_x64.a
endif
# Binary goes in the c-relay-pg build/ directory so the launcher can find it.
TARGET = ../build/caching_relay
all: $(TARGET)
# Build nostr_core_lib with the NIPs needed by the relay pool + signer:
# 1 (basic), 6 (keys), 19 (npub bech32), 4 (legacy encryption, signer dep),
# 42 (auth, relay pool dep), 44 (modern encryption, signer dep)
$(NOSTR_CORE_LIB):
@echo "Building nostr_core_lib with required NIPs..."
cd $(NOSTR_CORE_DIR) && ./build.sh --nips=1,4,6,19,42,44
$(TARGET): $(MAIN_SRC) $(NOSTR_CORE_LIB)
@echo "Compiling caching_relay for architecture: $(ARCH)"
@# Extract all objects from the static library and link them directly.
@# This avoids archive symbol resolution ordering issues (NIP-42 cross-refs).
@# Use a fixed temp dir and chain all commands with && so failures stop the build.
@rm -rf /tmp/cr_lib && mkdir -p /tmp/cr_lib && \
ar x $(NOSTR_CORE_LIB) --output=/tmp/cr_lib && \
echo "Extracted $$(ls /tmp/cr_lib/*.o | wc -l) objects" && \
$(CC) $(CFLAGS) $(INCLUDES) $(MAIN_SRC) /tmp/cr_lib/*.o -o $(TARGET) $(LIBS) && \
rm -rf /tmp/cr_lib && \
echo "Build complete: $(TARGET)"
clean:
rm -f $(TARGET)
.PHONY: all clean
+297
View File
@@ -0,0 +1,297 @@
# caching_relay
A C99 daemon that caches Nostr events from people you follow into a local relay,
so your Nostr client can point at a single fast local relay instead of fanning
out to dozens of upstream relays.
## What it does
1. Reads a `.jsonc` config file listing your **root npub(s)**, upstream relays,
a local relay URL, and the event kinds to cache.
2. For each root npub, fetches its kind-3 contact list to discover **followed
pubkeys** (your follows + their follows).
3. **Live-subscribes** to new events of the configured kinds from the union of
followed pubkeys.
4. **Backfills** historical events using a progressive window-expansion strategy
(24h → 7d → 30d → 90d → 365d), round-robin per pubkey, throttled to be polite
to upstream relays.
5. **Re-publishes** every fetched event to the local relay via a plain WebSocket
`EVENT` client connection (relay-agnostic - works with c-relay, c-relay-pg,
or any Nostr relay).
6. Persists its backfill progress **in the config file itself** so a restart
resumes where it left off.
## Build
### Static binary (recommended, c-relay style)
Produces a truly portable statically-linked MUSL binary with zero runtime
dependencies:
```bash
./build_static.sh
# or with debug symbols:
./build_static.sh --debug
# or cross-compile for arm64:
./build_static.sh --arch arm64
```
Output: `caching_relay` (in the project root).
Requires Docker. The build runs in an Alpine container that compiles
libsecp256k1, libwebsockets, and nostr_core_lib from source, then statically
links everything.
### Local build (if you have the shared libs installed)
```bash
make
```
Output: `caching_relay` (in the project root).
Requires: `libwebsockets`, `openssl`, `libsecp256k1`, `libcurl`, `zlib` shared
libraries, and a pre-built `../nostr_core_lib/libnostr_core_x64.a`.
## Usage
```bash
./caching_relay -d 3
```
The daemon looks for `caching_relay_config.jsonc` in the current directory by default.
You can override with `-c`:
```bash
./caching_relay -c /path/to/my_config.jsonc -d 3
```
Options:
- `-c, --config <file>` - Path to `.jsonc` config file (default: `./caching_relay_config.jsonc`)
- `-d, --debug <level>` - Log level 1-4 (1=error, 2=warn, 3=info, 4=debug). Default 3.
- `-h, --help` - Show help
Signals:
- `SIGINT` / `SIGTERM` - Graceful shutdown (saves state, closes connections)
- `SIGHUP` - Reload config (preserves backfill state)
## Config file
The config file [`caching_relay_config.jsonc`](caching_relay_config.jsonc) lives in the
project root. It is JSONC (JSON with `//` and `/* */` comments). The daemon
rewrites it as plain JSON when saving state (comments are not preserved on
rewrite).
Key fields:
| Field | Description |
|-------|-------------|
| `root_npubs` | npubs whose kind-3 follows list we crawl |
| `upstream_relays` | bootstrap relays for initial discovery (kind-3, kind-10002). Outbox relays are discovered dynamically via NIP-65. |
| `local_relay` | relay to publish cached events into (never queried) |
| `kinds` | event kinds to cache for followed people (e.g. `[1, 3, 6, 10000, 30023]`) |
| `admin_kinds` | kinds to follow specifically for root (admin) npubs. Use `["*"]` for all kinds. If omitted, admin uses same `kinds` as everyone else. |
| `backfill.window_schedule_seconds` | progressive window sizes in seconds |
| `backfill.events_per_tick` | max events per pubkey per backfill tick |
| `backfill.tick_interval_seconds` | delay between pubkey backfills (throttle) |
| `live.enabled` | enable live subscription |
| `follow_graph_refresh_seconds` | how often to re-resolve the follow graph |
| `state.*` | managed by the daemon - do not hand-edit |
## Architecture
See [`plans/plan.md`](plans/plan.md) for the full architecture document with
Mermaid diagrams.
The daemon uses a **NIP-65 outbox model**: instead of querying a fixed set of
bootstrap relays for everything, it discovers which relays each followed pubkey
actually posts to (via kind 10002 relay lists) and connects to those relays
dynamically. The `upstream_relays` in the config serve only as **bootstrap
relays** -- the initial set used to discover kind-3 and kind-10002 events before
the outbox relay map is built.
### Relay pool selection: minimum covering set
After discovering each followed pubkey's outbox relays (from their kind 10002),
the daemon computes the **minimum set of relays that covers all followed
pubkeys**. This is the classic set cover problem, solved with a greedy
approximation:
1. Build a map: `{relay_url -> set of pubkeys that list it in their 10002}`
2. Greedily pick the relay that covers the most uncovered pubkeys
3. Repeat until all pubkeys are covered (or no more relays to pick)
4. Always include the bootstrap relays in the final set (they may have events
from pubkeys that don't publish a kind 10002)
This minimizes the number of WebSocket connections while ensuring every
followed pubkey is reachable. The daemon logs the selected relay set and which
pubkeys each relay covers.
Pubkeys that have no kind 10002 (or whose 10002 lists no relays) are covered by
the bootstrap relays as a fallback.
```
┌─────────────────────────────────────────┐
│ caching_relay │
│ │
upstream relays │ upstream_pool sink_pool │ local relay
(damus, nos.lol) │ (query + subscribe) (publish only) │ (c-relay)
│ │ │ │ │ │
│ │ follow_graph relay_sink │ │
└──────────►│ live_subscriber ──────►│ ├─────►│
│ backfill ──────────────►│ │ │
│ │ │ │ │
│ state + seen ring │ │ │
└──────────────────────────────────────────┘ │
```
Two `nostr_relay_pool_t` instances:
- **upstream_pool** - holds bootstrap relays initially, then dynamically adds
outbox relays discovered from kind 10002. Used for `query_sync` (kind-3 fetch,
kind-10002 fetch, backfill) and the long-lived live subscription.
- **sink_pool** - holds only the local relay; used exclusively for
`publish_async`. Never queried.
## Startup Flow
### First-time startup (empty local relay)
The daemon has never run before. The local relay has no cached events. The
daemon must bootstrap from the config's `upstream_relays` to discover the
outbox relays for each followed pubkey.
```
START
|
v
Load config (caching_relay_config.jsonc)
| - root_npubs, upstream_relays (bootstrap), kinds, admin_kinds
| - state.backfilled_until == 0 => first-time startup
|
v
Create upstream_pool with bootstrap relays only
Create sink_pool with local_relay
|
v
Phase 1: Resolve follow graph (from bootstrap relays)
| - For each root npub: query_sync kind=3 from bootstrap relays
| - Parse "p" tags => followed pubkey set
| - Log: "follow: resolved N followed pubkeys"
|
v
Phase 2: Discover outbox relays (NIP-65, kind 10002) from bootstrap relays
| - For each followed pubkey: query_sync kind=10002 from bootstrap relays
| - Parse "r" tags => per-pubkey relay list
| - Publish all kind-10002 events to local relay (cache them)
| - Dynamically add discovered relays to upstream_pool
| - Log: "relay_discovery: found N relays from M pubkeys"
| - Log: "upstream_pool: now connected to N relays" + list them
|
v
Phase 3: Open live subscriptions on upstream_pool
| - follows_sub: non-admin pubkeys + regular kinds
| - admin_sub: admin npubs + admin_kinds (or all kinds)
|
v
Phase 4: Begin progressive backfill
| - Window 0: 24h -> query each pubkey from their outbox relays
| - Window 1: 7d -> ...
| - Window 2: 30d -> ...
| - Publish all fetched events to local relay
| - Save state to config file as each window completes
|
v
Steady-state: live sub + periodic follow-graph refresh + periodic
kind-10002 refresh + backfill re-cycle
|
v
SHUTDOWN (SIGINT/SIGTERM) -> save state -> clean exit
```
### Subsequent startup (local relay already has cached events)
The daemon has run before. The local relay already has kind-3 and kind-10002
events cached from the previous run. The daemon can read these from the local
relay directly (fast, no network round-trip to bootstrap relays) and only falls
back to bootstrap relays for pubkeys it cannot find locally.
```
START
|
v
Load config (caching_relay_config.jsonc)
| - state.backfilled_until > 0 => subsequent startup
| - state.current_window_index, backfill_cursor preserved
|
v
Create upstream_pool with bootstrap relays
Create sink_pool with local_relay
|
v
Phase 1: Resolve follow graph (from LOCAL relay first, bootstrap fallback)
| - Query local relay for kind=3 per root npub
| - If found locally: use it (fast, no upstream query)
| - If not found: fall back to bootstrap relays
| - Parse "p" tags => followed pubkey set
| - Log: "follow: resolved N followed pubkeys (L local, B bootstrap)"
|
v
Phase 2: Discover outbox relays (from LOCAL relay first, bootstrap fallback)
| - Query local relay for kind=10002 per followed pubkey
| - If found locally: use it (fast)
| - If not found: fall back to bootstrap relays, cache result to local
| - Parse "r" tags => per-pubkey relay list
| - Dynamically add discovered relays to upstream_pool
| - Log: "relay_discovery: found N relays (L local, B bootstrap)"
| - Log: "upstream_pool: now connected to N relays" + list them
|
v
Phase 3: Open live subscriptions on upstream_pool
| - (same as first-time)
|
v
Phase 4: Resume backfill from saved state
| - Resume at state.current_window_index, state.backfill_cursor
| - No re-pull of already-backfilled windows
| - Continue progressive window expansion from where it left off
|
v
Steady-state (same as first-time)
|
v
SHUTDOWN -> save state -> clean exit
```
### Relay logging
The daemon logs all relay activity so you can see exactly which relays are
being used:
- `upstream: added wss://relay.damus.io` -- each relay added to the pool
- `relay_discovery: found 47 relays from 195 pubkeys` -- outbox discovery summary
- `upstream_pool: 50 relays connected:` -- full relay list at startup
- `relay_discovery: pubkey X -> wss://relay.example.com (local)` -- per-pubkey
relay source (local cache vs bootstrap)
- `backfill: pubkey[3/195] abc123 -> wss://relay.example.com (12 events)` --
which relay served each backfill query
## Dependencies
- [nostr_core_lib](../nostr_core_lib) - built with NIPs 1, 4, 6, 19, 42, 44
- libwebsockets, openssl, libsecp256k1, libcurl, zlib (all statically linked
in the Docker build)
## Testing
Start a local c-relay, then run the daemon:
```bash
# Start local relay (from c-relay project)
cd ../c-relay/build && ./c_relay_static_x86_64 -p 8888 &
# Run the caching daemon (uses ./caching_relay_config.jsonc by default)
./caching_relay -d 3
# Verify events are cached (from another terminal)
nak req -k 1 -l 10 ws://127.0.0.1:8888
```
+1
View File
@@ -0,0 +1 @@
0.0.2
+147
View File
@@ -0,0 +1,147 @@
#!/bin/bash
# Build fully static MUSL binary for caching_relay using Alpine Docker.
# Produces a truly portable binary with zero runtime dependencies.
# Adapted from c-relay's build_static.sh.
set -e
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
DOCKERFILE="$SCRIPT_DIR/Dockerfile.alpine-musl"
# Parse command line arguments
DEBUG_BUILD=false
TARGET_ARCH=""
while [[ $# -gt 0 ]]; do
case "$1" in
--debug)
DEBUG_BUILD=true
shift
;;
--arch)
if [[ -z "$2" ]]; then
echo "ERROR: --arch requires a value"
echo "Usage: $0 [--debug] [--arch <arm64|x86_64>]"
exit 1
fi
case "$2" in
arm64|x86_64)
TARGET_ARCH="$2"
;;
*)
echo "ERROR: Unsupported architecture '$2'"
echo "Supported values: arm64, x86_64"
exit 1
;;
esac
shift 2
;;
*)
echo "ERROR: Unknown argument '$1'"
echo "Usage: $0 [--debug] [--arch <arm64|x86_64>]"
exit 1
;;
esac
done
if [ "$DEBUG_BUILD" = true ]; then
echo "=========================================="
echo "caching_relay MUSL Static Binary Builder (DEBUG MODE)"
echo "=========================================="
else
echo "=========================================="
echo "caching_relay MUSL Static Binary Builder (PRODUCTION MODE)"
echo "=========================================="
fi
echo "Project directory: $SCRIPT_DIR"
echo ""
if ! command -v docker &> /dev/null; then
echo "ERROR: Docker is not installed or not in PATH"
exit 1
fi
if ! docker info &> /dev/null; then
echo "ERROR: Docker daemon is not running or user not in docker group"
exit 1
fi
echo "Docker is available and running"
echo ""
# Detect host architecture
ARCH=$(uname -m)
case "$ARCH" in
x86_64) ARCH="x86_64";;
aarch64) ARCH="arm64";;
arm64) ARCH="arm64";;
esac
if [[ -n "$TARGET_ARCH" ]]; then
ARCH="$TARGET_ARCH"
echo "Using target architecture: $ARCH"
else
echo "Detected host architecture: $ARCH"
fi
echo ""
# nostr_core_lib is inside the c-relay-pg project root (one dir up from caching/).
# Copy it into the Docker build context (excluding .git and build artifacts).
NOSTR_CORE_DIR="$SCRIPT_DIR/../nostr_core_lib"
if [ ! -d "$NOSTR_CORE_DIR" ]; then
echo "ERROR: nostr_core_lib not found at $NOSTR_CORE_DIR"
exit 1
fi
NEEDS_COPY=1
if [ -d "$SCRIPT_DIR/nostr_core_lib" ] && [ ! -L "$SCRIPT_DIR/nostr_core_lib" ]; then
# Already a real directory (e.g. from a previous run); assume it's good.
NEEDS_COPY=0
fi
if [ "$NEEDS_COPY" = "1" ]; then
echo "Copying nostr_core_lib into build context..."
rm -rf "$SCRIPT_DIR/nostr_core_lib"
mkdir -p "$SCRIPT_DIR/nostr_core_lib"
rsync -a --exclude='.git' --exclude='*.a' --exclude='*.o' \
--exclude='.venv*' --exclude='backups' --exclude='verify_*' \
--exclude='rewrite_mirror' --exclude='websocket_debug' \
"$NOSTR_CORE_DIR/" "$SCRIPT_DIR/nostr_core_lib/"
COPIED_NOSTR_CORE=1
fi
# Build args
BUILD_ARGS="--build-arg DEBUG_BUILD=$DEBUG_BUILD"
if [ "$ARCH" = "arm64" ] && [ "$(uname -m)" != "aarch64" ] && [ "$(uname -m)" != "arm64" ]; then
BUILD_ARGS="$BUILD_ARGS --platform linux/arm64"
fi
IMAGE_TAG="caching_relay_builder:latest"
echo "Building Docker image..."
docker build $BUILD_ARGS \
-f "$DOCKERFILE" \
-t "$IMAGE_TAG" \
"$SCRIPT_DIR" 2>&1
BUILD_RC=$?
# Clean up the copied nostr_core_lib to keep the project dir tidy.
if [ "${COPIED_NOSTR_CORE:-0}" = "1" ]; then
echo "Cleaning up copied nostr_core_lib from build context..."
rm -rf "$SCRIPT_DIR/nostr_core_lib"
fi
if [ $BUILD_RC -ne 0 ]; then
exit $BUILD_RC
fi
echo ""
echo "Extracting binary from image..."
# The output stage is FROM scratch with no CMD, so pass an empty command.
CONTAINER_ID=$(docker create "$IMAGE_TAG" "")
docker cp "$CONTAINER_ID:/caching_relay_static" "$SCRIPT_DIR/caching_relay"
docker rm "$CONTAINER_ID" >/dev/null
chmod +x "$SCRIPT_DIR/caching_relay"
echo ""
echo "=== Build complete ==="
ls -lh "$SCRIPT_DIR/caching_relay"
file "$SCRIPT_DIR/caching_relay"
+431
View File
@@ -0,0 +1,431 @@
/*
* caching_relay - relay-by-relay per-author until-cursor drain backfill.
*
* Each (author, relay) pair has its own persistent until_cursor stored in
* the caching_backfill_relay_progress table. Each backfill tick picks the
* next author that still has at least one incomplete relay (round-robin),
* then queries each incomplete relay one at a time with
* since=0 & until=cursor & limit=page_size
* publishes the page, and advances that relay's cursor to
* oldest_event_created_at - 1. A relay is marked complete when a page
* returns fewer than page_size events (the relay is drained for this
* author). An author is marked complete in caching_followed_pubkeys when
* all their relay progress rows are complete.
*/
#define _GNU_SOURCE
#include "backfill.h"
#include "follow_graph.h"
#include "pg_inbox.h"
#include "debug.h"
#include "../nostr_core_lib/cjson/cJSON.h"
#include <string.h>
#include <stdlib.h>
#include <time.h>
/* ------------------------------------------------------------------ */
/* Global access for status reporting */
/* ------------------------------------------------------------------ */
/* Singleton pointer set in cr_backfill_init, used by the accessor
* functions below so the main loop / status reporter can see what
* the backfill is currently working on. */
static cr_backfill_t *g_bf = NULL;
const char *cr_backfill_active_pubkey(void) {
return g_bf ? g_bf->active_pubkey : "";
}
const char *cr_backfill_active_relay(void) {
return g_bf ? g_bf->active_relay : "";
}
int cr_backfill_active_got_eose(void) {
return g_bf ? g_bf->active_relay_got_eose : 0;
}
/* ------------------------------------------------------------------ */
/* EOSE callback for synchronous_query_relays_with_progress */
/* ------------------------------------------------------------------ */
/* Context passed to the query callback. Records whether the relay sent
* EOSE (we got everything) or timed out (partial result). */
typedef struct {
int got_eose; /* 1 if EOSE received, 0 if timed out/errored */
int events_count; /* events reported by callback */
char last_status[256]; /* last status string from callback (may include message) */
} backfill_query_ctx_t;
static void backfill_query_callback(const char *relay_url, const char *status,
const char *event_id, int events_received,
int total_relays, int completed_relays,
void *user_data) {
(void)relay_url; (void)total_relays; (void)completed_relays;
backfill_query_ctx_t *ctx = (backfill_query_ctx_t *)user_data;
if (!ctx || !status) return;
/* Ignore the synthetic "all_complete" status from core_relays.c — it
* is sent AFTER the real EOSE/timeout and would overwrite the real
* status with a misleading "all_complete" string, and clobber
* events_count with total_unique_events. The real per-relay status
* (eose/timeout/error) is what we need for completion decisions. */
if (strcmp(status, "all_complete") == 0) {
return;
}
/* For NOTICE/CLOSED/error, event_id carries the descriptive message
* content (relay's own text for NOTICE/CLOSED, or our transport-level
* diagnosis for error). Include it in the status for display.
* Priority: relay's own response (NOTICE/CLOSED) > our error > bare
* status string. */
if ((strcmp(status, "NOTICE") == 0 || strcmp(status, "CLOSED") == 0) &&
event_id && event_id[0] != '\0') {
snprintf(ctx->last_status, sizeof(ctx->last_status), "%s: %s", status, event_id);
} else if (strcmp(status, "error") == 0) {
if (event_id && event_id[0] != '\0') {
snprintf(ctx->last_status, sizeof(ctx->last_status), "error: %s", event_id);
} else {
snprintf(ctx->last_status, sizeof(ctx->last_status), "error");
}
} else {
snprintf(ctx->last_status, sizeof(ctx->last_status), "%s", status);
}
if (strcmp(status, "eose") == 0) {
ctx->got_eose = 1;
} else if (strcmp(status, "timeout") == 0) {
ctx->got_eose = 0;
} else if (strcmp(status, "error") == 0) {
ctx->got_eose = 0;
}
/* NOTICE/CLOSED don't change got_eose — the relay might still send
* events or EOSE after a NOTICE. */
ctx->events_count = events_received;
}
/* ------------------------------------------------------------------ */
/* Helpers */
/* ------------------------------------------------------------------ */
/* Returns 1 if the PostgreSQL inbox is initialized (PG mode active).
* Probes the config table; a non-NULL result means the connection is up. */
static int pg_mode_active(void) {
char *v = pg_inbox_get_config_value("caching_root_npubs");
if (v) { free(v); return 1; }
char *v2 = pg_inbox_get_config_value("caching_kinds");
if (v2) { free(v2); return 1; }
return 0;
}
/* Find the oldest (minimum) created_at among an array of event JSON objects.
* Returns 1 and sets *out_oldest, or 0 if no events / no valid created_at. */
static int find_oldest_created_at(cJSON **events, int count, long *out_oldest) {
long oldest = 0;
int found = 0;
for (int i = 0; i < count; i++) {
cJSON *ca = cJSON_GetObjectItem(events[i], "created_at");
if (!ca || !cJSON_IsNumber(ca)) continue;
long ts = (long)ca->valuedouble;
if (!found || ts < oldest) {
oldest = ts;
found = 1;
}
}
if (found && out_oldest) *out_oldest = oldest;
return found;
}
/* Build the kinds array for a given pubkey (admin vs regular).
* Returns NULL if no kinds filter should be applied (admin_all_kinds). */
static cJSON *build_kinds(cr_config_t *cfg, const char *pk) {
int is_admin = cr_follow_is_root(cfg, pk);
if (is_admin && cfg->admin_all_kinds) {
return NULL;
}
cJSON *kinds = cJSON_CreateArray();
if (is_admin && cfg->admin_kind_count > 0) {
for (int i = 0; i < cfg->admin_kind_count; i++)
cJSON_AddItemToArray(kinds, cJSON_CreateNumber(cfg->admin_kinds[i]));
} else {
for (int i = 0; i < cfg->kind_count; i++)
cJSON_AddItemToArray(kinds, cJSON_CreateNumber(cfg->kinds[i]));
}
return kinds;
}
/* Resolve the relay URL list to query for a given pubkey. Sets *out_urls and
* *out_n. Caller frees *out_urls (but not the strings, which alias internal
* storage). Returns 0 on success, -1 if no relays available. */
static int resolve_relays(nostr_relay_pool_t *upstream,
const cr_relay_map_t *relay_map,
const char *pk,
const char ***out_urls,
int *out_n) {
*out_urls = NULL;
*out_n = 0;
/* Collect outbox relays for this author (from kind-10002). */
int outbox_count = 0;
const cr_outbox_entry_t *oe = NULL;
if (relay_map) {
oe = cr_relay_map_get_outbox(relay_map, pk);
if (oe) outbox_count = oe->relay_count;
}
/* Also collect all upstream (bootstrap) relays. We query BOTH the
* author's outbox relays AND the bootstrap relays to maximize coverage.
* Different relays may have different subsets of the author's events. */
char **listed = NULL;
nostr_pool_relay_status_t *statuses = NULL;
int upstream_count = nostr_relay_pool_list_relays(upstream, &listed, &statuses);
/* Merge outbox + upstream, deduplicating by URL. */
int max_total = outbox_count + upstream_count;
if (max_total <= 0) {
free(listed);
free(statuses);
return 0;
}
const char **urls = malloc(max_total * sizeof(char *));
if (!urls) { free(listed); free(statuses); return -1; }
int n = 0;
/* Add outbox relays first. */
if (oe) {
for (int j = 0; j < oe->relay_count && n < max_total; j++) {
urls[n++] = oe->relays[j];
}
}
/* Add upstream relays, skipping duplicates. */
for (int j = 0; j < upstream_count && n < max_total; j++) {
int dup = 0;
for (int k = 0; k < n; k++) {
if (strcmp(urls[k], listed[j]) == 0) { dup = 1; break; }
}
if (!dup) {
urls[n++] = listed[j];
}
}
free(listed);
free(statuses);
*out_urls = urls;
*out_n = n;
return 0;
}
/* ------------------------------------------------------------------ */
/* Init */
/* ------------------------------------------------------------------ */
void cr_backfill_init(cr_backfill_t *bf, cr_config_t *cfg) {
(void)cfg;
memset(bf, 0, sizeof(*bf));
g_bf = bf; /* Set singleton for status accessors */
/* In PG mode, check whether any incomplete authors remain in the DB.
* If so, backfill is in progress; otherwise we're in steady state.
* Outside PG mode there is no per-author table, so we just start. */
if (pg_mode_active()) {
int complete = 0, total = 0;
if (pg_inbox_count_backfill_progress(&complete, &total) == 0) {
bf->in_progress = (total > 0 && complete < total) ? 1 : 0;
DEBUG_INFO("backfill: init complete=%d/%d -> in_progress=%d",
complete, total, bf->in_progress);
return;
}
/* Count query failed - assume in progress so we keep trying. */
bf->in_progress = 1;
DEBUG_WARN("backfill: init could not read progress counts, "
"defaulting to in_progress=1");
return;
}
/* Legacy (non-PG) mode: no per-author table; just start. */
bf->in_progress = 1;
DEBUG_INFO("backfill: init (legacy mode) in_progress=1");
}
/* ------------------------------------------------------------------ */
/* Tick */
/* ------------------------------------------------------------------ */
int cr_backfill_tick(cr_backfill_t *bf, cr_config_t *cfg,
nostr_relay_pool_t *upstream,
cr_pubkey_set_t *followed,
cr_sink_t *sink, const cr_relay_map_t *relay_map) {
(void)followed; /* followed set is tracked in the DB now */
(void)relay_map; /* relays are tracked per-author in the DB */
if (!cfg->backfill.enabled) return -2;
if (!bf->in_progress) return -2;
/* Throttle: one query per tick_interval. */
time_t now = time(NULL);
if (bf->last_tick && (now - bf->last_tick) < cfg->backfill.tick_interval_seconds)
return 0;
/* Pick the next author that has at least one incomplete relay progress
* row (round-robin). */
char pk[CR_HEX_LEN];
if (pg_inbox_pick_next_author_with_incomplete_relays(pk, sizeof(pk),
&bf->author_round_cursor) != 0) {
/* No incomplete authors - steady state. */
bf->in_progress = 0;
DEBUG_INFO("backfill: no authors with incomplete relays, steady-state");
return -2;
}
int page_size = cfg->backfill.events_per_tick;
if (page_size < 1) page_size = 500;
bf->last_tick = now;
/* Get the incomplete relays for this author. */
cJSON *relays = pg_inbox_get_incomplete_relays(pk);
if (!relays) {
DEBUG_WARN("backfill: no incomplete relays returned for %s", pk);
/* Author has no relay rows - mark complete so we don't loop. */
pg_inbox_check_and_mark_author_complete(pk);
return 1;
}
int relay_count = cJSON_GetArraySize(relays);
DEBUG_TRACE("backfill: author %s has %d incomplete relay(s)", pk, relay_count);
cr_sink_set_source_class(sink, CR_SINK_CLASS_BACKFILL);
/* Query each incomplete relay one at a time. */
for (int r = 0; r < relay_count; r++) {
cJSON *entry = cJSON_GetArrayItem(relays, r);
if (!entry) continue;
cJSON *url_node = cJSON_GetObjectItem(entry, "relay_url");
cJSON *cur_node = cJSON_GetObjectItem(entry, "until_cursor");
if (!url_node || !cJSON_IsString(url_node)) continue;
const char *relay_url = cJSON_GetStringValue(url_node);
long until_cursor = (cur_node && cJSON_IsNumber(cur_node))
? (long)cur_node->valuedouble : 0;
if (until_cursor == 0) until_cursor = (long)now;
/* Build the filter for this single relay. */
cJSON *filter = cJSON_CreateObject();
cJSON *authors = cJSON_CreateArray();
cJSON_AddItemToArray(authors, cJSON_CreateString(pk));
cJSON_AddItemToObject(filter, "authors", authors);
cJSON *kinds = build_kinds(cfg, pk);
if (kinds) cJSON_AddItemToObject(filter, "kinds", kinds);
cJSON_AddItemToObject(filter, "since", cJSON_CreateNumber(0.0));
cJSON_AddItemToObject(filter, "until", cJSON_CreateNumber((double)until_cursor));
cJSON_AddItemToObject(filter, "limit", cJSON_CreateNumber((double)page_size));
DEBUG_TRACE("backfill: %s @ %s (until=%ld, limit=%d)",
pk, relay_url, until_cursor, page_size);
/* Record the active target for UI status display (both
* in-memory for local accessors and in PG for the relay). */
strncpy(bf->active_pubkey, pk, sizeof(bf->active_pubkey) - 1);
bf->active_pubkey[sizeof(bf->active_pubkey) - 1] = '\0';
strncpy(bf->active_relay, relay_url, sizeof(bf->active_relay) - 1);
bf->active_relay[sizeof(bf->active_relay) - 1] = '\0';
bf->active_relay_got_eose = 0;
pg_inbox_set_active_target(pk, relay_url);
/* Query this single relay using synchronous_query_relays_with_progress
* which gives us an EOSE/timeout callback — the relay's own signal
* of whether it sent everything or we timed out. */
backfill_query_ctx_t qctx = {0, 0};
const char *one_url = relay_url;
int ev_count = 0;
cJSON **events = synchronous_query_relays_with_progress(
&one_url, 1, filter, RELAY_QUERY_ALL_RESULTS,
&ev_count, 30, /* timeout in seconds */
backfill_query_callback, &qctx,
0, NULL /* nip42 disabled, no private key */);
cJSON_Delete(filter);
bf->active_relay_got_eose = qctx.got_eose;
if (!events || ev_count == 0) {
/* No events from this relay - it is drained for this author.
* (ev_count == 0 with EOSE means truly empty; without EOSE it
* means a connection failure — keep incomplete to retry.
* The pg_inbox_update_relay_progress SQL will auto-mark
* complete once consecutive_errors reaches 3.) */
int mark_complete = qctx.got_eose ? 1 : 0;
pg_inbox_update_relay_progress(pk, relay_url, until_cursor,
mark_complete, 0, qctx.last_status);
free(events);
if (!qctx.got_eose) {
/* Error or timeout with no events — surface the
* descriptive status at WARN so operators can see why. */
DEBUG_WARN("backfill: %s @ %s failed: %s (will auto-complete after 3 consecutive errors)",
pk, relay_url, qctx.last_status);
} else {
DEBUG_LOG("backfill: %s @ %s complete (no events, eose)",
pk, relay_url);
}
continue;
}
/* Find oldest timestamp, then publish every event. */
long oldest_ts = 0;
find_oldest_created_at(events, ev_count, &oldest_ts);
for (int k = 0; k < ev_count; k++) {
cr_sink_publish(sink, events[k]);
cJSON_Delete(events[k]);
}
free(events);
bf->events_total += ev_count;
DEBUG_LOG("backfill: %s @ %s -> %d events (until=%ld, oldest=%ld, eose=%d)",
pk, relay_url, ev_count, until_cursor, oldest_ts, qctx.got_eose);
/* Completion decision based on EOSE status:
* - ev_count >= page_size: saturated, advance cursor, keep incomplete
* - ev_count < page_size AND got EOSE: relay is truly drained
* - ev_count < page_size AND no EOSE (timeout): partial result,
* advance cursor but keep incomplete to retry next tick
* - Guard: if cursor didn't advance (oldest_ts >= until_cursor),
* mark complete to avoid infinite loop on identical timestamps. */
long next_cursor = (oldest_ts > 0) ? oldest_ts - 1 : 0;
int cursor_advanced = (oldest_ts > 0 && oldest_ts < until_cursor);
if (ev_count >= page_size) {
pg_inbox_update_relay_progress(pk, relay_url, next_cursor, 0, ev_count, qctx.last_status);
} else if (qctx.got_eose) {
/* Relay sent EOSE with < page_size events — truly drained. */
pg_inbox_update_relay_progress(pk, relay_url, next_cursor, 1, ev_count, qctx.last_status);
} else {
/* Timed out before EOSE — partial result. Keep incomplete
* to retry, unless the cursor didn't advance (stall guard).
* Timeouts DO count toward the consecutive_errors limit
* (a relay that consistently times out is effectively dead
* for backfill purposes), so the SQL will auto-mark
* complete after 3 consecutive timeouts/errors. */
int mark_complete = cursor_advanced ? 0 : 1;
pg_inbox_update_relay_progress(pk, relay_url, next_cursor,
mark_complete, ev_count, qctx.last_status);
DEBUG_WARN("backfill: %s @ %s partial: %s (%d events, will auto-complete after 3 consecutive timeouts/errors)",
pk, relay_url, qctx.last_status, ev_count);
}
}
/* Clear active target — done with this author. */
bf->active_relay[0] = '\0';
pg_inbox_set_active_target(pk, "");
cJSON_Delete(relays);
/* After querying all incomplete relays for this author, check whether
* the author is now fully complete. */
int crc = pg_inbox_check_and_mark_author_complete(pk);
if (crc == 1) {
DEBUG_LOG("backfill: author %s fully complete (all relays drained)", pk);
}
return 1;
}
+58
View File
@@ -0,0 +1,58 @@
/*
* caching_relay - per-author until-cursor drain backfill.
*
* Each followed author has a single persistent cursor (starting at `now`)
* stored in the caching_followed_pubkeys table. Each backfill tick picks
* the next incomplete author (round-robin), queries
* since=0 & until=cursor & limit=page_size
* publishes the page, and sets cursor = oldest_event_created_at - 1.
* Repeat until a page returns fewer than page_size events (beginning of
* the author's history reached), then mark the author complete.
*/
#ifndef CACHING_RELAY_BACKFILL_H
#define CACHING_RELAY_BACKFILL_H
#include "config.h"
#include "state.h"
#include "relay_sink.h"
#include "relay_discovery.h"
#include "../nostr_core_lib/nostr_core/nostr_core.h"
typedef struct {
int in_progress; /* 1 if any incomplete authors remain */
long events_total; /* events pulled across all ticks (diagnostic) */
time_t last_tick; /* last time an author was queried (throttle) */
int author_round_cursor;/* round-robin cursor for author selection */
/* Current backfill target (for UI status display). */
char active_pubkey[65]; /* pubkey currently being backfilled (or "") */
char active_relay[256]; /* relay URL currently being queried (or "") */
int active_relay_got_eose; /* 1 if last query got EOSE, 0 if timed out */
} cr_backfill_t;
/* Global access to the current backfill target for status reporting.
* Returns pointers to static storage in the singleton cr_backfill_t.
* Safe to call from the main loop; not thread-safe but backfill is
* single-threaded. */
const char *cr_backfill_active_pubkey(void);
const char *cr_backfill_active_relay(void);
int cr_backfill_active_got_eose(void);
/* Initialize backfill state. Checks the caching_followed_pubkeys table for
* any incomplete authors; sets in_progress accordingly. */
void cr_backfill_init(cr_backfill_t *bf, cr_config_t *cfg);
/* Perform one backfill tick. Picks the next incomplete author from the DB
* (round-robin), queries one page of history, publishes it to the sink, and
* updates the per-author cursor in the DB.
*
* Returns:
* 1 if a tick was performed (an author was queried)
* 0 if throttled (tick_interval not elapsed) - caller should pump pools
* -2 if backfill is complete (no incomplete authors) - caller goes steady-state
*/
int cr_backfill_tick(cr_backfill_t *bf, cr_config_t *cfg,
nostr_relay_pool_t *upstream,
cr_pubkey_set_t *followed,
cr_sink_t *sink, const cr_relay_map_t *relay_map);
#endif /* CACHING_RELAY_BACKFILL_H */
+290
View File
@@ -0,0 +1,290 @@
/*
* caching_relay - config parsing + persistent state (in the .jsonc file itself)
*/
#define _GNU_SOURCE
#include "config.h"
#include "jsonc_strip.h"
#include "debug.h"
#include "../nostr_core_lib/cjson/cJSON.h"
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <errno.h>
#include <unistd.h>
#include <fcntl.h>
static int read_file(const char *path, char **out, size_t *out_len) {
FILE *f = fopen(path, "rb");
if (!f) {
DEBUG_ERROR("cannot open config '%s': %s", path, strerror(errno));
return -1;
}
if (fseek(f, 0, SEEK_END) != 0) { fclose(f); return -1; }
long sz = ftell(f);
if (sz < 0) { fclose(f); return -1; }
rewind(f);
char *buf = malloc(sz + 1);
if (!buf) { fclose(f); return -1; }
size_t n = fread(buf, 1, sz, f);
fclose(f);
buf[n] = '\0';
*out = buf;
if (out_len) *out_len = n;
return 0;
}
static void copy_string_array(cJSON *arr, char *dst, int max, int *count_out,
int elem_len) {
int count = 0;
if (arr && cJSON_IsArray(arr)) {
cJSON *item;
cJSON_ArrayForEach(item, arr) {
if (count >= max) break;
if (!cJSON_IsString(item)) continue;
const char *s = cJSON_GetStringValue(item);
if (!s) continue;
strncpy(dst + count * elem_len, s, elem_len - 1);
dst[count * elem_len + (elem_len - 1)] = '\0';
count++;
}
}
*count_out = count;
}
static void copy_int_array(cJSON *arr, int *dst, int max, int *count_out) {
int count = 0;
if (arr && cJSON_IsArray(arr)) {
cJSON *item;
cJSON_ArrayForEach(item, arr) {
if (count >= max) break;
if (cJSON_IsNumber(item)) {
dst[count++] = (int)cJSON_GetNumberValue(item);
}
}
}
*count_out = count;
}
static void copy_long_array(cJSON *arr, long *dst, int max, int *count_out) {
int count = 0;
if (arr && cJSON_IsArray(arr)) {
cJSON *item;
cJSON_ArrayForEach(item, arr) {
if (count >= max) break;
if (cJSON_IsNumber(item)) {
dst[count++] = (long)cJSON_GetNumberValue(item);
}
}
}
*count_out = count;
}
int cr_config_load(cr_config_t *cfg, const char *path) {
memset(cfg, 0, sizeof(*cfg));
strncpy(cfg->path, path, sizeof(cfg->path) - 1);
char *raw = NULL;
size_t raw_len = 0;
if (read_file(path, &raw, &raw_len) != 0) return -1;
char *stripped = jsonc_strip_comments(raw, raw_len);
free(raw);
if (!stripped) {
DEBUG_ERROR("failed to strip comments from config");
return -1;
}
cJSON *root = cJSON_Parse(stripped);
free(stripped);
if (!root) {
DEBUG_ERROR("failed to parse config JSON");
return -1;
}
/* root_npubs */
copy_string_array(cJSON_GetObjectItem(root, "root_npubs"),
(char *)cfg->root_npubs, CR_MAX_ROOT_NPUBS,
&cfg->root_npub_count, CR_NPUB_LEN);
/* upstream_relays */
copy_string_array(cJSON_GetObjectItem(root, "upstream_relays"),
(char *)cfg->upstream_relays, CR_MAX_UPSTREAM,
&cfg->upstream_count, CR_URL_LEN);
/* local_relay */
cJSON *local = cJSON_GetObjectItem(root, "local_relay");
if (local && cJSON_IsString(local)) {
strncpy(cfg->local_relay, cJSON_GetStringValue(local), CR_URL_LEN - 1);
}
/* kinds */
copy_int_array(cJSON_GetObjectItem(root, "kinds"),
cfg->kinds, CR_MAX_KINDS, &cfg->kind_count);
/* admin_kinds - kinds to follow specifically for root (admin) npubs.
* Supports [*] to mean "all kinds" (no kind filter for admin). */
cJSON *ak = cJSON_GetObjectItem(root, "admin_kinds");
if (ak && cJSON_IsArray(ak)) {
cJSON *item;
cJSON_ArrayForEach(item, ak) {
if (cJSON_IsString(item)) {
const char *s = cJSON_GetStringValue(item);
if (s && strcmp(s, "*") == 0) {
cfg->admin_all_kinds = 1;
break;
}
}
}
if (!cfg->admin_all_kinds) {
copy_int_array(ak, cfg->admin_kinds, CR_MAX_KINDS, &cfg->admin_kind_count);
}
}
/* backfill */
cJSON *bf = cJSON_GetObjectItem(root, "backfill");
if (bf) {
cfg->backfill.enabled = cJSON_IsTrue(cJSON_GetObjectItem(bf, "enabled"));
cJSON *ept = cJSON_GetObjectItem(bf, "events_per_tick");
if (ept) cfg->backfill.events_per_tick = (int)cJSON_GetNumberValue(ept);
cJSON *tis = cJSON_GetObjectItem(bf, "tick_interval_seconds");
if (tis) cfg->backfill.tick_interval_seconds = (int)cJSON_GetNumberValue(tis);
}
/* live */
cJSON *lv = cJSON_GetObjectItem(root, "live");
if (lv) {
cfg->live.enabled = cJSON_IsTrue(cJSON_GetObjectItem(lv, "enabled"));
cJSON *rsi = cJSON_GetObjectItem(lv, "resubscribe_interval_seconds");
if (rsi) cfg->live.resubscribe_interval_seconds = (int)cJSON_GetNumberValue(rsi);
}
/* follow_graph_refresh_seconds */
cJSON *fgr = cJSON_GetObjectItem(root, "follow_graph_refresh_seconds");
if (fgr) cfg->follow_graph_refresh_seconds = (int)cJSON_GetNumberValue(fgr);
/* state */
cJSON *st = cJSON_GetObjectItem(root, "state");
if (st) {
cJSON *bu = cJSON_GetObjectItem(st, "backfilled_until");
if (bu) cfg->state.backfilled_until = (long)cJSON_GetNumberValue(bu);
}
cJSON_Delete(root);
/* Defaults if missing. */
if (cfg->backfill.events_per_tick == 0) cfg->backfill.events_per_tick = 500;
if (cfg->backfill.tick_interval_seconds == 0) cfg->backfill.tick_interval_seconds = 5;
if (cfg->live.resubscribe_interval_seconds == 0) cfg->live.resubscribe_interval_seconds = 300;
if (cfg->follow_graph_refresh_seconds == 0) cfg->follow_graph_refresh_seconds = 600;
/* Validation. */
if (cfg->root_npub_count == 0) {
DEBUG_ERROR("config: at least one root_npub required");
return -1;
}
if (cfg->upstream_count == 0) {
DEBUG_ERROR("config: at least one upstream_relay required");
return -1;
}
if (cfg->local_relay[0] == '\0') {
DEBUG_ERROR("config: local_relay required");
return -1;
}
if (cfg->kind_count == 0) {
DEBUG_ERROR("config: at least one kind required");
return -1;
}
DEBUG_INFO("config loaded: %d root npubs, %d upstream relays, %d kinds",
cfg->root_npub_count, cfg->upstream_count, cfg->kind_count);
return 0;
}
/* Build a fresh cJSON tree from the in-memory config and write it out.
* We re-emit plain JSON (comments are not preserved on rewrite). */
int cr_config_save_state(cr_config_t *cfg) {
cJSON *root = cJSON_CreateObject();
cJSON *npubs = cJSON_CreateArray();
for (int i = 0; i < cfg->root_npub_count; i++)
cJSON_AddItemToArray(npubs, cJSON_CreateString(cfg->root_npubs[i]));
cJSON_AddItemToObject(root, "root_npubs", npubs);
cJSON *upstream = cJSON_CreateArray();
for (int i = 0; i < cfg->upstream_count; i++)
cJSON_AddItemToArray(upstream, cJSON_CreateString(cfg->upstream_relays[i]));
cJSON_AddItemToObject(root, "upstream_relays", upstream);
cJSON_AddItemToObject(root, "local_relay", cJSON_CreateString(cfg->local_relay));
cJSON *kinds = cJSON_CreateArray();
for (int i = 0; i < cfg->kind_count; i++)
cJSON_AddItemToArray(kinds, cJSON_CreateNumber(cfg->kinds[i]));
cJSON_AddItemToObject(root, "kinds", kinds);
/* admin_kinds */
cJSON *akinds = cJSON_CreateArray();
if (cfg->admin_all_kinds) {
cJSON_AddItemToArray(akinds, cJSON_CreateString("*"));
} else {
for (int i = 0; i < cfg->admin_kind_count; i++)
cJSON_AddItemToArray(akinds, cJSON_CreateNumber(cfg->admin_kinds[i]));
}
cJSON_AddItemToObject(root, "admin_kinds", akinds);
cJSON *bf = cJSON_CreateObject();
cJSON_AddItemToObject(bf, "enabled", cJSON_CreateBool(cfg->backfill.enabled));
cJSON_AddItemToObject(bf, "events_per_tick", cJSON_CreateNumber(cfg->backfill.events_per_tick));
cJSON_AddItemToObject(bf, "tick_interval_seconds", cJSON_CreateNumber(cfg->backfill.tick_interval_seconds));
cJSON_AddItemToObject(root, "backfill", bf);
cJSON *lv = cJSON_CreateObject();
cJSON_AddItemToObject(lv, "enabled", cJSON_CreateBool(cfg->live.enabled));
cJSON_AddItemToObject(lv, "resubscribe_interval_seconds", cJSON_CreateNumber(cfg->live.resubscribe_interval_seconds));
cJSON_AddItemToObject(root, "live", lv);
cJSON_AddItemToObject(root, "follow_graph_refresh_seconds",
cJSON_CreateNumber(cfg->follow_graph_refresh_seconds));
cJSON *st = cJSON_CreateObject();
cJSON_AddItemToObject(st, "backfilled_until", cJSON_CreateNumber(cfg->state.backfilled_until));
cJSON_AddItemToObject(root, "state", st);
char *json = cJSON_Print(root);
cJSON_Delete(root);
if (!json) {
DEBUG_ERROR("config save: failed to serialize");
return -1;
}
/* Atomic write: temp file + rename. */
char tmp[1100];
snprintf(tmp, sizeof(tmp), "%s.tmp", cfg->path);
int fd = open(tmp, O_WRONLY | O_CREAT | O_TRUNC, 0644);
if (fd < 0) {
DEBUG_ERROR("config save: cannot open tmp '%s': %s", tmp, strerror(errno));
free(json);
return -1;
}
size_t jlen = strlen(json);
ssize_t w = write(fd, json, jlen);
close(fd);
free(json);
if (w < 0 || (size_t)w != jlen) {
DEBUG_ERROR("config save: short write");
return -1;
}
if (rename(tmp, cfg->path) != 0) {
DEBUG_ERROR("config save: rename failed: %s", strerror(errno));
return -1;
}
DEBUG_LOG("config state saved: backfilled_until=%ld",
cfg->state.backfilled_until);
return 0;
}
void cr_config_free(cr_config_t *cfg) {
(void)cfg;
}
+79
View File
@@ -0,0 +1,79 @@
/*
* caching_relay - config parsing + persistent state (in the .jsonc file itself)
*/
#ifndef CACHING_RELAY_CONFIG_H
#define CACHING_RELAY_CONFIG_H
#include <stddef.h>
#include <time.h>
/* Maximums to keep things statically sized and simple. */
#define CR_MAX_ROOT_NPUBS 16
#define CR_MAX_UPSTREAM 32
#define CR_MAX_KINDS 32
#define CR_NPUB_LEN 64 /* npub1... bech32, generous */
#define CR_URL_LEN 256
#define CR_HEX_PUBKEY_LEN 65 /* 64 hex chars + NUL */
typedef struct {
int enabled;
int events_per_tick; /* per-query page size (default 500) */
int tick_interval_seconds;
} cr_backfill_config_t;
typedef struct {
int enabled;
int resubscribe_interval_seconds;
} cr_live_config_t;
/* Persistent state. The per-author until-cursor drain model stores all
* backfill progress in the caching_followed_pubkeys table, so the only
* state kept here is the legacy-mode first-time flag (used to drive relay
* discovery caching behavior). */
typedef struct {
long backfilled_until; /* legacy: 0 = first-time startup */
} cr_state_t;
typedef struct {
char root_npubs[CR_MAX_ROOT_NPUBS][CR_NPUB_LEN];
int root_npub_count;
/* Decoded hex pubkeys for root npubs (filled by follow_graph). */
char root_hex[CR_MAX_ROOT_NPUBS][CR_HEX_PUBKEY_LEN];
int root_hex_ready;
char upstream_relays[CR_MAX_UPSTREAM][CR_URL_LEN];
int upstream_count;
char local_relay[CR_URL_LEN];
int kinds[CR_MAX_KINDS];
int kind_count;
/* Kinds to follow specifically for the root (admin) npubs.
* If admin_all_kinds is 1, grab everything (no kind filter). */
int admin_kinds[CR_MAX_KINDS];
int admin_kind_count;
int admin_all_kinds;
cr_backfill_config_t backfill;
cr_live_config_t live;
int follow_graph_refresh_seconds;
cr_state_t state;
/* Path the config was loaded from (for state write-back). */
char path[1024];
} cr_config_t;
/* Load + validate config from a .jsonc file. Returns 0 on success, -1 on error.
* On success cfg->path is set and cfg->state is populated from the file. */
int cr_config_load(cr_config_t *cfg, const char *path);
/* Persist the state sub-object back into the config file (temp + rename).
* Preserves all other config fields. Returns 0 on success, -1 on error. */
int cr_config_save_state(cr_config_t *cfg);
/* Free any heap resources held by cfg (currently none, but kept for future). */
void cr_config_free(cr_config_t *cfg);
#endif /* CACHING_RELAY_CONFIG_H */
+71
View File
@@ -0,0 +1,71 @@
#include "debug.h"
#include <stdarg.h>
#include <string.h>
/**
* @file debug.c
* @brief Debug and logging system implementation
*
* Provides a configurable logging system with timestamp formatting,
* level-based filtering, and optional file:line information.
*/
// Global debug level (default: no debug output)
debug_level_t g_debug_level = DEBUG_LEVEL_NONE;
/**
* @brief Initialize the debug system with a specific level
* @param level Debug level (0-5, clamped to valid range)
*/
void debug_init(int level) {
if (level < 0) level = 0;
if (level > 5) level = 5;
g_debug_level = (debug_level_t)level;
}
/**
* @brief Core logging function
* @param level Debug level for this message
* @param file Source file name (__FILE__)
* @param line Source line number (__LINE__)
* @param format printf-style format string
* @param ... Variable arguments for format string
*/
void debug_log(debug_level_t level, const char* file, int line, const char* format, ...) {
// Get timestamp
time_t now = time(NULL);
struct tm* tm_info = localtime(&now);
char timestamp[32];
strftime(timestamp, sizeof(timestamp), "%Y-%m-%d %H:%M:%S", tm_info);
// Get level string
const char* level_str = "UNKNOWN";
switch (level) {
case DEBUG_LEVEL_ERROR: level_str = "ERROR"; break;
case DEBUG_LEVEL_WARN: level_str = "WARN "; break;
case DEBUG_LEVEL_INFO: level_str = "INFO "; break;
case DEBUG_LEVEL_DEBUG: level_str = "DEBUG"; break;
case DEBUG_LEVEL_TRACE: level_str = "TRACE"; break;
default: break;
}
// Print prefix with timestamp and level
printf("[%s] [%s] ", timestamp, level_str);
// Print source location when debug level is TRACE (5) or higher
if (file && g_debug_level >= DEBUG_LEVEL_TRACE) {
// Extract just the filename (not full path)
const char* filename = strrchr(file, '/');
filename = filename ? filename + 1 : file;
printf("[%s:%d] ", filename, line);
}
// Print message
va_list args;
va_start(args, format);
vprintf(format, args);
va_end(args);
printf("\n");
fflush(stdout);
}
+65
View File
@@ -0,0 +1,65 @@
#ifndef C_UTILS_DEBUG_H
#define C_UTILS_DEBUG_H
/**
* @file debug.h
* @brief Debug and logging system with configurable verbosity levels
*
* Provides a simple, efficient logging system with 5 levels:
* - ERROR: Critical errors
* - WARN: Warnings
* - INFO: Informational messages
* - DEBUG: Debug messages
* - TRACE: Detailed trace with file:line info
*/
#include <stdio.h>
#include <time.h>
// Debug levels
typedef enum {
DEBUG_LEVEL_NONE = 0, /**< No debug output */
DEBUG_LEVEL_ERROR = 1, /**< Critical errors only */
DEBUG_LEVEL_WARN = 2, /**< Warnings and above */
DEBUG_LEVEL_INFO = 3, /**< Informational messages and above */
DEBUG_LEVEL_DEBUG = 4, /**< Debug messages and above */
DEBUG_LEVEL_TRACE = 5 /**< Detailed trace with file:line info */
} debug_level_t;
// Global debug level (set at runtime via CLI)
extern debug_level_t g_debug_level;
/**
* @brief Initialize the debug system
* @param level Debug level (0-5, clamped to valid range)
*/
void debug_init(int level);
/**
* @brief Core logging function
* @param level Debug level for this message
* @param file Source file name (__FILE__)
* @param line Source line number (__LINE__)
* @param format printf-style format string
* @param ... Variable arguments for format string
*/
void debug_log(debug_level_t level, const char* file, int line, const char* format, ...);
// Convenience macros that check level before calling
// Note: TRACE level (5) and above include file:line information for ALL messages
#define DEBUG_ERROR(...) \
do { if (g_debug_level >= DEBUG_LEVEL_ERROR) debug_log(DEBUG_LEVEL_ERROR, __FILE__, __LINE__, __VA_ARGS__); } while(0)
#define DEBUG_WARN(...) \
do { if (g_debug_level >= DEBUG_LEVEL_WARN) debug_log(DEBUG_LEVEL_WARN, __FILE__, __LINE__, __VA_ARGS__); } while(0)
#define DEBUG_INFO(...) \
do { if (g_debug_level >= DEBUG_LEVEL_INFO) debug_log(DEBUG_LEVEL_INFO, __FILE__, __LINE__, __VA_ARGS__); } while(0)
#define DEBUG_LOG(...) \
do { if (g_debug_level >= DEBUG_LEVEL_DEBUG) debug_log(DEBUG_LEVEL_DEBUG, __FILE__, __LINE__, __VA_ARGS__); } while(0)
#define DEBUG_TRACE(...) \
do { if (g_debug_level >= DEBUG_LEVEL_TRACE) debug_log(DEBUG_LEVEL_TRACE, __FILE__, __LINE__, __VA_ARGS__); } while(0)
#endif /* C_UTILS_DEBUG_H */
+110
View File
@@ -0,0 +1,110 @@
/*
* caching_relay - follow graph resolution
*/
#define _GNU_SOURCE
#include "follow_graph.h"
#include "debug.h"
#include <string.h>
#include <stdlib.h>
int cr_follow_is_root(const cr_config_t *cfg, const char *hex) {
if (!cfg->root_hex_ready) return 0;
for (int i = 0; i < cfg->root_npub_count; i++) {
if (strcmp(cfg->root_hex[i], hex) == 0) return 1;
}
return 0;
}
int cr_follow_decode_roots(cr_config_t *cfg) {
for (int i = 0; i < cfg->root_npub_count; i++) {
unsigned char pubkey[32];
if (nostr_decode_npub(cfg->root_npubs[i], pubkey) != NOSTR_SUCCESS) {
DEBUG_ERROR("follow: failed to decode npub '%s'", cfg->root_npubs[i]);
return -1;
}
nostr_bytes_to_hex(pubkey, 32, cfg->root_hex[i]);
}
cfg->root_hex_ready = 1;
DEBUG_INFO("follow: decoded %d root npubs", cfg->root_npub_count);
return 0;
}
/* Parse "p" tags from a kind-3 event and add pubkeys to the set. */
static int parse_p_tags(cJSON *event, cr_pubkey_set_t *followed) {
cJSON *tags = cJSON_GetObjectItem(event, "tags");
if (!tags || !cJSON_IsArray(tags)) return 0;
int added = 0;
cJSON *tag;
cJSON_ArrayForEach(tag, tags) {
if (!cJSON_IsArray(tag)) continue;
cJSON *name = cJSON_GetArrayItem(tag, 0);
if (!name || !cJSON_IsString(name)) continue;
if (strcmp(cJSON_GetStringValue(name), "p") != 0) continue;
cJSON *pk = cJSON_GetArrayItem(tag, 1);
if (!pk || !cJSON_IsString(pk)) continue;
const char *hex = cJSON_GetStringValue(pk);
if (strlen(hex) != 64) continue;
if (cr_pubkey_set_add(followed, hex) == 1) added++;
}
return added;
}
int cr_follow_resolve(cr_config_t *cfg, nostr_relay_pool_t *upstream,
cr_pubkey_set_t *followed) {
if (!cfg->root_hex_ready) {
if (cr_follow_decode_roots(cfg) != 0) return -1;
}
/* Seed the followed set with the root pubkeys themselves. */
for (int i = 0; i < cfg->root_npub_count; i++) {
cr_pubkey_set_add(followed, cfg->root_hex[i]);
}
int total_follows = 0;
for (int i = 0; i < cfg->root_npub_count; i++) {
/* Build filter: authors=[root], kinds=[3], limit=1 (most recent). */
cJSON *filter = cJSON_CreateObject();
cJSON *authors = cJSON_CreateArray();
cJSON_AddItemToArray(authors, cJSON_CreateString(cfg->root_hex[i]));
cJSON_AddItemToObject(filter, "authors", authors);
cJSON *kinds = cJSON_CreateArray();
cJSON_AddItemToArray(kinds, cJSON_CreateNumber(3));
cJSON_AddItemToObject(filter, "kinds", kinds);
cJSON_AddItemToObject(filter, "limit", cJSON_CreateNumber(1));
const char **urls = NULL;
int n = 0;
char **listed = NULL;
nostr_pool_relay_status_t *statuses = NULL;
int listed_count = nostr_relay_pool_list_relays(upstream, &listed, &statuses);
if (listed_count > 0) {
urls = malloc(listed_count * sizeof(char *));
for (int j = 0; j < listed_count; j++) { urls[n++] = listed[j]; }
}
free(listed);
free(statuses);
int ev_count = 0;
cJSON **events = nostr_relay_pool_query_sync(upstream, urls, n,
filter, &ev_count, 15000);
free(urls);
cJSON_Delete(filter);
if (events && ev_count > 0) {
int added = parse_p_tags(events[0], followed);
total_follows += added;
DEBUG_INFO("follow: root %d: kind-3 found, +%d follows (total set %d)",
i, added, followed->count);
for (int k = 0; k < ev_count; k++) cJSON_Delete(events[k]);
free(events);
} else {
DEBUG_WARN("follow: root %d: no kind-3 found", i);
free(events);
}
}
DEBUG_INFO("follow: resolved %d total followed pubkeys (set size %d)",
total_follows, followed->count);
return 0;
}
+27
View File
@@ -0,0 +1,27 @@
/*
* caching_relay - follow graph resolution.
*
* Decodes root npubs (NIP-19) to hex, queries their most recent kind-3
* contact list from the upstream pool, parses the "p" tags, and populates
* the in-memory followed-pubkey set (root pubkeys + their follows).
*/
#ifndef CACHING_RELAY_FOLLOW_GRAPH_H
#define CACHING_RELAY_FOLLOW_GRAPH_H
#include "config.h"
#include "state.h"
#include "../nostr_core_lib/nostr_core/nostr_core.h"
/* Check if a hex pubkey is one of the root (admin) npubs. Returns 1 if yes. */
int cr_follow_is_root(const cr_config_t *cfg, const char *hex);
/* Decode all root_npubs in cfg into cfg->root_hex[]. Returns 0 on success. */
int cr_follow_decode_roots(cr_config_t *cfg);
/* Resolve the follow graph: for each root hex pubkey, query the most recent
* kind-3 from the upstream pool, parse "p" tags, and add root + follows to
* the followed set. Returns 0 on success, -1 on hard failure. */
int cr_follow_resolve(cr_config_t *cfg, nostr_relay_pool_t *upstream,
cr_pubkey_set_t *followed);
#endif /* CACHING_RELAY_FOLLOW_GRAPH_H */
+69
View File
@@ -0,0 +1,69 @@
/*
* caching_relay - strip JSONC comments so cJSON can parse it.
*/
#define _GNU_SOURCE
#include "jsonc_strip.h"
#include <stdlib.h>
#include <string.h>
char *jsonc_strip_comments(const char *input, size_t len) {
if (!input) return NULL;
if (len == 0) len = strlen(input);
/* Worst case: output is same size as input (no comments). +1 for NUL. */
char *out = malloc(len + 1);
if (!out) return NULL;
size_t i = 0, o = 0;
int in_string = 0;
int escape = 0;
while (i < len) {
char c = input[i];
if (in_string) {
out[o++] = c;
if (escape) {
escape = 0;
} else if (c == '\\') {
escape = 1;
} else if (c == '"') {
in_string = 0;
}
i++;
continue;
}
/* Not in a string. */
if (c == '"') {
in_string = 1;
out[o++] = c;
i++;
continue;
}
/* Line comment // */
if (c == '/' && i + 1 < len && input[i + 1] == '/') {
i += 2;
while (i < len && input[i] != '\n') i++;
continue;
}
/* Block comment / * ... * / */
if (c == '/' && i + 1 < len && input[i + 1] == '*') {
i += 2;
while (i < len && !(input[i] == '*' && i + 1 < len && input[i + 1] == '/')) i++;
if (i < len) i += 2; /* skip closing */
/* Preserve a space so tokens don't merge. */
out[o++] = ' ';
continue;
}
out[o++] = c;
i++;
}
out[o] = '\0';
return out;
}
+16
View File
@@ -0,0 +1,16 @@
/*
* caching_relay - strip JSONC comments so cJSON can parse it.
*
* cJSON does not natively understand JSONC. This produces a heap buffer the
* caller must free() containing the comment-stripped JSON. String literals are
* respected so a double-slash inside a string is preserved.
*/
#ifndef CACHING_RELAY_JSONC_STRIP_H
#define CACHING_RELAY_JSONC_STRIP_H
#include <stddef.h>
/* Returns malloc'd buffer (null-terminated) or NULL on alloc failure. */
char *jsonc_strip_comments(const char *input, size_t len);
#endif /* CACHING_RELAY_JSONC_STRIP_H */
+189
View File
@@ -0,0 +1,189 @@
/*
* caching_relay - live subscription on the upstream pool
*/
#define _GNU_SOURCE
#include "live_subscriber.h"
#include "follow_graph.h"
#include "debug.h"
#include <string.h>
#include <stdlib.h>
#include <time.h>
/* Context passed as user_data to the subscription callbacks. */
typedef struct {
cr_sink_t *sink;
cr_live_t *live;
cr_config_t *cfg;
} cr_live_ctx_t;
static cr_live_ctx_t g_live_ctx;
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;
(void)relay_url;
ctx->live->events_received++;
cr_sink_publish(ctx->sink, event);
/* Detect admin kind-3 (contact list) changes from a root pubkey and
* signal the main loop to refresh the follow graph immediately. */
if (ctx->cfg) {
cJSON *kind = cJSON_GetObjectItem(event, "kind");
cJSON *pubkey = cJSON_GetObjectItem(event, "pubkey");
if (kind && cJSON_IsNumber(kind) && kind->valuedouble == 3.0 &&
pubkey && cJSON_IsString(pubkey)) {
if (cr_follow_is_root(ctx->cfg, cJSON_GetStringValue(pubkey))) {
ctx->live->follow_graph_changed = 1;
DEBUG_INFO("live: root kind-3 detected from %s, "
"signaling follow graph refresh",
cJSON_GetStringValue(pubkey));
}
}
}
}
static void live_on_eose(cJSON **events, int event_count, void *user_data) {
(void)events; (void)event_count; (void)user_data;
DEBUG_LOG("live: EOSE (stored events flushed), staying open");
}
/* Build a filter for non-admin followed pubkeys with regular kinds. */
static cJSON *build_follows_filter(cr_config_t *cfg, cr_pubkey_set_t *followed) {
cJSON *filter = cJSON_CreateObject();
cJSON *authors = cJSON_CreateArray();
for (int i = 0; i < followed->count; i++) {
/* Skip admin pubkeys - they get their own subscription. */
if (cr_follow_is_root(cfg, followed->items[i])) continue;
cJSON_AddItemToArray(authors, cJSON_CreateString(followed->items[i]));
}
cJSON_AddItemToObject(filter, "authors", authors);
cJSON *kinds = cJSON_CreateArray();
for (int i = 0; i < cfg->kind_count; i++)
cJSON_AddItemToArray(kinds, cJSON_CreateNumber(cfg->kinds[i]));
cJSON_AddItemToObject(filter, "kinds", kinds);
cJSON_AddItemToObject(filter, "since", cJSON_CreateNumber((double)time(NULL)));
return filter;
}
/* Build a filter for admin (root) pubkeys with admin_kinds (or all kinds). */
static cJSON *build_admin_filter(cr_config_t *cfg) {
cJSON *filter = cJSON_CreateObject();
cJSON *authors = cJSON_CreateArray();
for (int i = 0; i < cfg->root_npub_count; i++)
cJSON_AddItemToArray(authors, cJSON_CreateString(cfg->root_hex[i]));
cJSON_AddItemToObject(filter, "authors", authors);
/* If admin_all_kinds, omit the kinds filter entirely (grab everything). */
if (!cfg->admin_all_kinds && cfg->admin_kind_count > 0) {
cJSON *kinds = cJSON_CreateArray();
for (int i = 0; i < cfg->admin_kind_count; i++)
cJSON_AddItemToArray(kinds, cJSON_CreateNumber(cfg->admin_kinds[i]));
cJSON_AddItemToObject(filter, "kinds", kinds);
}
cJSON_AddItemToObject(filter, "since", cJSON_CreateNumber((double)time(NULL)));
return filter;
}
static int get_relay_urls(nostr_relay_pool_t *upstream, const char ***urls_out) {
char **listed = NULL;
nostr_pool_relay_status_t *statuses = NULL;
int n = nostr_relay_pool_list_relays(upstream, &listed, &statuses);
const char **urls = NULL;
if (n > 0) {
urls = malloc(n * sizeof(char *));
for (int j = 0; j < n; j++) urls[j] = listed[j];
}
free(listed);
free(statuses);
*urls_out = urls;
return n;
}
static nostr_pool_subscription_t *open_subscription(nostr_relay_pool_t *upstream,
cJSON *filter) {
const char **urls = NULL;
int n = get_relay_urls(upstream, &urls);
nostr_pool_subscription_t *sub = nostr_relay_pool_subscribe(
upstream, urls, n, filter,
live_on_event, live_on_eose, &g_live_ctx,
0, 1, NOSTR_POOL_EOSE_FULL_SET, 0, 0);
free(urls);
return sub;
}
static int open_subs(cr_live_t *live, cr_config_t *cfg, nostr_relay_pool_t *upstream,
cr_pubkey_set_t *followed, cr_sink_t *sink) {
g_live_ctx.sink = sink;
g_live_ctx.live = live;
g_live_ctx.cfg = cfg;
int admin_count = 0;
int follows_count = 0;
for (int i = 0; i < followed->count; i++) {
if (cr_follow_is_root(cfg, followed->items[i])) admin_count++;
else follows_count++;
}
/* Follows subscription (non-admin pubkeys, regular kinds). */
if (follows_count > 0) {
cJSON *filter = build_follows_filter(cfg, followed);
live->follows_sub = open_subscription(upstream, filter);
cJSON_Delete(filter);
if (!live->follows_sub) {
DEBUG_ERROR("live: follows subscribe failed");
} else {
DEBUG_INFO("live: follows sub - %d authors, %d kinds",
follows_count, cfg->kind_count);
}
}
/* Admin subscription (root npubs, admin_kinds or all kinds). */
if (admin_count > 0 || cfg->root_npub_count > 0) {
cJSON *filter = build_admin_filter(cfg);
live->admin_sub = open_subscription(upstream, filter);
cJSON_Delete(filter);
if (!live->admin_sub) {
DEBUG_ERROR("live: admin subscribe failed");
} else {
if (cfg->admin_all_kinds) {
DEBUG_INFO("live: admin sub - %d authors, ALL kinds",
cfg->root_npub_count);
} else {
DEBUG_INFO("live: admin sub - %d authors, %d kinds",
cfg->root_npub_count, cfg->admin_kind_count);
}
}
}
live->last_resubscribe = time(NULL);
return 0;
}
int cr_live_open(cr_live_t *live, cr_config_t *cfg, nostr_relay_pool_t *upstream,
cr_pubkey_set_t *followed, cr_sink_t *sink) {
memset(live, 0, sizeof(*live));
return open_subs(live, cfg, upstream, followed, sink);
}
int cr_live_resubscribe(cr_live_t *live, cr_config_t *cfg,
nostr_relay_pool_t *upstream, cr_pubkey_set_t *followed,
cr_sink_t *sink) {
cr_live_close(live);
DEBUG_INFO("live: resubscribing (events so far: %ld)", live->events_received);
return open_subs(live, cfg, upstream, followed, sink);
}
void cr_live_close(cr_live_t *live) {
if (live->follows_sub) {
nostr_pool_subscription_close(live->follows_sub);
live->follows_sub = NULL;
}
if (live->admin_sub) {
nostr_pool_subscription_close(live->admin_sub);
live->admin_sub = NULL;
}
}
+38
View File
@@ -0,0 +1,38 @@
/*
* caching_relay - live subscription on the upstream pool.
*
* Builds filters from the followed-pubkey set + configured kinds, opens
* long-lived subscriptions, and routes on_event to the relay sink.
*
* Two subscriptions are opened:
* 1. "follows" sub: non-admin followed pubkeys + regular kinds
* 2. "admin" sub: root (admin) npubs + admin_kinds (or all kinds if [*])
*/
#ifndef CACHING_RELAY_LIVE_SUBSCRIBER_H
#define CACHING_RELAY_LIVE_SUBSCRIBER_H
#include "config.h"
#include "state.h"
#include "relay_sink.h"
#include "../nostr_core_lib/nostr_core/nostr_core.h"
typedef struct {
nostr_pool_subscription_t *follows_sub; /* non-admin pubkeys, regular kinds */
nostr_pool_subscription_t *admin_sub; /* admin pubkeys, admin_kinds */
long events_received;
time_t last_resubscribe;
int follow_graph_changed; /* set when a root npub publishes a kind-3 */
} cr_live_t;
/* Open the live subscription(s). Returns 0 on success. */
int cr_live_open(cr_live_t *live, cr_config_t *cfg, nostr_relay_pool_t *upstream,
cr_pubkey_set_t *followed, cr_sink_t *sink);
/* Close + reopen the subscription(s). Returns 0 on success. */
int cr_live_resubscribe(cr_live_t *live, cr_config_t *cfg,
nostr_relay_pool_t *upstream, cr_pubkey_set_t *followed,
cr_sink_t *sink);
void cr_live_close(cr_live_t *live);
#endif /* CACHING_RELAY_LIVE_SUBSCRIBER_H */
+629
View File
@@ -0,0 +1,629 @@
/*
* caching_relay - a daemon that caches Nostr events from followed people
* into a local relay.
*
* Architecture: see plans/plan.md. Two nostr_relay_pool_t instances:
* - upstream_pool: query/subscribe to upstream relays
* - sink_pool (in cr_sink_t): publish-only to the local relay
*
* Build: see Makefile. Statically linked C99 binary, c-relay style.
*/
#define _GNU_SOURCE
#include "main.h"
#include "debug.h"
#include "config.h"
#include "state.h"
#include "follow_graph.h"
#include "relay_sink.h"
#include "live_subscriber.h"
#include "backfill.h"
#include "relay_discovery.h"
#include "pg_inbox.h"
#include "pg_config.h"
#include "../nostr_core_lib/nostr_core/nostr_core.h"
#include "../nostr_core_lib/nostr_core/nostr_log.h"
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <signal.h>
#include <time.h>
#include <unistd.h>
#include <getopt.h>
/* Forward nostr_core_lib's internal logging to our debug system. */
static void nostr_log_forwarder(int level, const char *component,
const char *message, void *user_data) {
(void)user_data;
/* Map nostr log levels to our debug levels (they're the same 1-5). */
if (level >= 5) {
DEBUG_TRACE("[nostr:%s] %s", component ? component : "?", message ? message : "");
} else if (level >= 4) {
DEBUG_LOG("[nostr:%s] %s", component ? component : "?", message ? message : "");
} else if (level >= 3) {
DEBUG_INFO("[nostr:%s] %s", component ? component : "?", message ? message : "");
} else if (level >= 2) {
DEBUG_WARN("[nostr:%s] %s", component ? component : "?", message ? message : "");
} else {
DEBUG_ERROR("[nostr:%s] %s", component ? component : "?", message ? message : "");
}
}
/* Initialize per-relay backfill progress rows for every followed pubkey.
* For each author, merges its discovered outbox relays (from relay_map)
* with the bootstrap (upstream) relays from cfg, then inserts one
* caching_backfill_relay_progress row per relay (ON CONFLICT DO NOTHING).
* Safe to call repeatedly - existing rows are left untouched. */
static void init_relay_progress_for_all(const cr_config_t *cfg,
const cr_relay_map_t *relay_map,
const cr_pubkey_set_t *followed) {
if (!followed || followed->count <= 0) return;
/* Bootstrap relay URLs from cfg->upstream_relays. */
const char **bootstrap = NULL;
int bootstrap_n = cfg->upstream_count;
if (bootstrap_n > 0) {
bootstrap = malloc(bootstrap_n * sizeof(char *));
if (!bootstrap) return;
for (int i = 0; i < bootstrap_n; i++)
bootstrap[i] = cfg->upstream_relays[i];
}
for (int i = 0; i < followed->count; i++) {
const char *pk = followed->items[i];
if (!pk || !*pk) continue;
/* Collect this author's outbox relays. */
const cr_outbox_entry_t *oe = NULL;
int outbox_n = 0;
if (relay_map) {
oe = cr_relay_map_get_outbox(relay_map, pk);
if (oe) outbox_n = oe->relay_count;
}
int total = outbox_n + bootstrap_n;
if (total <= 0) continue;
const char **urls = malloc(total * sizeof(char *));
if (!urls) continue;
int n = 0;
if (oe) {
for (int j = 0; j < oe->relay_count && n < total; j++)
urls[n++] = oe->relays[j];
}
for (int j = 0; j < bootstrap_n && n < total; j++) {
int dup = 0;
for (int k = 0; k < n; k++) {
if (strcmp(urls[k], bootstrap[j]) == 0) { dup = 1; break; }
}
if (!dup) urls[n++] = bootstrap[j];
}
if (n > 0) {
pg_inbox_init_relay_progress_for_author(pk, urls, n);
}
free(urls);
}
free(bootstrap);
}
volatile sig_atomic_t g_shutdown = 0;
static volatile sig_atomic_t g_reload = 0;
static void on_signal(int sig) {
if (sig == SIGINT || sig == SIGTERM) g_shutdown = 1;
else if (sig == SIGHUP) g_reload = 1;
}
static void usage(const char *prog) {
fprintf(stderr,
"caching_relay %s - cache Nostr events from followed people into a local relay\n"
"\n"
"Usage: %s [-c <config.jsonc>] [-p <pg-conn>] [options]\n"
"\n"
"Options:\n"
" -c, --config <file> Path to .jsonc config file (default: ./caching_relay_config.jsonc)\n"
" -p, --pg-conn <str> PostgreSQL connection string (libpq format). When provided,\n"
" config is read from the c-relay-pg config table and fetched\n"
" events are inserted into the caching_event_inbox table instead\n"
" of being published via WebSocket to a local relay.\n"
" -d, --debug <level> Log level 0-5 (0=none, 1=error, 2=warn, 3=info, 4=debug, 5=trace). Default 3.\n"
" -r, --restart Reset state to first-time startup (ignore local relay cache,\n"
" re-discover all kind-10002 from bootstrap relays, reset backfill)\n"
" -h, --help Show this help\n"
"\n"
"Without -p, the daemon uses the .jsonc config file and publishes to a local relay\n"
"via WebSocket (legacy mode). With -p, it uses PostgreSQL for config and inbox.\n"
"The config file is also the persistent state store in legacy mode; the daemon\n"
"rewrites it as backfill progresses. See plans/plan.md and caching_relay_config.jsonc.\n",
CR_VERSION, prog);
}
int main(int argc, char **argv) {
const char *config_path = NULL;
const char *pg_conn = NULL;
int log_level = DEBUG_LEVEL_INFO;
int restart = 0;
static struct option longopts[] = {
{"config", required_argument, 0, 'c'},
{"pg-conn", required_argument, 0, 'p'},
{"debug", required_argument, 0, 'd'},
{"restart", no_argument, 0, 'r'},
{"help", no_argument, 0, 'h'},
{0, 0, 0, 0}
};
int opt;
while ((opt = getopt_long(argc, argv, "c:p:d:rh", longopts, NULL)) != -1) {
switch (opt) {
case 'c': config_path = optarg; break;
case 'p': pg_conn = optarg; break;
case 'd': log_level = atoi(optarg); break;
case 'r': restart = 1; break;
case 'h': usage(argv[0]); return 0;
default: usage(argv[0]); return 1;
}
}
/* In PostgreSQL mode, the .jsonc config file is optional. */
if (!config_path && !pg_conn) {
/* Default: look for caching_relay.jsonc in the current directory. */
config_path = "caching_relay_config.jsonc";
if (access(config_path, F_OK) != 0) {
fprintf(stderr, "ERROR: no config specified and default '%s' not found\n\n", config_path);
usage(argv[0]);
return 1;
}
}
if (log_level < 0) log_level = 0;
if (log_level > 5) log_level = 5;
debug_init(log_level);
/* Enable nostr_core_lib internal logging and forward to our debug system.
* This shows the actual WebSocket messages (REQ filters, EVENT responses)
* at debug level 5 (trace). */
nostr_set_log_callback(nostr_log_forwarder, NULL);
nostr_set_log_level((nostr_log_level_t)log_level);
DEBUG_INFO("caching_relay %s starting (config=%s, pg-conn=%s, loglevel=%d%s)",
CR_VERSION,
config_path ? config_path : "(none)",
pg_conn ? "yes" : "no",
log_level, restart ? ", RESTART" : "");
/* Signals. */
struct sigaction sa;
memset(&sa, 0, sizeof(sa));
sa.sa_handler = on_signal;
sigaction(SIGINT, &sa, NULL);
sigaction(SIGTERM, &sa, NULL);
sigaction(SIGHUP, &sa, NULL);
/* Ignore SIGPIPE - relay pool handles its own socket errors. */
signal(SIGPIPE, SIG_IGN);
/* PostgreSQL inbox mode: connect and load config from the config table. */
long config_generation = -1;
if (pg_conn) {
if (pg_inbox_init(pg_conn) != 0) {
DEBUG_ERROR("failed to connect to PostgreSQL");
return 1;
}
config_generation = pg_inbox_get_config_generation();
if (config_generation < 0) {
DEBUG_WARN("could not read caching_config_generation (defaulting to 0)");
config_generation = 0;
}
DEBUG_INFO("config generation: %ld", config_generation);
}
/* Load config. */
cr_config_t cfg;
if (pg_conn) {
if (pg_config_load(&cfg) != 0) {
DEBUG_ERROR("failed to load config from PostgreSQL");
pg_inbox_shutdown();
return 1;
}
} else {
if (cr_config_load(&cfg, config_path) != 0) return 1;
}
/* --restart: reset state to first-time startup. */
if (restart) {
DEBUG_INFO("RESTART: resetting state to first-time startup");
cfg.state.backfilled_until = 0;
if (pg_conn) {
/* Reset per-author backfill progress (caching_followed_pubkeys)
* so the next run starts fresh. The followed set itself is
* preserved; only cursor/completion state is reset. */
pg_inbox_reset_backfill_progress();
} else {
cr_config_save_state(&cfg);
}
}
/* Init crypto. */
if (nostr_crypto_init() != NOSTR_SUCCESS) {
DEBUG_ERROR("nostr_crypto_init failed");
return 1;
}
/* In-memory state. */
cr_seen_ring_t seen;
cr_seen_ring_init(&seen);
cr_pubkey_set_t followed;
cr_pubkey_set_init(&followed);
/* Upstream pool. */
nostr_relay_pool_t *upstream = nostr_relay_pool_create(nostr_pool_reconnect_config_default());
if (!upstream) {
DEBUG_ERROR("failed to create upstream pool");
return 1;
}
for (int i = 0; i < cfg.upstream_count; i++) {
if (nostr_relay_pool_add_relay(upstream, cfg.upstream_relays[i]) != NOSTR_SUCCESS) {
DEBUG_WARN("failed to add upstream relay %s (continuing)", cfg.upstream_relays[i]);
} else {
DEBUG_INFO("upstream: added %s", cfg.upstream_relays[i]);
}
}
/* Sink: WebSocket pool (legacy) or PostgreSQL inbox. */
cr_sink_t sink;
if (pg_conn) {
if (cr_sink_init_pg(&sink, &seen) != 0) {
DEBUG_ERROR("failed to init sink (pg)");
return 1;
}
} else {
if (cr_sink_init(&sink, cfg.local_relay, &seen) != 0) {
DEBUG_ERROR("failed to init sink");
return 1;
}
}
/* Give the sink pool a moment to connect to the local relay before we
* start publishing (relay discovery caches kind-10002 events to local).
* No-op in PostgreSQL inbox mode (cr_sink_pump does nothing). */
if (!pg_conn) {
DEBUG_INFO("waiting for sink connection to establish...");
for (int i = 0; i < 30 && !g_shutdown; i++) {
cr_sink_pump(&sink, 100);
}
}
/* Resolve follow graph. */
if (g_shutdown) goto shutdown;
if (cr_follow_resolve(&cfg, upstream, &followed) != 0) {
DEBUG_ERROR("follow graph resolution failed");
return 1;
}
/* Sync the followed set to the DB so the backfill tick can iterate
* over it (per-author until-cursor drain). Also run the one-time
* migration from the old caching_backfill_progress table. */
if (pg_conn) {
pg_inbox_migrate_backfill_progress();
const char **root_pks = malloc(cfg.root_npub_count * sizeof(char *));
for (int i = 0; i < cfg.root_npub_count; i++)
root_pks[i] = cfg.root_hex[i];
pg_inbox_sync_followed_pubkeys((const char **)followed.items,
followed.count, root_pks,
cfg.root_npub_count);
free(root_pks);
}
if (g_shutdown) goto shutdown;
/* Discover outbox relays (NIP-65) and compute minimum covering set. */
int is_first_time = (cfg.state.backfilled_until == 0);
cr_relay_map_t relay_map;
if (cr_relay_discovery_run(&relay_map, &cfg, upstream, &sink, &followed,
is_first_time) != 0) {
DEBUG_WARN("relay discovery failed, continuing with bootstrap relays only");
memset(&relay_map, 0, sizeof(relay_map));
}
/* Add discovered outbox relays to the upstream pool. */
for (int i = 0; i < relay_map.selected_count; i++) {
/* Check if already in the pool (bootstrap relays may already be there). */
char **listed = NULL;
nostr_pool_relay_status_t *statuses = NULL;
int n = nostr_relay_pool_list_relays(upstream, &listed, &statuses);
int already = 0;
for (int j = 0; j < n; j++) {
if (strcmp(listed[j], relay_map.selected_relays[i]) == 0) {
already = 1; break;
}
}
free(listed);
free(statuses);
if (!already) {
if (nostr_relay_pool_add_relay(upstream, relay_map.selected_relays[i]) == NOSTR_SUCCESS) {
DEBUG_INFO("upstream: added outbox relay %s", relay_map.selected_relays[i]);
}
}
}
/* Log final upstream pool. */
{
char **listed = NULL;
nostr_pool_relay_status_t *statuses = NULL;
int n = nostr_relay_pool_list_relays(upstream, &listed, &statuses);
DEBUG_INFO("upstream_pool: %d relays connected:", n);
for (int j = 0; j < n; j++) {
DEBUG_INFO(" %s", listed[j]);
}
free(listed);
free(statuses);
}
/* Create per-relay backfill progress rows for every followed pubkey
* (outbox relays + bootstrap relays). Existing rows are left untouched
* so cursor/completion state is preserved across restarts. */
if (pg_conn) {
init_relay_progress_for_all(&cfg, &relay_map, &followed);
}
/* Open live subscription. */
cr_live_t live;
if (cfg.live.enabled) {
if (cr_live_open(&live, &cfg, upstream, &followed, &sink) != 0) {
DEBUG_WARN("live subscription failed to open (will retry on resubscribe)");
}
} else {
memset(&live, 0, sizeof(live));
}
/* Init backfill. */
cr_backfill_t bf;
cr_backfill_init(&bf, &cfg);
time_t last_follow_refresh = time(NULL);
time_t last_state_save = time(NULL);
time_t last_status_heartbeat = 0;
/* Initial status heartbeat in PostgreSQL mode. */
if (pg_conn) {
int bf_complete = 0, bf_total = 0;
pg_inbox_count_backfill_progress(&bf_complete, &bf_total);
pg_inbox_update_status("starting", config_generation, (long)time(NULL),
followed.count, relay_map.selected_count, 0,
bf_complete, bf_total, 0, 0, NULL, 0);
}
DEBUG_INFO("entering main loop");
while (!g_shutdown) {
if (g_reload) {
g_reload = 0;
DEBUG_INFO("SIGHUP: reloading config (state preserved)");
cr_config_t newcfg;
int reload_ok = 0;
if (pg_conn) {
if (pg_config_load(&newcfg) == 0 &&
cr_follow_decode_roots(&newcfg) == 0) reload_ok = 1;
} else if (config_path) {
if (cr_config_load(&newcfg, config_path) == 0) reload_ok = 1;
}
if (reload_ok) {
/* Preserve runtime state across reload. */
newcfg.state = cfg.state;
cr_config_free(&cfg);
cfg = newcfg;
DEBUG_INFO("config reloaded");
/* Force an immediate follow-graph refresh so any new
* root npubs are picked up right away. */
last_follow_refresh = 0;
} else {
DEBUG_ERROR("config reload failed, keeping old config");
}
}
/* PostgreSQL: check for config generation change and reload. */
if (pg_conn) {
int changed = pg_config_generation_changed(config_generation);
if (changed == 1) {
long new_gen = pg_inbox_get_config_generation();
DEBUG_INFO("config generation changed (%ld -> %ld), reloading",
config_generation, new_gen);
cr_config_t newcfg;
if (pg_config_load(&newcfg) == 0) {
/* Decode root npubs to hex — pg_config_load fills
* root_npubs[] but NOT root_hex[] / root_hex_ready.
* Without this, cr_follow_is_root() returns 0 for
* everything and the new root's follows are never
* resolved. */
if (cr_follow_decode_roots(&newcfg) != 0) {
DEBUG_ERROR("config reload: failed to decode root npubs, keeping old config");
cr_config_free(&newcfg);
} else {
newcfg.state = cfg.state;
cr_config_free(&cfg);
cfg = newcfg;
config_generation = new_gen;
DEBUG_INFO("config reloaded from PostgreSQL");
/* Force an immediate follow-graph refresh so the
* new root npub's follows are picked up right
* away (instead of waiting up to
* follow_graph_refresh_seconds). */
last_follow_refresh = 0;
/* Resubscribe live with new config. */
if (cfg.live.enabled) {
cr_live_resubscribe(&live, &cfg, upstream, &followed, &sink);
}
}
} else {
DEBUG_ERROR("PostgreSQL config reload failed, keeping old config");
}
} else if (changed < 0) {
DEBUG_WARN("config generation check failed");
}
}
/* Pump upstream pool (drives live subscription callbacks). */
nostr_relay_pool_run(upstream, 100);
/* Pump sink pool (flush publish callbacks). */
cr_sink_pump(&sink, 50);
/* Backfill tick. */
int brc = cr_backfill_tick(&bf, &cfg, upstream, &followed, &sink, &relay_map);
(void)brc;
/* Immediate follow-graph refresh when a root npub publishes a new
* kind-3 contact list (detected by the live subscriber). */
if (cfg.live.enabled && live.follow_graph_changed) {
live.follow_graph_changed = 0;
DEBUG_INFO("live: admin kind-3 detected, refreshing follow graph immediately");
cr_pubkey_set_t new_followed;
cr_pubkey_set_init(&new_followed);
if (cr_follow_resolve(&cfg, upstream, &new_followed) == 0) {
int changed = (new_followed.count != followed.count);
if (!changed) {
for (int i = 0; i < followed.count; i++) {
if (!cr_pubkey_set_contains(&new_followed, followed.items[i])) {
changed = 1; break;
}
}
}
cr_pubkey_set_free(&followed);
followed = new_followed;
if (pg_conn) {
const char **root_pks = malloc(cfg.root_npub_count * sizeof(char *));
for (int i = 0; i < cfg.root_npub_count; i++)
root_pks[i] = cfg.root_hex[i];
pg_inbox_sync_followed_pubkeys((const char **)followed.items,
followed.count, root_pks,
cfg.root_npub_count);
free(root_pks);
/* Create relay progress rows for newly added follows.
* Existing rows are preserved (ON CONFLICT DO NOTHING). */
init_relay_progress_for_all(&cfg, &relay_map, &followed);
}
if (changed && cfg.live.enabled) {
cr_live_resubscribe(&live, &cfg, upstream, &followed, &sink);
}
/* Reset the backfill in_progress flag in case we had entered
* steady-state; new follows need to be drained. */
bf.in_progress = 1;
last_follow_refresh = time(NULL);
} else {
DEBUG_WARN("immediate follow graph refresh failed");
cr_pubkey_set_free(&new_followed);
}
}
/* Periodic follow-graph refresh. */
time_t now = time(NULL);
if (cfg.follow_graph_refresh_seconds > 0 &&
(now - last_follow_refresh) >= cfg.follow_graph_refresh_seconds) {
DEBUG_INFO("refreshing follow graph");
cr_pubkey_set_t new_followed;
cr_pubkey_set_init(&new_followed);
if (cr_follow_resolve(&cfg, upstream, &new_followed) == 0) {
/* If the set changed, resubscribe live. */
int changed = (new_followed.count != followed.count);
if (!changed) {
for (int i = 0; i < followed.count; i++) {
if (!cr_pubkey_set_contains(&new_followed, followed.items[i])) {
changed = 1; break;
}
}
}
cr_pubkey_set_free(&followed);
followed = new_followed;
/* Sync the refreshed followed set to the DB so newly added
* follows get backfilled and dropped follows stop. Existing
* cursor/completion state is preserved (upsert only touches
* is_root and last_seen). */
if (pg_conn) {
const char **root_pks = malloc(cfg.root_npub_count * sizeof(char *));
for (int i = 0; i < cfg.root_npub_count; i++)
root_pks[i] = cfg.root_hex[i];
pg_inbox_sync_followed_pubkeys((const char **)followed.items,
followed.count, root_pks,
cfg.root_npub_count);
free(root_pks);
/* Create relay progress rows for newly added follows.
* Existing rows are preserved (ON CONFLICT DO NOTHING). */
init_relay_progress_for_all(&cfg, &relay_map, &followed);
}
if (changed && cfg.live.enabled) {
cr_live_resubscribe(&live, &cfg, upstream, &followed, &sink);
}
/* Reset the backfill in_progress flag in case we had entered
* steady-state; new follows need to be drained. The backfill
* tick will quickly re-enter steady-state if there are no
* incomplete authors, so this is cheap. */
bf.in_progress = 1;
} else {
DEBUG_WARN("follow graph refresh failed");
cr_pubkey_set_free(&new_followed);
}
last_follow_refresh = now;
}
/* Periodic live resubscribe. */
if (cfg.live.enabled && cfg.live.resubscribe_interval_seconds > 0 &&
(now - live.last_resubscribe) >= cfg.live.resubscribe_interval_seconds) {
cr_live_resubscribe(&live, &cfg, upstream, &followed, &sink);
}
/* Periodic state save (in case backfill didn't just save). */
if ((now - last_state_save) >= 60) {
if (!pg_conn) cr_config_save_state(&cfg);
last_state_save = now;
}
/* PostgreSQL: periodic status heartbeat. */
if (pg_conn && (now - last_status_heartbeat) >= 15) {
long events_fetched = live.events_received + bf.events_total;
long inbox_inserts = sink.published_ok;
int connected = 0;
{
char **listed = NULL;
nostr_pool_relay_status_t *statuses = NULL;
int n = nostr_relay_pool_list_relays(upstream, &listed, &statuses);
for (int j = 0; j < n; j++) {
if (statuses[j] == NOSTR_POOL_RELAY_CONNECTED) connected++;
}
free(listed);
free(statuses);
}
int bf_complete = 0, bf_total = 0;
pg_inbox_count_backfill_progress(&bf_complete, &bf_total);
pg_inbox_update_status("running", config_generation, (long)now,
followed.count, relay_map.selected_count,
connected, bf_complete, bf_total,
events_fetched, inbox_inserts, NULL, 0);
last_status_heartbeat = now;
}
}
/* Graceful shutdown. */
shutdown:
DEBUG_INFO("shutting down...");
cr_live_close(&live);
if (!pg_conn) cr_config_save_state(&cfg);
if (pg_conn) {
int bf_complete = 0, bf_total = 0;
pg_inbox_count_backfill_progress(&bf_complete, &bf_total);
pg_inbox_update_status("stopped", config_generation, (long)time(NULL),
followed.count, relay_map.selected_count, 0,
bf_complete, bf_total,
live.events_received + bf.events_total,
sink.published_ok, NULL, 0);
}
cr_sink_destroy(&sink);
nostr_relay_pool_destroy(upstream);
cr_pubkey_set_free(&followed);
cr_relay_map_free(&relay_map);
cr_config_free(&cfg);
if (pg_conn) pg_inbox_shutdown();
nostr_crypto_cleanup();
DEBUG_INFO("clean exit");
return 0;
}
+16
View File
@@ -0,0 +1,16 @@
/*
* caching_relay - main header
*
* Version information is auto-updated by increment_and_push.sh.
* Git tags are the source of truth for versioning.
*/
#ifndef CACHING_RELAY_MAIN_H
#define CACHING_RELAY_MAIN_H
// Version information (auto-updated by increment_and_push.sh)
#define CR_VERSION_MAJOR 0
#define CR_VERSION_MINOR 0
#define CR_VERSION_PATCH 2
#define CR_VERSION "v0.0.2"
#endif /* CACHING_RELAY_MAIN_H */
+218
View File
@@ -0,0 +1,218 @@
/*
* caching_relay - read caching configuration from the c-relay-pg PostgreSQL
* config table and populate the existing cr_config_t structure.
*/
#define _GNU_SOURCE
#include "pg_config.h"
#include "pg_inbox.h"
#include "debug.h"
#include <stdlib.h>
#include <string.h>
/* ------------------------------------------------------------------ */
/* Helpers */
/* ------------------------------------------------------------------ */
/* Split a comma-separated string into a string array (fixed-size rows).
* Trims leading/trailing whitespace from each token. */
static void split_csv_str(const char *s, char *dst, int max, int elem_len,
int *count_out) {
int count = 0;
if (!s) { *count_out = 0; return; }
/* Work on a mutable copy because strtok_r modifies its input. */
char *copy = strdup(s);
if (!copy) { *count_out = 0; return; }
char *saveptr = NULL;
char *tok = strtok_r(copy, ",", &saveptr);
while (tok && count < max) {
/* Trim leading spaces. */
while (*tok == ' ' || *tok == '\t') tok++;
/* Trim trailing spaces. */
char *end = tok + strlen(tok) - 1;
while (end > tok && (*end == ' ' || *end == '\t' || *end == '\r' || *end == '\n')) {
*end-- = '\0';
}
if (*tok != '\0') {
strncpy(dst + count * elem_len, tok, elem_len - 1);
dst[count * elem_len + (elem_len - 1)] = '\0';
count++;
}
tok = strtok_r(NULL, ",", &saveptr);
}
free(copy);
*count_out = count;
}
/* Split a comma-separated string of integers into an int array. */
static void split_csv_int(const char *s, int *dst, int max, int *count_out) {
int count = 0;
if (!s) { *count_out = 0; return; }
char *copy = strdup(s);
if (!copy) { *count_out = 0; return; }
char *saveptr = NULL;
char *tok = strtok_r(copy, ",", &saveptr);
while (tok && count < max) {
while (*tok == ' ' || *tok == '\t') tok++;
if (*tok != '\0') {
dst[count++] = atoi(tok);
}
tok = strtok_r(NULL, ",", &saveptr);
}
free(copy);
*count_out = count;
}
/* Split a comma-separated string of longs into a long array. */
static void split_csv_long(const char *s, long *dst, int max, int *count_out) {
int count = 0;
if (!s) { *count_out = 0; return; }
char *copy = strdup(s);
if (!copy) { *count_out = 0; return; }
char *saveptr = NULL;
char *tok = strtok_r(copy, ",", &saveptr);
while (tok && count < max) {
while (*tok == ' ' || *tok == '\t') tok++;
if (*tok != '\0') {
dst[count++] = strtol(tok, NULL, 10);
}
tok = strtok_r(NULL, ",", &saveptr);
}
free(copy);
*count_out = count;
}
/* Read a config key as a freshly-allocated string. Returns NULL if missing. */
static char *get_key(const char *key) {
return pg_inbox_get_config_value(key);
}
/* Read a config key as a boolean ("true"/"1"/"yes" -> 1). */
static int get_bool(const char *key, int default_val) {
char *v = get_key(key);
if (!v) return default_val;
int r = (strcmp(v, "true") == 0 || strcmp(v, "1") == 0 ||
strcmp(v, "yes") == 0 || strcmp(v, "on") == 0);
free(v);
return r;
}
/* Read a config key as a long. */
static long get_long(const char *key, long default_val) {
char *v = get_key(key);
if (!v) return default_val;
long r = strtol(v, NULL, 10);
free(v);
return r;
}
/* Read a config key as an int. */
static int get_int(const char *key, int default_val) {
return (int)get_long(key, (long)default_val);
}
/* ------------------------------------------------------------------ */
/* Public API */
/* ------------------------------------------------------------------ */
int pg_config_load(cr_config_t *cfg) {
if (!cfg) return -1;
memset(cfg, 0, sizeof(*cfg));
/* root_npubs */
char *npubs = get_key("caching_root_npubs");
split_csv_str(npubs, (char *)cfg->root_npubs, CR_MAX_ROOT_NPUBS,
CR_NPUB_LEN, &cfg->root_npub_count);
free(npubs);
/* bootstrap_relays -> upstream_relays */
char *relays = get_key("caching_bootstrap_relays");
split_csv_str(relays, (char *)cfg->upstream_relays, CR_MAX_UPSTREAM,
CR_URL_LEN, &cfg->upstream_count);
free(relays);
/* local_relay - no longer required for PostgreSQL mode, but keep a
* placeholder for compatibility. The caller may override it. */
cfg->local_relay[0] = '\0';
/* kinds */
char *kinds = get_key("caching_kinds");
split_csv_int(kinds, cfg->kinds, CR_MAX_KINDS, &cfg->kind_count);
free(kinds);
/* admin_kinds - supports "*" to mean all kinds */
char *akinds = get_key("caching_admin_kinds");
if (akinds) {
/* Check for "*" anywhere in the list. */
if (strstr(akinds, "*") != NULL) {
cfg->admin_all_kinds = 1;
} else {
split_csv_int(akinds, cfg->admin_kinds, CR_MAX_KINDS,
&cfg->admin_kind_count);
}
free(akinds);
}
/* live */
cfg->live.enabled = get_bool("caching_live_enabled", 1);
cfg->live.resubscribe_interval_seconds =
get_int("caching_live_resubscribe_seconds", 300);
/* backfill */
cfg->backfill.enabled = get_bool("caching_backfill_enabled", 1);
cfg->backfill.events_per_tick =
get_int("caching_backfill_page_size", 500);
/* tick interval is stored in ms in the config table; convert to seconds. */
int tick_ms = get_int("caching_backfill_tick_interval_ms", 5000);
cfg->backfill.tick_interval_seconds = (tick_ms + 999) / 1000;
if (cfg->backfill.tick_interval_seconds < 1)
cfg->backfill.tick_interval_seconds = 1;
/* follow graph refresh */
cfg->follow_graph_refresh_seconds =
get_int("caching_follow_graph_refresh_seconds", 600);
/* Defaults if missing. */
if (cfg->backfill.events_per_tick == 0)
cfg->backfill.events_per_tick = 500;
if (cfg->backfill.tick_interval_seconds == 0)
cfg->backfill.tick_interval_seconds = 5;
if (cfg->live.resubscribe_interval_seconds == 0)
cfg->live.resubscribe_interval_seconds = 300;
if (cfg->follow_graph_refresh_seconds == 0)
cfg->follow_graph_refresh_seconds = 600;
/* State: per-author backfill progress lives in caching_followed_pubkeys.
* backfilled_until is only used as a first-time flag in legacy mode. */
cfg->state.backfilled_until = 0;
/* Validation. */
if (cfg->root_npub_count == 0) {
DEBUG_ERROR("pg_config: at least one caching_root_npubs required");
return -1;
}
if (cfg->upstream_count == 0) {
DEBUG_ERROR("pg_config: at least one caching_bootstrap_relays required");
return -1;
}
if (cfg->kind_count == 0) {
DEBUG_ERROR("pg_config: at least one caching_kinds required");
return -1;
}
DEBUG_INFO("pg_config loaded: %d root npubs, %d upstream relays, %d kinds",
cfg->root_npub_count, cfg->upstream_count, cfg->kind_count);
return 0;
}
int pg_config_generation_changed(long last_generation) {
long cur = pg_inbox_get_config_generation();
if (cur < 0) return -1;
return (cur != last_generation) ? 1 : 0;
}
+19
View File
@@ -0,0 +1,19 @@
/*
* caching_relay - read caching configuration from the c-relay-pg PostgreSQL
* config table and populate the existing cr_config_t structure.
*/
#ifndef CACHING_RELAY_PG_CONFIG_H
#define CACHING_RELAY_PG_CONFIG_H
#include "config.h" /* cr_config_t */
/* Load caching configuration from the c-relay-pg PostgreSQL config table.
* Populates the cr_config_t fields from the config table.
* Returns 0 on success, -1 on error. */
int pg_config_load(cr_config_t *cfg);
/* Check if the config generation has changed since the last load.
* Returns 1 if changed, 0 if not, -1 on error. */
int pg_config_generation_changed(long last_generation);
#endif /* CACHING_RELAY_PG_CONFIG_H */
File diff suppressed because it is too large Load Diff
+152
View File
@@ -0,0 +1,152 @@
/*
* caching_relay - PostgreSQL inbox connection and operations.
*
* Manages the libpq connection to the c-relay-pg PostgreSQL database and
* provides functions to insert fetched events into the caching_event_inbox
* table, update the caching_service_state singleton, and read configuration
* from the shared config table.
*/
#ifndef CACHING_RELAY_PG_INBOX_H
#define CACHING_RELAY_PG_INBOX_H
#include <stddef.h>
/* Forward declaration so the header does not pull in cJSON everywhere. */
typedef struct cJSON cJSON;
/* Initialize PostgreSQL connection using the given connection string.
* Returns 0 on success, -1 on error. */
int pg_inbox_init(const char *connection_string);
/* Close the PostgreSQL connection. */
void pg_inbox_shutdown(void);
/* Insert an event into the caching_event_inbox table.
* event_json is the raw Nostr event JSON string.
* source_relay may be NULL. source_class is "live", "discovery", or "backfill".
* priority is 0 (live/discovery) or 1 (backfill).
* Returns 0 on success (including conflict-skip), -1 on error. */
int pg_inbox_insert_event(const char *event_json, const char *source_relay,
const char *source_class, int priority);
/* Update the caching_service_state singleton row.
* All string parameters may be NULL (left unchanged).
* Returns 0 on success, -1 on error. */
int pg_inbox_update_status(const char *service_state,
long config_generation,
long heartbeat_at,
int followed_author_count,
int selected_relay_count,
int connected_relay_count,
int backfill_authors_complete,
int backfill_authors_total,
long events_fetched,
long inbox_inserts,
const char *last_error,
long last_error_at);
/* Read a config value from the c-relay-pg config table.
* Returns a malloc'd string (caller must free) or NULL if not found/error. */
char *pg_inbox_get_config_value(const char *key);
/* Read the current config generation.
* Returns the generation number, or -1 on error. */
long pg_inbox_get_config_generation(void);
/* ------------------------------------------------------------------ */
/* Per-author until-cursor drain backfill model */
/* ------------------------------------------------------------------ */
/* Reset all backfill progress: set until_cursor=0 and backfill_complete=FALSE
* for all rows in caching_followed_pubkeys (the followed set itself is
* preserved). Used by the "Reset Backfill" API button and --restart.
* Returns 0 on success, -1 on error. */
int pg_inbox_reset_backfill_progress(void);
/* Upsert all followed pubkeys into caching_followed_pubkeys.
* For each pubkey: INSERT if new (until_cursor=0, backfill_complete=FALSE),
* or UPDATE is_root and last_seen if existing (until_cursor and
* backfill_complete are NOT touched). root_pubkeys marks which entries are
* root follows. Returns 0 on success, -1 on error. */
int pg_inbox_sync_followed_pubkeys(const char **pubkeys, int count,
const char **root_pubkeys, int root_count);
/* Pick the next incomplete author for backfill, round-robin.
* On success fills out_pk, out_until_cursor and out_is_root, advances
* *round_cursor, and returns 0. Returns -1 if no incomplete authors or
* on error. */
int pg_inbox_pick_next_backfill_author(char *out_pk, int pk_len,
long *out_until_cursor,
int *out_is_root,
int *round_cursor);
/* Update the cursor and completion state for an author.
* events_this_page is added to the running events_fetched counter.
* Returns 0 on success, -1 on error. */
int pg_inbox_update_backfill_progress(const char *pk, long until_cursor,
int complete, int events_this_page);
/* One-time migration from the old caching_backfill_progress table to the
* new caching_followed_pubkeys table. Only runs if the new table is empty
* and the old table has rows. Returns 0 on success (including no-op),
* -1 on error. */
int pg_inbox_migrate_backfill_progress(void);
/* Count complete/total authors for status reporting.
* Returns 0 on success, -1 on error. */
int pg_inbox_count_backfill_progress(int *out_complete, int *out_total);
/* ------------------------------------------------------------------ */
/* Per-relay backfill progress (relay-by-relay drain model) */
/* ------------------------------------------------------------------ */
/* Initialize relay progress rows for an author. Creates one row per relay
* URL with until_cursor=0, complete=FALSE. Skips rows that already exist
* (ON CONFLICT DO NOTHING). Returns 0 on success, -1 on error. */
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. On success fills out_pk and returns 0.
* Returns -1 if no incomplete authors or on error. */
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 cJSON array of
* {relay_url, until_cursor} objects (caller frees with cJSON_Delete).
* Returns NULL on error or if no incomplete relays. */
cJSON *pg_inbox_get_incomplete_relays(const char *pk);
/* Update relay progress: advance cursor and/or mark complete.
* events_this_page is added to the running events_fetched counter.
* last_status is a short string like "eose", "timeout", "error", or NULL
* to leave unchanged. Returns 0 on success, -1 on error. */
int pg_inbox_update_relay_progress(const char *pk, const char *relay_url,
long until_cursor, int complete,
int events_this_page,
const char *last_status);
/* Check if all relays for an author are complete. If so, mark the author
* as backfill_complete=TRUE in caching_followed_pubkeys.
* Returns 1 if marked complete, 0 if still incomplete, -1 on error. */
int pg_inbox_check_and_mark_author_complete(const char *pk);
/* ------------------------------------------------------------------ */
/* Active backfill target (for UI status display) */
/* ------------------------------------------------------------------ */
/* Set the currently-active backfill target (pubkey + relay being queried).
* Either parameter may be NULL/empty to clear. Creates the
* caching_backfill_active table if needed (single-row singleton).
* Returns 0 on success, -1 on error. */
int pg_inbox_set_active_target(const char *pubkey, const char *relay_url);
/* Read the active backfill target. Fills out_pubkey/out_relay with
* the current values (or empty strings if none). Buffers must be
* at least 65 and 256 bytes respectively.
* Returns 0 on success, -1 on error. */
int pg_inbox_get_active_target(char *out_pubkey, int pk_len,
char *out_relay, int relay_len);
#endif /* CACHING_RELAY_PG_INBOX_H */
+471
View File
@@ -0,0 +1,471 @@
/*
* caching_relay - NIP-65 outbox relay discovery
*/
#define _GNU_SOURCE
#include "relay_discovery.h"
#include "debug.h"
#include <string.h>
#include <stdlib.h>
#include <signal.h>
/* Access the shutdown flag from main.c so we can abort discovery early. */
extern volatile sig_atomic_t g_shutdown;
/* Normalize a relay URL: strip trailing slash for consistency. */
static void normalize_url(char *url, size_t maxlen) {
size_t len = strlen(url);
while (len > 0 && url[len - 1] == '/') {
url[--len] = '\0';
}
(void)maxlen;
}
/* ---- helpers ---- */
static int get_pool_urls(nostr_relay_pool_t *pool, const char ***urls_out) {
char **listed = NULL;
nostr_pool_relay_status_t *statuses = NULL;
int n = nostr_relay_pool_list_relays(pool, &listed, &statuses);
const char **urls = NULL;
if (n > 0) {
urls = malloc(n * sizeof(char *));
for (int j = 0; j < n; j++) urls[j] = listed[j];
}
free(listed);
free(statuses);
*urls_out = urls;
return n;
}
/* Query the most recent kind 10002 for a single pubkey from a pool.
* Returns the event (caller must cJSON_Delete) or NULL. */
static cJSON *query_kind10002(nostr_relay_pool_t *pool, const char *hex) {
cJSON *filter = cJSON_CreateObject();
cJSON *authors = cJSON_CreateArray();
cJSON_AddItemToArray(authors, cJSON_CreateString(hex));
cJSON_AddItemToObject(filter, "authors", authors);
cJSON *kinds = cJSON_CreateArray();
cJSON_AddItemToArray(kinds, cJSON_CreateNumber(10002));
cJSON_AddItemToObject(filter, "kinds", kinds);
cJSON_AddItemToObject(filter, "limit", cJSON_CreateNumber(1));
const char **urls = NULL;
int n = get_pool_urls(pool, &urls);
int ev_count = 0;
cJSON **events = nostr_relay_pool_query_sync(pool, urls, n, filter, &ev_count, 10000);
free(urls);
cJSON_Delete(filter);
cJSON *result = NULL;
if (events && ev_count > 0) {
result = events[0]; /* take first (most recent) */
for (int k = 1; k < ev_count; k++) cJSON_Delete(events[k]);
}
free(events);
return result;
}
/* Parse "r" tags from a kind 10002 event into an outbox entry.
* Only includes relays with "read" marker or no marker (assume both).
* Skips "write" only relays. */
static int parse_r_tags(cJSON *event, cr_outbox_entry_t *entry) {
memset(entry, 0, sizeof(*entry));
cJSON *pubkey = cJSON_GetObjectItem(event, "pubkey");
if (pubkey && cJSON_IsString(pubkey)) {
strncpy(entry->pubkey, cJSON_GetStringValue(pubkey), CR_HEX_LEN - 1);
}
cJSON *tags = cJSON_GetObjectItem(event, "tags");
if (!tags || !cJSON_IsArray(tags)) return 0;
int added = 0;
cJSON *tag;
cJSON_ArrayForEach(tag, tags) {
if (!cJSON_IsArray(tag)) continue;
cJSON *name = cJSON_GetArrayItem(tag, 0);
if (!name || !cJSON_IsString(name)) continue;
if (strcmp(cJSON_GetStringValue(name), "r") != 0) continue;
cJSON *url = cJSON_GetArrayItem(tag, 1);
if (!url || !cJSON_IsString(url)) continue;
const char *relay_url = cJSON_GetStringValue(url);
if (!relay_url || !relay_url[0]) continue;
/* Check marker (3rd element): "read", "write", or absent. */
cJSON *marker = cJSON_GetArrayItem(tag, 2);
if (marker && cJSON_IsString(marker)) {
const char *m = cJSON_GetStringValue(marker);
if (strcmp(m, "write") == 0) continue; /* skip write-only relays */
}
/* No marker or "read" => include. */
if (entry->relay_count >= CR_MAX_RELAYS_PER_PUBKEY) break;
strncpy(entry->relays[entry->relay_count], relay_url, CR_URL_LEN - 1);
entry->relays[entry->relay_count][CR_URL_LEN - 1] = '\0';
normalize_url(entry->relays[entry->relay_count], CR_URL_LEN);
entry->relay_count++;
added++;
}
return added;
}
/* ---- greedy set cover ---- */
/* Build relay -> pubkey coverage map and compute minimum covering set. */
static void compute_covering_set(cr_relay_map_t *map, cr_config_t *cfg,
const cr_pubkey_set_t *followed) {
/* Build a temporary relay -> pubkeys map. */
typedef struct {
char url[CR_URL_LEN];
char pubkeys[CR_MAX_PUBKEYS_PER_RELAY][CR_HEX_LEN];
int pubkey_count;
} relay_cov_t;
relay_cov_t *relays = calloc(CR_MAX_DISCOVERED_RELAYS, sizeof(relay_cov_t));
int relay_count = 0;
/* Always include bootstrap relays in the coverage map. */
for (int i = 0; i < cfg->upstream_count && relay_count < CR_MAX_DISCOVERED_RELAYS; i++) {
strncpy(relays[relay_count].url, cfg->upstream_relays[i], CR_URL_LEN - 1);
relays[relay_count].url[CR_URL_LEN - 1] = '\0';
normalize_url(relays[relay_count].url, CR_URL_LEN);
relay_count++;
}
/* Add outbox relays and build coverage. */
for (int i = 0; i < map->outbox_count; i++) {
cr_outbox_entry_t *oe = &map->outboxes[i];
for (int r = 0; r < oe->relay_count; r++) {
/* Find or create relay entry. */
int found = -1;
for (int j = 0; j < relay_count; j++) {
if (strcmp(relays[j].url, oe->relays[r]) == 0) { found = j; break; }
}
if (found < 0) {
if (relay_count >= CR_MAX_DISCOVERED_RELAYS) break;
strncpy(relays[relay_count].url, oe->relays[r], CR_URL_LEN - 1);
found = relay_count++;
}
/* Add this pubkey to the relay's coverage. */
if (relays[found].pubkey_count < CR_MAX_PUBKEYS_PER_RELAY) {
strncpy(relays[found].pubkeys[relays[found].pubkey_count],
oe->pubkey, CR_HEX_LEN - 1);
relays[found].pubkey_count++;
}
}
}
/* Greedy set cover. */
char *covered = calloc(followed->count, 1); /* 1 if pubkey is covered */
map->selected_count = 0;
map->coverage = calloc(map->outbox_count, sizeof(int));
for (int i = 0; i < map->outbox_count; i++) map->coverage[i] = -1;
int uncovered_count = followed->count;
/* Helper: find index of a pubkey in the followed set. */
/* (linear search, fine for our scale) */
while (uncovered_count > 0 && map->selected_count < CR_MAX_DISCOVERED_RELAYS) {
int best_relay = -1;
int best_count = 0;
for (int r = 0; r < relay_count; r++) {
/* Count how many uncovered pubkeys this relay covers. */
int count = 0;
for (int p = 0; p < relays[r].pubkey_count; p++) {
/* Find this pubkey in followed set. */
for (int i = 0; i < followed->count; i++) {
if (strcmp(followed->items[i], relays[r].pubkeys[p]) == 0) {
if (!covered[i]) count++;
break;
}
}
}
if (count > best_count) {
best_count = count;
best_relay = r;
}
}
if (best_relay < 0 || best_count == 0) break; /* no more coverage possible */
/* Select this relay. */
strncpy(map->selected_relays[map->selected_count],
relays[best_relay].url, CR_URL_LEN - 1);
map->selected_count++;
/* Mark covered pubkeys and record coverage. */
for (int p = 0; p < relays[best_relay].pubkey_count; p++) {
for (int i = 0; i < followed->count; i++) {
if (strcmp(followed->items[i], relays[best_relay].pubkeys[p]) == 0) {
if (!covered[i]) {
covered[i] = 1;
uncovered_count--;
/* Record which selected relay covers this outbox entry. */
for (int oe_i = 0; oe_i < map->outbox_count; oe_i++) {
if (strcmp(map->outboxes[oe_i].pubkey,
relays[best_relay].pubkeys[p]) == 0) {
if (map->coverage[oe_i] < 0) {
map->coverage[oe_i] = map->selected_count - 1;
}
}
}
}
break;
}
}
}
}
/* Log results. */
DEBUG_INFO("relay_discovery: selected %d relays to cover %d/%d pubkeys",
map->selected_count, followed->count - uncovered_count, followed->count);
for (int i = 0; i < map->selected_count; i++) {
DEBUG_INFO(" relay[%d]: %s", i, map->selected_relays[i]);
}
if (uncovered_count > 0) {
DEBUG_WARN("relay_discovery: %d pubkeys have no outbox relays (covered by bootstrap)",
uncovered_count);
}
free(covered);
free(relays);
}
/* ---- batched kind-10002 query ---- */
/* Query kind 10002 for a batch of up to 100 pubkeys from a pool.
* Returns a cJSON array of events (caller must cJSON_Delete each + free).
* Sets *out_count to the number of events returned. */
static cJSON **query_kind10002_batch(nostr_relay_pool_t *pool,
const char **pubkeys, int pubkey_count,
int *out_count) {
cJSON *filter = cJSON_CreateObject();
cJSON *authors = cJSON_CreateArray();
for (int i = 0; i < pubkey_count; i++)
cJSON_AddItemToArray(authors, cJSON_CreateString(pubkeys[i]));
cJSON_AddItemToObject(filter, "authors", authors);
cJSON *kinds = cJSON_CreateArray();
cJSON_AddItemToArray(kinds, cJSON_CreateNumber(10002));
cJSON_AddItemToObject(filter, "kinds", kinds);
const char **urls = NULL;
int n = get_pool_urls(pool, &urls);
/* TRACE: log the query being sent. */
if (g_debug_level >= DEBUG_LEVEL_TRACE) {
char *filter_str = cJSON_PrintUnformatted(filter);
DEBUG_TRACE("query_kind10002_batch: querying %d relays for %d pubkeys", n, pubkey_count);
if (filter_str) {
/* Only print first 200 chars of filter to avoid huge output */
if (strlen(filter_str) > 200) filter_str[200] = '\0';
DEBUG_TRACE(" filter: %s...", filter_str);
free(filter_str);
}
}
int ev_count = 0;
cJSON **events = nostr_relay_pool_query_sync(pool, urls, n, filter, &ev_count, 30000);
free(urls);
cJSON_Delete(filter);
DEBUG_TRACE("query_kind10002_batch: returned %d events", ev_count);
*out_count = ev_count;
return events;
}
/* Process a batch of kind-10002 events: parse r-tags, populate outbox entries,
* publish to local relay. Returns count of events processed. */
static int process_10002_batch(cJSON **events, int ev_count,
cr_relay_map_t *map, cr_sink_t *sink,
const char *source) {
int processed = 0;
for (int k = 0; k < ev_count; k++) {
cJSON *event = events[k];
if (!event) continue;
/* Get pubkey from event. */
cJSON *pk = cJSON_GetObjectItem(event, "pubkey");
if (!pk || !cJSON_IsString(pk)) { cJSON_Delete(event); continue; }
const char *hex = cJSON_GetStringValue(pk);
/* Find or create outbox entry for this pubkey. */
cr_outbox_entry_t *oe = NULL;
for (int i = 0; i < map->outbox_count; i++) {
if (strcmp(map->outboxes[i].pubkey, hex) == 0) { oe = &map->outboxes[i]; break; }
}
if (!oe) {
if (map->outbox_count >= /* followed->count passed in via map size */ 99999) {
cJSON_Delete(event); continue;
}
oe = &map->outboxes[map->outbox_count];
strncpy(oe->pubkey, hex, CR_HEX_LEN - 1);
map->outbox_count++;
}
int n = parse_r_tags(event, oe);
(void)n;
if (strcmp(source, "bootstrap") == 0) {
cr_sink_publish(sink, event);
/* Pump sink pool every 16 events to avoid pending publish overflow
* (NOSTR_POOL_MAX_PENDING_PUBLISHES = 32). */
if (processed > 0 && (processed % 16) == 0) {
cr_sink_pump(sink, 200);
}
}
processed++;
cJSON_Delete(event);
}
free(events);
/* Log sink stats after each batch. */
DEBUG_INFO("relay_discovery: batch processed %d events (sink: ok=%ld reject=%ld dup=%ld err=%ld)",
processed, sink->published_ok, sink->published_failed,
sink->published_dup, 0L);
return processed;
}
/* ---- main discovery function ---- */
#define CR_10002_BATCH_SIZE 100
int cr_relay_discovery_run(cr_relay_map_t *map, cr_config_t *cfg,
nostr_relay_pool_t *upstream, cr_sink_t *sink,
const cr_pubkey_set_t *followed, int is_first_time) {
memset(map, 0, sizeof(*map));
map->outboxes = calloc(followed->count, sizeof(cr_outbox_entry_t));
if (!map->outboxes) return -1;
map->outbox_count = 0;
DEBUG_INFO("relay_discovery: discovering outbox relays for %d pubkeys (%s)",
followed->count, is_first_time ? "first-time" : "local-first");
int found_local = 0, found_bootstrap = 0, found_none = 0;
/* Pre-populate outbox entries for all followed pubkeys (empty, for coverage
* tracking of pubkeys with no 10002). */
for (int i = 0; i < followed->count; i++) {
strncpy(map->outboxes[i].pubkey, followed->items[i], CR_HEX_LEN - 1);
}
map->outbox_count = followed->count;
/* Track which pubkeys we've found 10002 for. */
char *found = calloc(followed->count, 1);
/* Phase 1: If subsequent startup, batch-query local relay first. */
if (!is_first_time && sink && sink->pool) {
DEBUG_INFO("relay_discovery: querying local relay for kind-10002 (batched)...");
for (int start = 0; start < followed->count && !g_shutdown; start += CR_10002_BATCH_SIZE) {
int batch = followed->count - start;
if (batch > CR_10002_BATCH_SIZE) batch = CR_10002_BATCH_SIZE;
int ev_count = 0;
cJSON **events = query_kind10002_batch(sink->pool,
(const char **)&followed->items[start],
batch, &ev_count);
if (events && ev_count > 0) {
for (int k = 0; k < ev_count; k++) {
cJSON *pk = cJSON_GetObjectItem(events[k], "pubkey");
if (pk && cJSON_IsString(pk)) {
const char *hex = cJSON_GetStringValue(pk);
for (int i = 0; i < followed->count; i++) {
if (strcmp(followed->items[i], hex) == 0) {
if (!found[i]) { found[i] = 1; found_local++; }
break;
}
}
}
}
process_10002_batch(events, ev_count, map, sink, "local");
/* Pump sink pool to flush published events. */
cr_sink_pump(sink, 500);
} else {
free(events);
}
}
DEBUG_INFO("relay_discovery: local relay provided %d kind-10002 events", found_local);
}
/* Phase 2: Batch-query bootstrap relays for pubkeys not found locally. */
int remaining = 0;
for (int i = 0; i < followed->count; i++) if (!found[i]) remaining++;
if (remaining > 0) {
DEBUG_INFO("relay_discovery: querying bootstrap relays for %d remaining pubkeys (batched)...",
remaining);
/* Build list of remaining pubkeys. */
const char **remaining_pk = malloc(remaining * sizeof(char *));
int ridx = 0;
for (int i = 0; i < followed->count; i++) {
if (!found[i]) remaining_pk[ridx++] = followed->items[i];
}
for (int start = 0; start < remaining && !g_shutdown; start += CR_10002_BATCH_SIZE) {
int batch = remaining - start;
if (batch > CR_10002_BATCH_SIZE) batch = CR_10002_BATCH_SIZE;
int ev_count = 0;
cJSON **events = query_kind10002_batch(upstream,
&remaining_pk[start],
batch, &ev_count);
if (events && ev_count > 0) {
for (int k = 0; k < ev_count; k++) {
cJSON *pk = cJSON_GetObjectItem(events[k], "pubkey");
if (pk && cJSON_IsString(pk)) {
const char *hex = cJSON_GetStringValue(pk);
for (int i = 0; i < followed->count; i++) {
if (strcmp(followed->items[i], hex) == 0) {
if (!found[i]) { found[i] = 1; found_bootstrap++; }
break;
}
}
}
}
process_10002_batch(events, ev_count, map, sink, "bootstrap");
/* Pump sink pool to flush published events. */
cr_sink_pump(sink, 500);
} else {
free(events);
}
}
free(remaining_pk);
}
found_none = followed->count - found_local - found_bootstrap;
DEBUG_INFO("relay_discovery: %d local, %d bootstrap, %d none",
found_local, found_bootstrap, found_none);
free(found);
/* Compute minimum covering set. */
compute_covering_set(map, cfg, followed);
return 0;
}
void cr_relay_map_free(cr_relay_map_t *map) {
if (map->outboxes) {
free(map->outboxes);
map->outboxes = NULL;
}
if (map->coverage) {
free(map->coverage);
map->coverage = NULL;
}
map->outbox_count = 0;
map->selected_count = 0;
}
const cr_outbox_entry_t *cr_relay_map_get_outbox(const cr_relay_map_t *map, const char *hex) {
for (int i = 0; i < map->outbox_count; i++) {
if (strcmp(map->outboxes[i].pubkey, hex) == 0) return &map->outboxes[i];
}
return NULL;
}
+67
View File
@@ -0,0 +1,67 @@
/*
* caching_relay - NIP-65 outbox relay discovery.
*
* For each followed pubkey, fetches their kind 10002 (relay list) and parses
* the "r" tags to build a per-pubkey outbox relay map. Then computes the
* minimum covering set of relays (greedy set cover) that covers all followed
* pubkeys.
*
* On first-time startup, queries bootstrap relays for kind 10002.
* On subsequent startups, queries the local relay first (fast), falls back
* to bootstrap relays for any pubkey not found locally.
*/
#ifndef CACHING_RELAY_RELAY_DISCOVERY_H
#define CACHING_RELAY_RELAY_DISCOVERY_H
#include "config.h"
#include "state.h"
#include "relay_sink.h"
#include "../nostr_core_lib/nostr_core/nostr_core.h"
#define CR_MAX_RELAYS_PER_PUBKEY 8
#define CR_MAX_PUBKEYS_PER_RELAY 512
#define CR_MAX_DISCOVERED_RELAYS 128
/* Per-pubkey outbox relay list. */
typedef struct {
char pubkey[CR_HEX_LEN];
char relays[CR_MAX_RELAYS_PER_PUBKEY][CR_URL_LEN];
int relay_count;
} cr_outbox_entry_t;
/* Result of relay discovery. */
typedef struct {
cr_outbox_entry_t *outboxes; /* per-pubkey relay lists (heap) */
int outbox_count;
/* The selected minimum covering set of relay URLs. */
char selected_relays[CR_MAX_DISCOVERED_RELAYS][CR_URL_LEN];
int selected_count;
/* Per-pubkey: which selected relay covers this pubkey (index into selected_relays).
* -1 means covered by bootstrap relays only. */
int *coverage; /* heap, outbox_count entries */
} cr_relay_map_t;
/* Discover outbox relays for all followed pubkeys.
*
* cfg - config (provides bootstrap relays, local_relay, root_hex)
* upstream - upstream pool (has bootstrap relays; discovered relays will be added)
* sink - sink pool (local relay, for publishing 10002 events + querying on subsequent startup)
* followed - the followed pubkey set
* is_first_time - 1 if first startup (query bootstrap only), 0 if subsequent (local-first)
*
* Returns 0 on success, -1 on hard error. On success, map is populated.
* Caller must call cr_relay_map_free() when done.
*/
int cr_relay_discovery_run(cr_relay_map_t *map, cr_config_t *cfg,
nostr_relay_pool_t *upstream, cr_sink_t *sink,
const cr_pubkey_set_t *followed, int is_first_time);
/* Free heap resources in a relay map. */
void cr_relay_map_free(cr_relay_map_t *map);
/* Get the outbox relays for a specific pubkey. Returns NULL if not found. */
const cr_outbox_entry_t *cr_relay_map_get_outbox(const cr_relay_map_t *map, const char *hex);
#endif /* CACHING_RELAY_RELAY_DISCOVERY_H */
+161
View File
@@ -0,0 +1,161 @@
/*
* caching_relay - relay sink: publishes fetched events to the local relay.
*
* See relay_sink.h for the dual-mode (WebSocket / PostgreSQL inbox) design.
*/
#define _GNU_SOURCE
#include "relay_sink.h"
#include "pg_inbox.h"
#include "debug.h"
#include <string.h>
#include <stdlib.h>
/* ------------------------------------------------------------------ */
/* WebSocket mode callback */
/* ------------------------------------------------------------------ */
static void sink_publish_cb(const char *relay_url, const char *event_id,
int success, const char *message, void *user_data) {
cr_sink_t *sink = (cr_sink_t *)user_data;
if (success) {
sink->published_ok++;
DEBUG_TRACE("sink: OK %s from %s", event_id ? event_id : "?",
relay_url ? relay_url : "?");
} else {
sink->published_failed++;
/* REJECT is important - show at INFO level so it's visible at level 3. */
DEBUG_INFO("sink: REJECT %s from %s: %s", event_id ? event_id : "?",
relay_url ? relay_url : "?",
message ? message : "(no message)");
}
}
/* ------------------------------------------------------------------ */
/* Init */
/* ------------------------------------------------------------------ */
int cr_sink_init(cr_sink_t *sink, const char *local_relay_url, cr_seen_ring_t *seen) {
memset(sink, 0, sizeof(*sink));
sink->seen = seen;
sink->source_class = CR_SINK_CLASS_LIVE;
strncpy(sink->url, local_relay_url, sizeof(sink->url) - 1);
sink->pool = nostr_relay_pool_create(nostr_pool_reconnect_config_default());
if (!sink->pool) {
DEBUG_ERROR("sink: failed to create pool");
return -1;
}
if (nostr_relay_pool_add_relay(sink->pool, local_relay_url) != NOSTR_SUCCESS) {
DEBUG_ERROR("sink: failed to add relay %s", local_relay_url);
nostr_relay_pool_destroy(sink->pool);
sink->pool = NULL;
return -1;
}
DEBUG_INFO("sink: initialized (WebSocket) for %s", local_relay_url);
return 0;
}
int cr_sink_init_pg(cr_sink_t *sink, cr_seen_ring_t *seen) {
memset(sink, 0, sizeof(*sink));
sink->seen = seen;
sink->source_class = CR_SINK_CLASS_LIVE;
sink->pool = NULL; /* PostgreSQL inbox mode - no WebSocket pool. */
DEBUG_INFO("sink: initialized (PostgreSQL inbox)");
return 0;
}
void cr_sink_set_source_class(cr_sink_t *sink, const char *class_ptr) {
if (sink) sink->source_class = class_ptr ? class_ptr : CR_SINK_CLASS_LIVE;
}
void cr_sink_destroy(cr_sink_t *sink) {
if (sink && sink->pool) {
nostr_relay_pool_destroy(sink->pool);
sink->pool = NULL;
}
}
/* ------------------------------------------------------------------ */
/* Publish */
/* ------------------------------------------------------------------ */
int cr_sink_publish_from(cr_sink_t *sink, cJSON *event, const char *source_relay) {
if (!sink || !event) return -1;
cJSON *id = cJSON_GetObjectItem(event, "id");
if (!id || !cJSON_IsString(id)) {
DEBUG_WARN("sink: event missing id, skipping");
return -1;
}
const char *eid = cJSON_GetStringValue(id);
if (cr_seen_ring_add(sink->seen, eid) == 0) {
sink->published_dup++;
return 0; /* already seen recently */
}
/* PostgreSQL inbox mode. */
if (!sink->pool) {
char *json = cJSON_PrintUnformatted(event);
if (!json) {
DEBUG_WARN("sink: failed to serialize event %s", eid);
sink->published_failed++;
return -1;
}
const char *klass = sink->source_class ? sink->source_class
: CR_SINK_CLASS_LIVE;
int priority = (strcmp(klass, CR_SINK_CLASS_BACKFILL) == 0) ? 1 : 0;
cJSON *kind = cJSON_GetObjectItem(event, "kind");
cJSON *pubkey = cJSON_GetObjectItem(event, "pubkey");
int kind_num = (kind && cJSON_IsNumber(kind)) ? (int)cJSON_GetNumberValue(kind) : -1;
DEBUG_TRACE("sink: INBOX event id=%s kind=%d pubkey=%s class=%s",
eid, kind_num,
pubkey && cJSON_IsString(pubkey) ? cJSON_GetStringValue(pubkey) : "?",
klass);
int rc = pg_inbox_insert_event(json, source_relay, klass, priority);
free(json);
if (rc != 0) {
sink->published_failed++;
return -1;
}
sink->published_ok++;
return 1;
}
/* WebSocket mode. */
const char *urls[1] = { sink->url };
cJSON *kind = cJSON_GetObjectItem(event, "kind");
cJSON *pubkey = cJSON_GetObjectItem(event, "pubkey");
int kind_num = (kind && cJSON_IsNumber(kind)) ? (int)cJSON_GetNumberValue(kind) : -1;
DEBUG_TRACE("sink: SEND event id=%s kind=%d pubkey=%s to %s",
eid, kind_num,
pubkey && cJSON_IsString(pubkey) ? cJSON_GetStringValue(pubkey) : "?",
sink->url);
int rc = nostr_relay_pool_publish_async(sink->pool, urls, 1, event,
sink_publish_cb, sink);
if (rc < 0) {
DEBUG_WARN("sink: publish_async error rc=%d for %s", rc, eid);
return -1;
}
if (rc == 0) {
DEBUG_INFO("sink: publish SKIPPED (not connected) for %s", eid);
return 0;
}
DEBUG_TRACE("sink: publish_async rc=%d for %s", rc, eid);
return 1;
}
int cr_sink_publish(cr_sink_t *sink, cJSON *event) {
return cr_sink_publish_from(sink, event, NULL);
}
void cr_sink_pump(cr_sink_t *sink, int timeout_ms) {
if (sink && sink->pool) {
nostr_relay_pool_run(sink->pool, timeout_ms);
}
/* No-op in PostgreSQL inbox mode. */
}
+75
View File
@@ -0,0 +1,75 @@
/*
* caching_relay - relay sink: publishes fetched events to the local relay.
*
* Two modes:
* - WebSocket mode (legacy): a dedicated single-relay pool publishes via
* nostr_relay_pool_publish_async(). Used when no PostgreSQL connection
* string is provided.
* - PostgreSQL inbox mode: events are inserted directly into the
* caching_event_inbox table via pg_inbox_insert_event(). Used when the
* daemon is started with --pg-conn.
*
* The public function signatures are identical in both modes so that callers
* (live_subscriber, backfill, relay_discovery) do not need to change.
*/
#ifndef CACHING_RELAY_RELAY_SINK_H
#define CACHING_RELAY_RELAY_SINK_H
#include "state.h"
#include "../nostr_core_lib/nostr_core/nostr_core.h"
/* Source class labels for the inbox priority field. */
#define CR_SINK_CLASS_LIVE "live"
#define CR_SINK_CLASS_DISCOVERY "discovery"
#define CR_SINK_CLASS_BACKFILL "backfill"
typedef struct {
/* WebSocket mode only (NULL in PostgreSQL inbox mode). */
nostr_relay_pool_t *pool;
char url[256];
/* PostgreSQL inbox mode: source class used for priority assignment.
* Defaults to "live". Set to "backfill" by the backfill module.
* Ignored in WebSocket mode. */
const char *source_class;
cr_seen_ring_t *seen; /* shared seen ring for dedup */
long published_ok;
long published_failed;
long published_dup;
} cr_sink_t;
/* Create the sink.
*
* In WebSocket mode (pg_mode = 0): creates a relay pool and adds
* local_relay_url.
* In PostgreSQL inbox mode (pg_mode != 0): no pool is created; the URL is
* ignored. Events are inserted via pg_inbox_insert_event() (the pg_inbox
* module must be initialized beforehand).
*
* The original two-argument signature is preserved via cr_sink_init() below
* for backward compatibility; it defaults to WebSocket mode. */
int cr_sink_init(cr_sink_t *sink, const char *local_relay_url, cr_seen_ring_t *seen);
/* Initialize the sink in PostgreSQL inbox mode. */
int cr_sink_init_pg(cr_sink_t *sink, cr_seen_ring_t *seen);
/* Set the source class for priority assignment in PostgreSQL inbox mode.
* class_ptr must point to a static string literal (e.g. CR_SINK_CLASS_LIVE). */
void cr_sink_set_source_class(cr_sink_t *sink, const char *class_ptr);
void cr_sink_destroy(cr_sink_t *sink);
/* Publish a cJSON event. Dedups against the seen ring.
* Returns 1 if enqueued/inserted, 0 if dup (skipped), -1 on error. */
int cr_sink_publish(cr_sink_t *sink, cJSON *event);
/* Variant that records the originating relay URL in PostgreSQL inbox mode.
* In WebSocket mode the source_relay is ignored. */
int cr_sink_publish_from(cr_sink_t *sink, cJSON *event, const char *source_relay);
/* Pump the sink pool briefly to flush pending publish callbacks.
* No-op in PostgreSQL inbox mode. */
void cr_sink_pump(cr_sink_t *sink, int timeout_ms);
#endif /* CACHING_RELAY_RELAY_SINK_H */
+76
View File
@@ -0,0 +1,76 @@
/*
* caching_relay - in-memory runtime state
*/
#define _GNU_SOURCE
#include "state.h"
#include <stdlib.h>
#include <string.h>
/* ---- pubkey set ---- */
void cr_pubkey_set_init(cr_pubkey_set_t *s) {
s->items = NULL;
s->count = 0;
s->capacity = 0;
}
void cr_pubkey_set_free(cr_pubkey_set_t *s) {
for (int i = 0; i < s->count; i++) free(s->items[i]);
free(s->items);
s->items = NULL;
s->count = 0;
s->capacity = 0;
}
void cr_pubkey_set_clear(cr_pubkey_set_t *s) {
cr_pubkey_set_free(s);
cr_pubkey_set_init(s);
}
int cr_pubkey_set_contains(const cr_pubkey_set_t *s, const char *hex) {
for (int i = 0; i < s->count; i++) {
if (strcmp(s->items[i], hex) == 0) return 1;
}
return 0;
}
int cr_pubkey_set_add(cr_pubkey_set_t *s, const char *hex) {
if (!hex || !hex[0]) return 0;
if (cr_pubkey_set_contains(s, hex)) return 0;
if (s->count >= s->capacity) {
int newcap = s->capacity ? s->capacity * 2 : 64;
char **ni = realloc(s->items, newcap * sizeof(char *));
if (!ni) return -1;
s->items = ni;
s->capacity = newcap;
}
s->items[s->count] = strdup(hex);
if (!s->items[s->count]) return -1;
s->count++;
return 1;
}
/* ---- seen-event ring buffer ---- */
void cr_seen_ring_init(cr_seen_ring_t *r) {
memset(r, 0, sizeof(*r));
}
int cr_seen_ring_contains(const cr_seen_ring_t *r, const char *id) {
for (int i = 0; i < r->count; i++) {
int idx = (r->head - 1 - i + CR_SEEN_RING_SIZE) % CR_SEEN_RING_SIZE;
if (strcmp(r->ids[idx], id) == 0) return 1;
}
return 0;
}
int cr_seen_ring_add(cr_seen_ring_t *r, const char *id) {
if (!id || !id[0]) return 0;
if (cr_seen_ring_contains(r, id)) return 0;
strncpy(r->ids[r->head], id, CR_HEX_LEN - 1);
r->ids[r->head][CR_HEX_LEN - 1] = '\0';
r->head = (r->head + 1) % CR_SEEN_RING_SIZE;
if (r->count < CR_SEEN_RING_SIZE) r->count++;
return 1;
}
+41
View File
@@ -0,0 +1,41 @@
/*
* caching_relay - in-memory runtime state:
* - followed-pubkey set (hex pubkeys)
* - seen-event-id ring buffer (to avoid redundant publish attempts)
*/
#ifndef CACHING_RELAY_STATE_H
#define CACHING_RELAY_STATE_H
#include <stddef.h>
/* Hex pubkey is 64 chars + NUL. Event id is 64 hex chars + NUL. */
#define CR_HEX_LEN 65
/* A simple dynamic string set for followed pubkeys. */
typedef struct {
char **items;
int count;
int capacity;
} cr_pubkey_set_t;
void cr_pubkey_set_init(cr_pubkey_set_t *s);
void cr_pubkey_set_free(cr_pubkey_set_t *s);
int cr_pubkey_set_add(cr_pubkey_set_t *s, const char *hex); /* 1 if added, 0 if dup */
int cr_pubkey_set_contains(const cr_pubkey_set_t *s, const char *hex);
void cr_pubkey_set_clear(cr_pubkey_set_t *s);
/* Fixed-size ring buffer of event ids for dedup. */
#define CR_SEEN_RING_SIZE 4096
typedef struct {
char ids[CR_SEEN_RING_SIZE][CR_HEX_LEN];
int head;
int count;
} cr_seen_ring_t;
void cr_seen_ring_init(cr_seen_ring_t *r);
/* Returns 1 if the id was newly inserted, 0 if it was already present. */
int cr_seen_ring_add(cr_seen_ring_t *r, const char *id);
int cr_seen_ring_contains(const cr_seen_ring_t *r, const char *id);
#endif /* CACHING_RELAY_STATE_H */
-9970
View File
File diff suppressed because it is too large Load Diff
+122 -15
View File
@@ -1,28 +1,135 @@
#!/bin/bash
# C-Relay-PG Static Binary Deployment Script
# Deploys build/c_relay_pg_static_x86_64 to server via ssh
# C-Relay-PG PostgreSQL Deployment Script
# Deploys static relay binary and configures PostgreSQL 18 + systemd service on remote server.
set -e
set -euo pipefail
# Configuration
REMOTE_HOST="ubuntu@laantungir.net"
LOCAL_BINARY="build/c_relay_pg_static_x86_64"
LOCAL_CACHING_BINARY="build/caching_relay"
REMOTE_BINARY_DIR="/usr/local/bin/c_relay_pg"
REMOTE_BINARY_PATH="/usr/local/bin/c_relay_pg/c_relay_pg"
REMOTE_CACHING_BINARY_PATH="/opt/c-relay-pg/caching_relay"
SERVICE_NAME="c-relay-pg"
# Create backup
ssh ubuntu@laantungir.com "sudo cp '$REMOTE_BINARY_PATH' '${REMOTE_BINARY_PATH}.backup.$(date +%Y%m%d_%H%M%S)'" 2>/dev/null || true
LOCAL_SERVICE_FILE="systemd/c-relay.service"
LOCAL_PG_SETUP_SCRIPT="systemd/setup_postgres_18.sh"
# Upload binary to temp location
scp "$LOCAL_BINARY" "ubuntu@laantungir.com:/tmp/c_relay_pg.tmp"
if [ ! -f "$LOCAL_BINARY" ]; then
echo "ERROR: Binary not found: $LOCAL_BINARY"
echo "Build it first (e.g. ./make_and_restart_relay.sh)"
exit 1
fi
# Install binary
ssh ubuntu@laantungir.com "sudo mv '/tmp/c_relay_pg.tmp' '$REMOTE_BINARY_PATH'"
ssh ubuntu@laantungir.com "sudo chown c-relay-pg:c-relay-pg '$REMOTE_BINARY_PATH'"
ssh ubuntu@laantungir.com "sudo chmod +x '$REMOTE_BINARY_PATH'"
if [ ! -f "$LOCAL_SERVICE_FILE" ]; then
echo "ERROR: Service file not found: $LOCAL_SERVICE_FILE"
exit 1
fi
# Reload systemd and restart service
ssh ubuntu@laantungir.com "sudo systemctl daemon-reload"
ssh ubuntu@laantungir.com "sudo systemctl restart '$SERVICE_NAME'"
if [ ! -f "$LOCAL_PG_SETUP_SCRIPT" ]; then
echo "ERROR: PostgreSQL setup script not found: $LOCAL_PG_SETUP_SCRIPT"
exit 1
fi
echo "Deployment complete!"
echo "==> Uploading artifacts to $REMOTE_HOST"
scp "$LOCAL_BINARY" "$REMOTE_HOST:/tmp/c_relay_pg.tmp"
scp "$LOCAL_SERVICE_FILE" "$REMOTE_HOST:/tmp/c-relay-pg.service"
scp "$LOCAL_PG_SETUP_SCRIPT" "$REMOTE_HOST:/tmp/setup_postgres_18.sh"
# Upload the caching_relay binary if it was built locally. The caching
# service is NOT started by this deploy (the systemd unit does not pass
# --start-caching), but the binary is placed in the relay's WorkingDirectory
# so it is ready for when caching is enabled via event-based config.
if [ -f "$LOCAL_CACHING_BINARY" ]; then
scp "$LOCAL_CACHING_BINARY" "$REMOTE_HOST:/tmp/caching_relay.tmp"
else
echo "WARNING: caching_relay binary not found locally ($LOCAL_CACHING_BINARY) — skipping caching binary upload"
fi
echo "==> Running remote install/configuration"
ssh "$REMOTE_HOST" 'bash -s' <<'EOF'
set -euo pipefail
SERVICE_NAME="c-relay-pg"
RELAY_USER="c-relay-pg"
REMOTE_BINARY_DIR="/usr/local/bin/c_relay_pg"
REMOTE_BINARY_PATH="/usr/local/bin/c_relay_pg/c_relay_pg"
REMOTE_CACHING_BINARY_PATH="/opt/c-relay-pg/caching_relay"
echo "[remote] Ensuring service user exists"
if ! id "$RELAY_USER" >/dev/null 2>&1; then
sudo useradd --system --home-dir /opt/c-relay-pg --shell /usr/sbin/nologin "$RELAY_USER"
fi
echo "[remote] Ensuring required directories exist"
sudo mkdir -p "$REMOTE_BINARY_DIR" /opt/c-relay-pg /etc/c-relay-pg
sudo chown "$RELAY_USER:$RELAY_USER" /opt/c-relay-pg
echo "[remote] Installing PostgreSQL 18 (PGDG) if missing"
if ! dpkg -s postgresql-18 >/dev/null 2>&1; then
sudo apt-get update
sudo apt-get install -y curl ca-certificates lsb-release gnupg
sudo install -d /usr/share/postgresql-common/pgdg
sudo curl -fsSL https://www.postgresql.org/media/keys/ACCC4CF8.asc -o /usr/share/postgresql-common/pgdg/apt.postgresql.org.asc
echo "deb [signed-by=/usr/share/postgresql-common/pgdg/apt.postgresql.org.asc] https://apt.postgresql.org/pub/repos/apt $(lsb_release -cs)-pgdg main" | sudo tee /etc/apt/sources.list.d/pgdg.list >/dev/null
sudo apt-get update
sudo apt-get install -y postgresql-18
fi
sudo systemctl enable postgresql
sudo systemctl restart postgresql
echo "[remote] Configuring PostgreSQL role/database"
sudo chmod +x /tmp/setup_postgres_18.sh
sudo /tmp/setup_postgres_18.sh
echo "[remote] Installing relay binary"
if [ -f "$REMOTE_BINARY_PATH" ]; then
sudo cp "$REMOTE_BINARY_PATH" "${REMOTE_BINARY_PATH}.backup.$(date +%Y%m%d_%H%M%S)"
fi
sudo mv /tmp/c_relay_pg.tmp "$REMOTE_BINARY_PATH"
sudo chown "$RELAY_USER:$RELAY_USER" "$REMOTE_BINARY_PATH"
sudo chmod +x "$REMOTE_BINARY_PATH"
echo "[remote] Installing caching_relay binary (if uploaded)"
if [ -f /tmp/caching_relay.tmp ]; then
sudo mv /tmp/caching_relay.tmp "$REMOTE_CACHING_BINARY_PATH"
sudo chown "$RELAY_USER:$RELAY_USER" "$REMOTE_CACHING_BINARY_PATH"
sudo chmod +x "$REMOTE_CACHING_BINARY_PATH"
echo " -> caching_relay installed at $REMOTE_CACHING_BINARY_PATH (not started; enable via config when ready)"
else
echo " -> no caching_relay binary uploaded, skipping"
fi
echo "[remote] Installing systemd unit"
sudo mv /tmp/c-relay-pg.service /etc/systemd/system/c-relay-pg.service
sudo chown root:root /etc/systemd/system/c-relay-pg.service
sudo chmod 644 /etc/systemd/system/c-relay-pg.service
echo "[remote] Killing stale caching_relay processes (safety)"
# The caching service is forked by the relay with setsid(), so it detaches
# from the relay's process group and survives when the relay is killed.
# Without this, every restart orphans the previous caching_relay child and
# a new relay forks another, leading to many stale processes with dead PG
# connections spamming errors. Kill them here so the new relay starts clean.
CACHING_PIDS=$(pgrep -f "caching_relay" || echo "")
if [ -n "$CACHING_PIDS" ]; then
echo " -> killing stale caching_relay PIDs: $CACHING_PIDS"
kill -9 $CACHING_PIDS 2>/dev/null || true
sleep 1
else
echo " -> no stale caching_relay processes found"
fi
echo "[remote] Reloading and restarting service"
sudo systemctl daemon-reload
sudo systemctl enable "$SERVICE_NAME"
sudo systemctl restart "$SERVICE_NAME"
echo "[remote] Health checks"
sudo systemctl --no-pager --full status "$SERVICE_NAME" | sed -n '1,25p'
sudo -u "$RELAY_USER" psql -d crelay -c "SELECT current_user, current_database();"
EOF
echo "Deployment complete: $REMOTE_HOST"
@@ -155,6 +155,34 @@ http {
log_not_found off;
}
# PHP admin page for caching service (direct PostgreSQL access).
# Bypasses the NIP-44 64KB encryption limit of the Nostr admin API.
# Requires: php-fpm + php-pgsql installed and configured.
location /admin/ {
alias /opt/c-relay-pg/admin/;
index index.php;
# HTTP Basic Auth
auth_basic "Relay Admin";
auth_basic_user_file /opt/c-relay-pg/admin/.htpasswd;
# Pass .php files to PHP-FPM
location ~ \.php$ {
# Adjust socket path for your distro:
# Debian/Ubuntu: unix:/run/php/php8.2-fpm.sock
# RHEL/Fedora: unix:/run/php-fpm/www.sock
fastcgi_pass unix:/run/php/php8.2-fpm.sock;
fastcgi_index index.php;
include fastcgi_params;
fastcgi_param SCRIPT_FILENAME $request_filename;
}
# Deny access to the lib/ directory (contains DB credentials)
location ^~ /admin/lib/ {
deny all;
}
}
# Optional: Metrics endpoint (if implemented)
location /metrics {
proxy_pass http://c_relay_pg_backend/metrics;
+61 -2
View File
@@ -13,6 +13,8 @@ ADMIN_KEY=""
RELAY_KEY=""
PORT_OVERRIDE=""
DEBUG_LEVEL="5"
START_CACHING=false
RESET_BACKFILL=false
DB_BACKEND="postgres"
DB_CONNSTRING=""
DB_HOST=""
@@ -75,6 +77,14 @@ while [[ $# -gt 0 ]]; do
PRESERVE_DATABASE=true
shift
;;
--start-caching)
START_CACHING=true
shift
;;
--reset-backfill)
RESET_BACKFILL=true
shift
;;
--test-keys|-t)
USE_TEST_KEYS=true
# Read keys from .test_keys file
@@ -359,6 +369,8 @@ if [ "$HELP" = true ]; then
echo " --db-name <name> PostgreSQL database name"
echo " --db-user <user> PostgreSQL database user"
echo " --db-password <pass> PostgreSQL database password"
echo " --start-caching Auto-start the caching service on startup"
echo " --reset-backfill Clear caching backfill progress (use with --start-caching)"
echo " --help, -h Show this help message"
echo ""
echo "Event-Based Configuration:"
@@ -455,6 +467,19 @@ fi
echo "Using static binary: $BINARY_PATH"
# Build the caching_relay binary (from caching/ directory)
# This is needed because the caching service launcher forks this binary.
if [ -d "caching" ] && [ -f "caching/Makefile" ]; then
echo "Building caching_relay binary..."
(cd caching && make) > /dev/null 2>&1
if [ $? -ne 0 ]; then
echo "WARNING: caching_relay build failed. The caching service will not be available."
echo "You can build it manually with: cd caching && make"
else
echo "✓ caching_relay binary built successfully"
fi
fi
echo "Build successful. Proceeding with relay restart..."
# Kill existing relay if running - start aggressive immediately
@@ -469,6 +494,21 @@ else
echo "No existing relay processes found"
fi
# Kill any stale caching_relay processes from previous runs.
# The caching service is forked by the relay with setsid(), so it detaches
# from the relay's process group and survives when the relay is killed -9.
# Without this, every restart orphans the previous caching_relay child and
# a new relay forks another, leading to many stale processes with dead PG
# connections spamming errors. Kill them here so the new relay starts fresh.
CACHING_PIDS=$(pgrep -f "caching_relay" || echo "")
if [ -n "$CACHING_PIDS" ]; then
echo "Killing stale caching_relay processes: $CACHING_PIDS"
kill -9 $CACHING_PIDS 2>/dev/null || true
sleep 1
else
echo "No existing caching_relay processes found"
fi
# Ensure port 8888 is completely free with retry loop
echo "Ensuring port 8888 is available..."
for attempt in {1..15}; do
@@ -499,14 +539,21 @@ for attempt in {1..15}; do
fi
done
# Final safety check - ensure no relay processes remain
# Final safety check - ensure no relay or caching processes remain
FINAL_PIDS=$(pgrep -f "c_relay_pg_" || echo "")
if [ -n "$FINAL_PIDS" ]; then
echo "Final cleanup: killing processes $FINAL_PIDS"
echo "Final cleanup: killing relay processes $FINAL_PIDS"
kill -9 $FINAL_PIDS 2>/dev/null || true
sleep 1
fi
FINAL_CACHING_PIDS=$(pgrep -f "caching_relay" || echo "")
if [ -n "$FINAL_CACHING_PIDS" ]; then
echo "Final cleanup: killing stale caching_relay processes $FINAL_CACHING_PIDS"
kill -9 $FINAL_CACHING_PIDS 2>/dev/null || true
sleep 1
fi
# Clean up PID file
rm -f relay.pid
@@ -566,6 +613,16 @@ if [ -n "$DB_PASSWORD" ]; then
RELAY_ARGS="$RELAY_ARGS --db-password '$DB_PASSWORD'"
fi
if [ "$START_CACHING" = true ]; then
RELAY_ARGS="$RELAY_ARGS --start-caching"
echo "Auto-starting caching service on startup"
fi
if [ "$RESET_BACKFILL" = true ]; then
RELAY_ARGS="$RELAY_ARGS --reset-backfill"
echo "Resetting caching backfill progress before start"
fi
# Change to build directory before starting relay so database files are created there
cd build
# Start relay in background and capture its PID
@@ -647,8 +704,10 @@ if ps -p "$RELAY_PID" >/dev/null 2>&1; then
echo "Configuration: Event-based (kind 33334 Nostr events)"
echo "Database: Automatically created with relay pubkey naming"
echo "To kill relay: pkill -f 'c_relay_pg_'"
echo "To kill caching service: pkill -f 'caching_relay'"
echo "To check status: ps aux | grep c_relay_pg_"
echo "To view logs: tail -f relay.log"
echo "To view caching logs: tail -f build/caching_relay.log"
echo "Binary: $BINARY_PATH (zero configuration needed)"
echo "Ready for Nostr client connections!"
else
+1 -1
View File
@@ -1,3 +1,3 @@
#!/bin/bash
curl -H "Accept: application/nostr+json" http://localhost:8888/
curl -H "Accept: application/nostr+json" https://relay.laantungir.net
Submodule nostr_core_lib deleted from 98cfd81b01
+178
View File
@@ -0,0 +1,178 @@
# API Upgrade Implementation Plan — Phases 1, 2, and 5
Goal: finish the remaining work from `plans/api_upgrade_plan.md`:
- **Phase 1 (design fix):** convert the `api-worker` thread from job-queue-driven to **timer + subscriber-check** driven.
- **Phase 2:** remove the per-event and per-subscription monitoring triggers so monitoring no longer runs on the event-processing path.
- **Phase 5:** add **PostgreSQL `LISTEN`/`NOTIFY`** so the worker wakes on real data changes instead of pure polling.
Target test relay: **port 7777** (local).
---
## Current State (verified in code)
- `api_worker_main()` ([`src/api.c:172`](src/api.c:172)) blocks on `api_worker_pop_job_blocking()` and reacts to three job types: `API_WORK_JOB_EVENT_STORED`, `API_WORK_JOB_SUBSCRIPTION_CHANGE`, `API_WORK_JOB_STATUS_POST`.
- Monitoring hooks `monitoring_on_event_stored()` ([`src/api.c:752`](src/api.c:752)) and `monitoring_on_subscription_change()` ([`src/api.c:759`](src/api.c:759)) are called from [`src/main.c:1169`](src/main.c:1169) and [`src/subscriptions.c:483`](src/subscriptions.c:483), [`src/subscriptions.c:536`](src/subscriptions.c:536). They enqueue jobs; sync fallback runs if the worker is down.
- Throttling lives in `monitoring_on_event_stored_sync()` / `monitoring_on_subscription_change_sync()` ([`src/api.c:711`](src/api.c:711), [`src/api.c:731`](src/api.c:731)).
- Round-robin selection in `generate_round_robin_monitoring_event()` ([`src/api.c:571`](src/api.c:571)); per-type build/sign/broadcast in `generate_monitoring_event_for_type()` ([`src/api.c:621`](src/api.c:621)).
- `has_subscriptions_for_kind(int)` ([`src/subscriptions.c:980`](src/subscriptions.c:980)) checks only subscriptions with an explicit `kinds` filter — it does **not** consider the `no_kind_filter_subs` list, so it alone is insufficient for a "is anyone listening to kind 24567" check.
- Worker DB connection is a `PGconn*` opened via `db_open_worker_connection()` ([`src/db_ops_postgres.c:226`](src/db_ops_postgres.c:226)). Schema is embedded in [`src/pg_schema.h`](src/pg_schema.h) (version `"4"`) and mirrored in [`src/pg_schema.sql`](src/pg_schema.sql).
- No `LISTEN`/`NOTIFY`/`pg_notify`/`PQnotifies` usage exists anywhere in `src/`.
---
## Design
### Phase 1 + 2 + 5 combined worker loop
The three phases are coupled, so they are implemented together in one rewrite of `api_worker_main()` and its helpers.
### CRITICAL BUG FIX (why the web API isn't getting updates today)
`postgres_db_open_worker_connection()` ([`src/db_ops_postgres.c:226`](src/db_ops_postgres.c:226)) calls
`postgres_db_set_thread_connection(conn)` at line 243 — but `g_thread_pg_conn` is declared `__thread`
([`src/db_ops_postgres.c:45`](src/db_ops_postgres.c:45)), so the binding is set in the **caller's thread**
(lws-main, which calls `start_api_worker`), NOT in the api-worker thread. When `api_worker_main` runs and
the monitoring query functions call `postgres_db_active_connection()`, `g_thread_pg_conn` is NULL in that
thread, so they fall back to the shared global `g_pg_conn` — concurrent access with the main thread. This
causes monitoring queries to fail or interleave, which is why the dashboard stops receiving kind 24567
events.
**Fix:** inside `api_worker_main()`, after opening `worker_db`, call
`db_set_thread_connection(worker_db)` so the `__thread` pointer is bound in the api-worker thread itself.
Call `db_clear_thread_connection()` before closing the connection on shutdown. This must be done in the
new rewrite regardless of the loop design.
```
api-worker thread:
1. Open own PG connection.
2. db_set_thread_connection(worker_db) // bind __thread pointer in THIS thread
3. If PG backend: LISTEN event_stored on this connection.
4. Loop while running:
a. Determine if anyone is listening to kind 24567
(has_subscriptions_for_kind(24567) OR no_kind_filter_subs non-empty).
b. If NO subscribers:
- condvar_timedwait(throttle_sec) // wakeable for shutdown / STATUS_POST job
- on wake: process any pending STATUS_POST job, then continue.
// Zero DB work, zero notify polling.
c. If subscribers exist:
- wait_for_notify_or_timeout(throttle_sec):
select() on { PQsocket, self-pipe read fd } with timeout = throttle_sec.
- If PQsocket readable: PQconsumeInput + drain PQnotifies.
- If self-pipe readable: drain (shutdown or STATUS_POST signal).
- If timeout: fall through.
- generate_round_robin_monitoring_event() // one d-tag per tick
- process any pending STATUS_POST job.
4. On shutdown: UNLISTEN, close PG connection.
```
Key properties:
- **Zero overhead when no subscribers** — condvar sleep, no `select`, no `LISTEN` polling.
- **Reactive when subscribers exist** — wakes within `throttle_sec` of an `event_stored` NOTIFY, but never more than once per `throttle_sec` (rate-limited by the select timeout + the round-robin cadence already in `generate_round_robin_monitoring_event`).
- **STATUS_POST preserved** — still enqueued from [`src/websockets.c:3470`](src/websockets.c:3470); the self-pipe wakes the worker's `select` so status posts are not delayed by a pending notify wait.
- **SQLite fallback** — `db_worker_poll_notify()` returns "not supported" on SQLite, so the worker falls back to pure timer behavior (condvar timed wait) — identical to the no-PG path.
### Self-pipe for job/shutdown signalling
A self-pipe (or `eventfd` on Linux) is added to `api.c`:
- `api_worker_enqueue_job(STATUS_POST)` writes one byte to the pipe → wakes `select`.
- `stop_api_worker()` sets `g_api_worker_running = 0`, writes a byte, and broadcasts the condvar (covers both the subscriber and no-subscriber wait paths).
### Subscriber-check helper
Add `int has_any_subscription_for_kind(int event_kind)` in [`src/subscriptions.c`](src/subscriptions.c) that returns true if `has_subscriptions_for_kind(event_kind)` OR the `no_kind_filter_subs` list is non-empty. Export it in [`src/subscriptions.h`](src/subscriptions.h). The worker calls `has_any_subscription_for_kind(24567)`.
### DB abstraction for LISTEN/NOTIFY
Add to [`src/db_ops.h`](src/db_ops.h) / [`src/db_ops_postgres.c`](src/db_ops_postgres.c) / [`src/db_ops_sqlite.c`](src/db_ops_sqlite.c):
- `int db_worker_listen(void* conn, const char* channel);` — issues `LISTEN <channel>`.
- `int db_worker_poll_notify(void* conn, int timeout_ms);` — returns `1` if a notification was consumed, `0` on timeout, `-1` on error/unsupported. Uses `PQsocket()`, `select()`, `PQconsumeInput()`, `PQnotifies()`.
- SQLite stubs return `-1` (unsupported) so the worker falls back to timer mode.
### Schema change (Phase 5)
Add to both [`src/pg_schema.sql`](src/pg_schema.sql) and [`src/pg_schema.h`](src/pg_schema.h):
```sql
CREATE OR REPLACE FUNCTION notify_event_stored() RETURNS trigger AS $$
BEGIN
PERFORM pg_notify('event_stored', json_build_object(
'kind', NEW.kind,
'pubkey', substring(NEW.pubkey, 1, 8)
)::text);
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
DROP TRIGGER IF EXISTS trg_notify_event_stored ON events;
CREATE TRIGGER trg_notify_event_stored
AFTER INSERT ON events
FOR EACH ROW EXECUTE FUNCTION notify_event_stored();
```
Bump `EMBEDDED_PG_SCHEMA_VERSION` from `"4"` to `"5"` in [`src/pg_schema.h`](src/pg_schema.h) and the matching `schema_info` version write in [`src/pg_schema.sql`](src/pg_schema.sql). The trigger is idempotent (`DROP TRIGGER IF EXISTS` + `CREATE`), so existing databases upgrade automatically on next startup via `postgres_db_apply_schema()` ([`src/db_ops_postgres.c:134`](src/db_ops_postgres.c:134)).
### Phase 2 removals
- Delete the `monitoring_on_event_stored()` call at [`src/main.c:1169`](src/main.c:1169) (and its forward decl at [`src/main.c:459`](src/main.c:459)).
- Delete the `monitoring_on_subscription_change()` calls at [`src/subscriptions.c:483`](src/subscriptions.c:483) and [`src/subscriptions.c:536`](src/subscriptions.c:536) (and its forward decl at [`src/subscriptions.c:58`](src/subscriptions.c:58)).
- Remove from [`src/api.h`](src/api.h): `monitoring_on_event_stored`, `monitoring_on_subscription_change`.
- Remove from [`src/api.c`](src/api.c): `API_WORK_JOB_EVENT_STORED`, `API_WORK_JOB_SUBSCRIPTION_CHANGE`, `monitoring_on_event_stored`, `monitoring_on_subscription_change`, `monitoring_on_event_stored_sync`, `monitoring_on_subscription_change_sync`. Keep `API_WORK_JOB_STATUS_POST`, `generate_round_robin_monitoring_event`, `generate_monitoring_event_for_type`, `generate_event_driven_monitoring`/`generate_subscription_driven_monitoring` (still used by `generate_monitoring_event` legacy path and tests).
- The `generate_event_driven_monitoring` / `generate_subscription_driven_monitoring` wrappers become unused by the worker but remain as public helpers for any external/test callers; the worker calls `generate_round_robin_monitoring_event()` directly.
### Thread layout (unchanged from plan target)
```
c_relay_pg process
├── lws-main — WebSocket event loop (no monitoring DB work)
├── db-read-1..N — Async REQ/COUNT queries
├── event-worker — Async EVENT ingestion (no monitoring hook)
├── db-write-1..N — Async sub logging, misc writes
└── api-worker — Timer + LISTEN/NOTIFY driven monitoring (own PG conn)
```
---
## Mermaid: api-worker state machine
```mermaid
stateDiagram-v2
[*] --> OpenConn
OpenConn --> BindThread: db_set_thread_connection
BindThread --> Listen: PG backend
BindThread --> Ready: SQLite/no-PG
Listen --> Ready
Ready --> CheckSubs
CheckSubs --> NoSubSleep: no kind-24567 subs
CheckSubs --> WaitNotify: subs present
NoSubSleep --> CheckSubs: throttle_sec or wake
WaitNotify --> Generate: NOTIFY or throttle_sec
Generate --> CheckSubs: after round-robin tick
WaitNotify --> NoSubSleep: subs dropped + wake
Ready --> [*]: stop_api_worker
```
---
## Implementation Todo List
1. Add `has_any_subscription_for_kind()` to `subscriptions.c`/`subscriptions.h` (checks kind index + no-kind-filter list).
2. Add `db_worker_listen()` and `db_worker_poll_notify()` to `db_ops.h`, `db_ops_postgres.c` (libpq), and `db_ops_sqlite.c` (stub).
3. Add `notify_event_stored()` function + `trg_notify_event_stored` trigger to `pg_schema.sql` and `pg_schema.h`; bump schema version to 5.
4. Rewrite `api_worker_main()` in `api.c`: bind the worker PG connection to the thread via `db_set_thread_connection()` (fixes the dashboard-not-updating bug), then run the timer + LISTEN/NOTIFY + subscriber-check loop; add self-pipe for STATUS_POST/shutdown wakeups; keep `start_api_worker`/`stop_api_worker`/`api_worker_process_completions`/`api_worker_enqueue_status_post` signatures.
5. Remove `API_WORK_JOB_EVENT_STORED`/`API_WORK_JOB_SUBSCRIPTION_CHANGE` job types and the `monitoring_on_event_stored`/`monitoring_on_subscription_change` functions + sync wrappers from `api.c` and `api.h`.
6. Remove the `monitoring_on_event_stored()` call from `main.c` (event storage path).
7. Remove the `monitoring_on_subscription_change()` calls from `subscriptions.c` (subscription create/close paths).
8. Build with `./make_and_restart_relay.sh` on port 7777; fix any compile errors.
9. Test: subscribe to kind 24567 on ws://localhost:7777 → confirm monitoring events arrive on throttle cadence (this validates the thread-connection fix).
10. Test: publish a kind-1 event → confirm a NOTIFY-driven monitoring event fires within throttle_sec.
11. Test: with no kind-24567 subscribers → confirm no monitoring events generated (zero overhead) and event storage latency unaffected.
12. Run `tests/quick_error_tests.sh` and `tests/subscribe_all.sh` against port 7777 to confirm no regressions.
---
## Risks & Mitigations
- **Self-pipe + condvar mix complexity.** Mitigation: the no-subscriber path uses only the condvar; the subscriber path uses `select` on the self-pipe + PQ socket. The condvar is only used to wake the no-subscriber sleep, so the two wait mechanisms never overlap.
- **`LISTEN` on a worker connection that also runs monitoring queries.** `LISTEN` is connection-local and persists; running `SELECT`s on the same connection does not cancel it. `PQconsumeInput` must be called before `PQnotifies`. Mitigation: encapsulate all of this in `db_worker_poll_notify()`.
- **Schema trigger on every insert adds per-event overhead.** `pg_notify` is cheap (in-memory queue) and the payload is tiny. The existing `sync_event_tags_from_events` AFTER INSERT trigger already does far more work per row, so this is negligible by comparison.
- **SQLite has no LISTEN/NOTIFY.** Mitigation: `db_worker_poll_notify` returns `-1` on SQLite and the worker falls back to pure timer mode — functionally identical to the no-notify path.
- **Removing the per-event monitoring hook changes dashboard freshness behavior.** Mitigation: with subscribers present, NOTIFY now drives near-real-time updates (better than before); with no subscribers, nothing is generated (matches the plan's "zero overhead" goal). Dashboard already handles event-driven updates via its kind-24567 subscription.
+224
View File
@@ -0,0 +1,224 @@
# API Upgrade Plan: Monitoring Thread + PostgreSQL Push
## Current API Architecture
### How Monitoring Works Today
The relay publishes monitoring data as **kind 24567 ephemeral Nostr events**. The admin dashboard is a standard Nostr client that subscribes to these events.
```
Dashboard connects → subscribes to kind 24567 with d-tags
Relay detects subscription → starts generating monitoring events
Relay queries DB → builds JSON → signs event → broadcasts to subscribers
Dashboard receives events → renders stats/charts
```
### Current Monitoring Event Types
| d-tag | Query Function | What It Contains | Triggered By |
|-------|---------------|-----------------|-------------|
| `event_kinds` | `query_event_kind_distribution()` | Event count per kind, total events | Event storage |
| `time_stats` | `query_time_based_statistics()` | Events in last 24h/7d/30d | Event storage |
| `top_pubkeys` | `query_top_pubkeys()` | Top 10 pubkeys by event count | Event storage |
| `subscription_details` | `query_subscription_details()` | Active subscriptions list | Subscription changes |
| `cpu_metrics` | `query_cpu_metrics()` | PID, memory, CPU, connections | Both triggers |
### Current Admin Command System
Two parallel admin interfaces exist:
**1. Kind 23456 Admin Events (WebSocket)**
- Admin sends NIP-44 encrypted commands as kind 23456 events
- Relay decrypts, processes, responds with kind 23457 events
- Commands: config get/set, auth rules, system commands, SQL queries
**2. NIP-17 DM Admin (Direct Messages)**
- Admin sends gift-wrapped DMs (kind 1059) to relay
- Relay unwraps, processes commands, responds via DM
- Commands: stats, config, help, SQL queries
### Current Problems
1. **All monitoring runs on lws-main thread** — DB queries block the event loop
2. **Monitoring is triggered by event storage**`generate_event_driven_monitoring()` runs synchronously after each event insert
3. **No dedicated monitoring thread** — monitoring piggybacks on the relay's main processing
4. **Throttle is time-based only**`kind_24567_reporting_throttle_sec` (default 5s) but queries still run on main thread
5. **Dashboard polls via subscription** — no way to get data without the relay actively generating events
---
## Upgrade Plan
### Phase 1: Dedicated API Thread (`api-worker`)
**What:** Create a new thread that owns all monitoring event generation. It runs on a timer, queries PostgreSQL with its own connection, builds and signs monitoring events, and pushes them into the broadcast system.
**Thread behavior:**
```
api-worker thread:
1. Open own PG connection
2. Loop:
a. Sleep for throttle_sec interval
b. Check if anyone is subscribed to kind 24567
c. If yes: run all monitoring queries
d. Build kind 24567 events with results
e. Sign events with relay key
f. Push completed events to lws-main for broadcast
g. Repeat
```
**Key design decisions:**
- The api-worker thread does ALL the heavy work (queries + signing)
- It only hands off pre-built, signed events to lws-main for broadcast
- lws-main never blocks on monitoring queries
- The thread sleeps when no one is subscribed (zero overhead)
**Files to change:**
- `src/api.c`: Refactor `generate_monitoring_event_for_type()` to be callable from any thread
- `src/websockets.c`: Add monitor thread lifecycle (start/stop), completion queue for broadcast
- `src/thread_pool.h`: Add api-worker thread config options
**New thread in the process:**
```
c_relay_pg process
├── lws-main — WebSocket event loop
├── db-read-1..N — Async REQ/COUNT queries
├── event-worker — Async EVENT ingestion
├── db-write-1..N — Async sub logging, misc writes
└── api-worker — NEW: periodic monitoring event generation
```
### Phase 2: Remove Monitoring from Main Thread Path
**What:** Remove `generate_event_driven_monitoring()` and `generate_subscription_driven_monitoring()` calls from the synchronous event processing path.
**Currently these are called from:**
- After event storage (event-driven monitoring)
- After subscription create/close (subscription-driven monitoring)
**After change:**
- The api-worker thread handles all monitoring on its own timer
- Event storage and subscription changes no longer trigger monitoring directly
- The throttle interval controls how fresh the data is (default 5 seconds)
**Benefit:** Event processing becomes faster — no monitoring overhead per event.
### Phase 3: Dashboard Uses Normal Subscriptions for Real-Time Events
**What:** Instead of the relay generating special monitoring events for "recent events," the dashboard simply subscribes to all events using standard Nostr REQ filters.
**Current approach:** Dashboard subscribes to kind 24567 monitoring events that contain aggregated stats.
**New approach for real-time event feed:**
```javascript
// Dashboard subscribes to ALL events on the relay
relayPool.subscribeMany([url], [
{ kinds: [1, 3, 5, 7, ...], limit: 50 } // Recent events
], {
onevent(event) {
// Render in real-time event feed
addEventToFeed(event);
}
});
```
**What this replaces:** The "Relay Events" page in the dashboard currently shows events from monitoring. Instead, it would show live events as they arrive via normal Nostr subscription.
**What stays as monitoring events:** Aggregated stats (event_kinds, time_stats, top_pubkeys, cpu_metrics, subscription_details) still need to be generated by the relay because they require SQL aggregation queries that a client can't do.
### Phase 4: Evaluate Which Monitoring Events Can Be Replaced
For each current monitoring event type, here's whether it should stay as a relay-generated event or be replaced by dashboard-side logic:
| d-tag | Current: Relay generates | Could dashboard do it? | Recommendation |
|-------|------------------------|----------------------|----------------|
| `event_kinds` | SQL: `SELECT kind, COUNT(*) FROM events GROUP BY kind` | No — requires DB aggregation | **Keep as monitoring event** |
| `time_stats` | SQL: `SELECT COUNT(*) FROM events WHERE created_at > ?` for 24h/7d/30d | No — requires DB aggregation | **Keep as monitoring event** |
| `top_pubkeys` | SQL: `SELECT pubkey, COUNT(*) FROM events GROUP BY pubkey ORDER BY count DESC LIMIT 10` | No — requires DB aggregation | **Keep as monitoring event** |
| `subscription_details` | SQL: `SELECT * FROM active_subscriptions_log` | No — requires DB access | **Keep as monitoring event** |
| `cpu_metrics` | Reads `/proc/self/stat`, memory info | No — requires server-side access | **Keep as monitoring event** |
| Real-time event feed | Currently via monitoring events | **Yes — normal Nostr subscription** | **Replace with REQ subscription** |
| Auth rules list | Currently via admin command | No — requires DB access | **Keep as admin command** |
| Config values | Currently via admin command | No — requires DB access | **Keep as admin command** |
**Conclusion:** All 5 monitoring event types should stay as relay-generated events (they require SQL aggregation or server-side data). The only thing that changes is the real-time event feed, which becomes a normal subscription.
### Phase 5: PostgreSQL LISTEN/NOTIFY for Monitoring Triggers (Future)
**What:** Instead of the monitor thread polling on a timer, PostgreSQL can push notifications when data changes.
**Example triggers:**
```sql
-- Notify when a new event is stored
CREATE OR REPLACE FUNCTION notify_event_stored() RETURNS trigger AS $$
BEGIN
PERFORM pg_notify('event_stored', json_build_object(
'kind', NEW.kind,
'pubkey', substring(NEW.pubkey, 1, 8)
)::text);
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER trg_notify_event_stored
AFTER INSERT ON events
FOR EACH ROW EXECUTE FUNCTION notify_event_stored();
```
**How the monitor thread would use it:**
```
api-worker thread:
1. LISTEN event_stored
2. Wait for notification (blocks efficiently)
3. On notification: batch up for throttle_sec, then generate monitoring events
4. Repeat
```
**Benefit:** Zero-polling. The monitor thread only wakes up when data actually changes.
**This is optional and can be added later.** The timer-based approach in Phase 1 works well and is simpler.
---
## Updated Thread Layout (After All Phases)
```
c_relay_pg process
├── lws-main — WebSocket event loop (never blocks on DB for monitoring)
├── db-read-1..4 — Async REQ/COUNT queries
├── event-worker — Async EVENT ingestion (validate + store)
├── db-write-1..2 — Async sub logging, IP bans, misc writes
└── api-worker — Periodic monitoring event generation (own PG conn)
```
**Total: 9 threads, 9 PG connections** (with 4 readers + 2 writers)
---
## Implementation Priority
| Phase | Description | Complexity | Impact |
|-------|-------------|-----------|--------|
| 1 | Dedicated api-worker thread | Medium | High — unblocks main thread |
| 2 | Remove monitoring from event processing path | Low | Medium — faster event processing |
| 3 | Dashboard uses normal subscriptions for event feed | Low (frontend only) | Medium — simpler, more reliable |
| 4 | Evaluate monitoring event types | Analysis only | Confirms architecture |
| 5 | PostgreSQL LISTEN/NOTIFY triggers | Medium | Low (optimization) — add later |
**Recommended start:** Phase 1 + 2 together (they're coupled), then Phase 3 (frontend change), then Phase 5 when needed.
---
## Relationship to Thread Pool Plan
This plan complements `thread_pool_pg_optimization_plan.md`:
- Thread pool plan handles **reader/writer scaling** for client-facing operations
- This plan handles **monitoring/API operations** on a dedicated thread
- Both plans share the same PG connection model (one connection per thread)
- The api-worker thread is independent from the thread pool — it has its own lifecycle
The combined target is:
```
lws-main (1) + readers (4) + event-worker (1) + writers (2) + api-worker (1) = 9 threads
```
+368
View File
@@ -0,0 +1,368 @@
# Backfill Redesign: Per-Author Until-Cursor Drain (Option B)
## Problem
The current window-expansion backfill (`caching/src/backfill.c`) has four
compounding structural defects that make cache coverage unacceptably poor
(~10-28% of upstream availability even for a 7-pubkey follow set):
1. **Overlapping windows re-fetch the same events.** Window N+1's time range
fully contains window N's range. The only dedup is a 4096-entry in-memory
ring + inbox `ON CONFLICT DO NOTHING`, which overflows for high-volume
authors, wasting query budget on duplicates.
2. **Broken saturation detection.** When a relay caps at 500 events, the code
retries with `limit=5000`, still gets 500, compares `500 == 5000` (false),
and incorrectly concludes the page was not saturated — marking the author
complete without paginating.
3. **`until`-cursor walk-back never triggers** because it's gated on the
broken saturation check.
4. **Window 4 (full history) was skipped for 5/7 pubkeys** and the loop won't
retry once `current_window_index` advances past the last window.
## Solution
Replace the window-expansion model with a **per-author `until`-cursor drain**:
each followed author has a single persistent cursor (starting at `now`). Each
backfill tick queries `since=0&until=cursor&limit=500` for one author,
publishes the page, and sets `cursor = oldest_event_created_at - 1`. Repeat
until a page returns fewer than the relay's cap (beginning of history reached).
The live subscriber stays as-is (it's already correct).
## Architecture
```mermaid
flowchart TD
A[cr_follow_resolve] --> B[caching_followed_pubkeys table]
B --> C[Backfill tick: pick next incomplete author]
C --> D[Query since=0 and until=cursor and limit=500]
D --> E{ev_count == relay_cap?}
E -- yes --> F[Publish page, set cursor = oldest - 1]
F --> D
E -- no < relay_cap --> G[Publish page, mark backfill_complete = true]
G --> H{More incomplete authors?}
H -- yes --> C
H -- no --> I[Steady state: live sub only]
J[Live subscriber] --> K[caching_event_inbox]
K --> L[c-relay-pg inbox poller]
L --> M[events table]
```
## Changes
### 1. New table: `caching_followed_pubkeys`
Replaces both the ephemeral in-memory `cr_pubkey_set_t` (for persistence) and
the window-coupled `caching_backfill_progress` (for cursor state).
**Schema** (add to `src/pg_schema.sql`):
```sql
CREATE TABLE IF NOT EXISTS caching_followed_pubkeys (
pubkey TEXT PRIMARY KEY,
is_root BOOLEAN NOT NULL DEFAULT FALSE,
until_cursor BIGINT NOT NULL DEFAULT 0,
backfill_complete BOOLEAN NOT NULL DEFAULT FALSE,
events_fetched INTEGER NOT NULL DEFAULT 0,
first_seen BIGINT NOT NULL DEFAULT EXTRACT(EPOCH FROM NOW())::BIGINT,
last_seen BIGINT NOT NULL DEFAULT EXTRACT(EPOCH FROM NOW())::BIGINT,
updated_at BIGINT NOT NULL DEFAULT EXTRACT(EPOCH FROM NOW())::BIGINT
);
CREATE INDEX IF NOT EXISTS idx_caching_followed_incomplete
ON caching_followed_pubkeys(backfill_complete, last_seen)
WHERE backfill_complete = FALSE;
```
- `until_cursor`: the `until` timestamp for the next backfill query. `0` means
"not started yet" (treat as `now` on first query). After each page, set to
`oldest_event_created_at - 1`.
- `backfill_complete`: set true when a page returns fewer than the relay cap
(we've reached the beginning of the author's history).
- `is_root`: true for root/admin npubs (used to apply `admin_kinds`).
- `events_fetched`: cumulative count for diagnostics.
- `last_seen`: updated each follow-graph refresh (prune stale follows).
**Migration**: On startup, if `caching_followed_pubkeys` is empty but
`caching_backfill_progress` has rows, migrate: for each distinct
`author_pubkey` in the old table, insert a row with `until_cursor = 0`,
`backfill_complete = FALSE` (re-backfill from scratch — the old data was
incomplete anyway). Drop `caching_backfill_progress` after migration.
### 2. Follow graph resolver: persist to `caching_followed_pubkeys`
**File**: `caching/src/follow_graph.c` + `caching/src/pg_inbox.c`
After `cr_follow_resolve()` builds the in-memory set, sync it to the table:
```c
/* For each pubkey in the followed set: */
/* INSERT INTO caching_followed_pubkeys (pubkey, is_root, last_seen)
* VALUES ($1, $2, now)
* ON CONFLICT (pubkey) DO UPDATE SET
* is_root = EXCLUDED.is_root,
* last_seen = EXCLUDED.last_seen;
*
* Mark follows not seen this refresh as stale (for pruning / diagnostics).
* Do NOT touch until_cursor or backfill_complete on existing rows. */
```
New function: `pg_inbox_sync_followed_pubkeys(cr_pubkey_set_t *followed,
cr_config_t *cfg)` — upserts all followed pubkeys, updates `last_seen`, sets
`is_root` based on `cr_follow_is_root()`.
New function: `pg_inbox_prune_stale_follows(long threshold_seconds)`
optionally deletes follows not seen in `threshold_seconds` (e.g. 24h). This is
off by default; stale follows just stop getting backfilled.
The in-memory `cr_pubkey_set_t followed` stays as the runtime working set
(used by the live subscriber). The table is the durable source of truth for
backfill iteration.
### 3. Rewrite `cr_backfill_tick` — per-author until-cursor drain
**File**: `caching/src/backfill.c`
Replace the entire window-expansion logic. New `cr_backfill_tick`:
```c
int cr_backfill_tick(cr_backfill_t *bf, cr_config_t *cfg,
nostr_relay_pool_t *upstream,
cr_sink_t *sink, const cr_relay_map_t *relay_map) {
if (!cfg->backfill.enabled) return -2;
/* Throttle: one query per tick_interval. */
time_t now = time(NULL);
if (bf->last_tick && (now - bf->last_tick) < cfg->backfill.tick_interval_seconds)
return 0;
bf->last_tick = now;
/* Pick the next incomplete author from the DB (round-robin via
* last_seen ordering, or a cursor we persist in bf). */
char pk[CR_HEX_LEN];
long until_cursor = 0;
int is_root = 0;
if (pg_inbox_pick_next_backfill_author(pk, &until_cursor, &is_root,
&bf->author_round_cursor) != 0) {
/* No incomplete authors — steady state. */
bf->in_progress = 0;
return -2;
}
/* First-time: cursor 0 means start from now. */
if (until_cursor == 0) until_cursor = (long)now;
int page_size = 500; /* matches typical relay cap */
int ev_count = 0;
cJSON **events = query_author(upstream, relay_map, cfg, pk,
0 /* since=0 full history */,
until_cursor, page_size, &ev_count);
if (!events || ev_count == 0) {
/* No events at all (or no more) — author is complete. */
pg_inbox_update_backfill_progress(pk, 0, 1 /* complete */, 0);
free(events);
return 1;
}
/* Find oldest timestamp BEFORE publishing. */
long oldest_ts = 0;
find_oldest_created_at(events, ev_count, &oldest_ts);
/* Publish all events. */
cr_sink_set_source_class(sink, CR_SINK_CLASS_BACKFILL);
for (int k = 0; k < ev_count; k++) {
cr_sink_publish(sink, events[k]);
cJSON_Delete(events[k]);
}
free(events);
bf->events_total += ev_count;
/* Saturation check: if we got exactly page_size, the page may be
* truncated. Set cursor = oldest - 1 and keep going next tick.
* If we got fewer than page_size, we've reached the beginning. */
if (ev_count >= page_size) {
/* Saturated — more history likely exists. Advance cursor. */
long next_cursor = (oldest_ts > 0) ? oldest_ts - 1 : 0;
pg_inbox_update_backfill_progress(pk, next_cursor, 0 /* not complete */,
ev_count);
} else {
/* Not saturated — beginning of history reached. */
pg_inbox_update_backfill_progress(pk, until_cursor, 1 /* complete */,
ev_count);
}
return 1;
}
```
Key differences from the old code:
- **No windows.** Single `since=0` query per author, always full history.
- **Saturation check compares against `page_size` (500)**, not `max_cap`
(5000). This fixes Defect 2 — if the relay returns 500, we paginate.
- **Cursor persisted per-author** in `caching_followed_pubkeys.until_cursor`,
not in a window-coupled table. This fixes Defect 4 — no window to skip.
- **No overlapping queries.** Each event is fetched exactly once as the cursor
walks backwards. This fixes Defect 1.
- **`pg_inbox_pick_next_backfill_author`** selects the next
`backfill_complete = FALSE` author, round-robin, so all authors get fair
backfill time.
### 4. New PG inbox functions
**File**: `caching/src/pg_inbox.c` + `caching/src/pg_inbox.h`
```c
/* Sync the followed set to the DB (upsert + update last_seen).
* Does NOT touch until_cursor or backfill_complete on existing rows. */
int pg_inbox_sync_followed_pubkeys(const cr_pubkey_set_t *followed,
const cr_config_t *cfg);
/* Pick the next incomplete author for backfill (round-robin).
* Sets out_pk, out_until_cursor, out_is_root.
* Returns 0 on success, -1 if no incomplete authors remain. */
int pg_inbox_pick_next_backfill_author(char *out_pk,
long *out_until_cursor,
int *out_is_root,
int *round_cursor);
/* Update backfill progress for an author. */
int pg_inbox_update_backfill_progress(const char *pk,
long until_cursor,
int complete,
int events_this_page);
/* Migrate old caching_backfill_progress rows to caching_followed_pubkeys.
* Called once on startup if the new table is empty and the old one has rows. */
int pg_inbox_migrate_backfill_progress(void);
/* Reset all backfill progress (set until_cursor=0, backfill_complete=FALSE
* for all followed pubkeys). Used by the "Reset Backfill" API button. */
int pg_inbox_reset_backfill_progress(void);
```
### 5. Simplify `cr_backfill_t` and `cr_config_t` state
**File**: `caching/src/backfill.h`, `caching/src/config.h`
Remove from `cr_backfill_t`:
- `window_index`, `current_window_s`, `events_this_window`, `window_started`
- `author_complete`, `blocked`, `blocked_until_ts`
Keep:
- `cursor` (now: round-robin index into followed set, for fair scheduling)
- `until_cursor` (transient, per-tick — not persisted here, read from DB)
- `in_progress` (1 if any incomplete authors remain)
- `events_total` (cumulative diagnostic)
- `last_tick` (throttle)
Remove from `cr_config_t` state:
- `state.backfilled_until` (no longer meaningful)
- `state.current_window_index` (no windows)
Remove from `cr_config_t` backfill config:
- `backfill.window_schedule_seconds[]`, `backfill.window_count`
- `backfill.window_cooldown_seconds`
- `backfill.events_per_tick` → rename to `backfill.page_size` (default 500)
Keep:
- `backfill.enabled`
- `backfill.page_size` (was `events_per_tick`)
- `backfill.tick_interval_seconds`
### 6. Update `caching_service_state` table
**File**: `src/pg_schema.sql`
The `current_window_index` and `backfill_cursor` columns are no longer
meaningful. Replace with:
```sql
ALTER TABLE caching_service_state
DROP COLUMN IF EXISTS current_window_index,
DROP COLUMN IF EXISTS backfill_cursor,
ADD COLUMN IF NOT EXISTS backfill_authors_complete INTEGER NOT NULL DEFAULT 0,
ADD COLUMN IF NOT EXISTS backfill_authors_total INTEGER NOT NULL DEFAULT 0;
```
Update `pg_inbox_update_status()` to report
`backfill_authors_complete / backfill_authors_total` instead of
`current_window_index / backfill_cursor`. The API status panel shows this as a
progress bar (e.g. "Backfill: 3/7 authors complete").
### 7. Update main loop
**File**: `caching/src/main.c`
- Remove `advance_window` calls (no windows).
- After `cr_follow_resolve()`, call `pg_inbox_sync_followed_pubkeys()`.
- On startup, call `pg_inbox_migrate_backfill_progress()` if needed.
- `cr_backfill_init` just sets `in_progress = 1` (check if any incomplete
authors exist in the DB).
- The follow-graph refresh at [`main.c:378`](caching/src/main.c:378) now also
calls `pg_inbox_sync_followed_pubkeys()` so new follows get backfilled and
unfollowed pubkeys stop getting backfilled.
### 8. Update API status display
**File**: `api/index.js`, `src/config.c` (caching_status handler)
- Show "Backfill: X/Y authors complete" instead of "Window N, cursor M".
- The "Reset Backfill Progress" button now calls
`pg_inbox_reset_backfill_progress()` which sets
`until_cursor=0, backfill_complete=FALSE` for all rows in
`caching_followed_pubkeys`.
### 9. Config changes
**File**: `src/pg_schema.sql` (default config inserts), `caching/src/pg_config.c`
Remove config keys:
- `caching_backfill_windows` (no window schedule)
- `caching_backfill_window_cooldown_seconds` (no windows)
Add config key:
- `caching_backfill_page_size` already exists (value 500) — repurpose as the
per-query limit (was already used this way, just rename in docs).
Keep:
- `caching_backfill_enabled`
- `caching_backfill_tick_interval_ms` (throttle between queries)
- `caching_backfill_page_size` (per-query limit, default 500)
## Implementation Order
1. **Schema**: Add `caching_followed_pubkeys` table + alter
`caching_service_state` in `src/pg_schema.sql`.
2. **PG inbox functions**: Add `pg_inbox_sync_followed_pubkeys`,
`pg_inbox_pick_next_backfill_author`, `pg_inbox_update_backfill_progress`,
`pg_inbox_migrate_backfill_progress`, `pg_inbox_reset_backfill_progress`
in `caching/src/pg_inbox.c` + `.h`.
3. **Follow graph sync**: Call `pg_inbox_sync_followed_pubkeys()` after
`cr_follow_resolve()` in `caching/src/main.c`.
4. **Rewrite backfill**: Replace `cr_backfill_tick` in
`caching/src/backfill.c`. Simplify `cr_backfill_t` in `backfill.h`.
5. **Config cleanup**: Remove window config from `caching/src/pg_config.c`
and `caching/src/config.c`. Update defaults in `src/pg_schema.sql`.
6. **State reporting**: Update `pg_inbox_update_status()` in
`caching/src/pg_inbox.c` to report authors complete/total.
7. **API status**: Update `api/index.js` and `src/config.c` status handler
to show backfill author progress.
8. **Migration**: Implement `pg_inbox_migrate_backfill_progress()` and call
on startup.
9. **Test**: Rebuild, restart with `--reset-backfill`, verify 100% coverage
for all 7 followed pubkeys via `nak req` comparison against upstream.
## Risk and Mitigation
- **Relays that don't honor `until`**: If a relay ignores the `until` filter,
the cursor won't advance and we'll re-fetch the same page. Mitigation:
detect when `oldest_ts` doesn't decrease between consecutive pages for the
same author and mark the author complete (or skip to a different relay).
- **High-volume authors take many ticks**: At 500 events/tick and 5s
tick_interval, a 10,000-event author takes ~100s. This is acceptable — the
live subscriber keeps you current, and the backfill makes steady progress.
If faster backfill is needed later, Option C (parallel workers) can be added
on top of this design.
- **Follow graph churn**: If follows change frequently, the sync upsert is
O(N) per refresh (every 10 min). Fine for personal-scale (hundreds of
follows). For thousands, batch the upsert.
+307
View File
@@ -0,0 +1,307 @@
# 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:
1. Sends the same REQ to all connected relays
2. Collects and deduplicates events from all relays into one merged set
3. Waits until ALL relays send EOSE, or the timeout (now 30s) expires
4. 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:
```sql
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`: the `until` timestamp for the next query to this specific
relay for this author. `0` means "not started" (treat as `now`).
- `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:
```c
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:
```c
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:
```c
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`
```c
/* 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
1. Schema: add `caching_backfill_relay_progress` table
2. PG inbox functions: relay progress CRUD
3. Backfill rewrite: relay-by-relay loop with per-relay cursors
4. Live subscriber: kind-3 detection + `follow_graph_changed` flag
5. Main loop: handle `follow_graph_changed`, create relay progress rows
6. Build and test
## Testing
1. Rebuild: `cd caching && make`
2. Reset backfill: restart relay with `--reset-backfill`
3. Monitor relay progress:
```sql
SELECT author_pubkey, relay_url, until_cursor, complete, events_fetched
FROM caching_backfill_relay_progress
ORDER BY author_pubkey, relay_url;
```
4. Monitor author completion:
```sql
SELECT pubkey, backfill_complete
FROM caching_followed_pubkeys ORDER BY pubkey;
```
5. Compare coverage: `nak req -k 1 -a <pk> ws://localhost:7777` vs upstream
6. Test new-follow detection: follow a new pubkey from the admin account,
verify it appears in `caching_followed_pubkeys` and gets backfilled within
seconds (not 10 minutes)
+240
View File
@@ -0,0 +1,240 @@
# C-Relay-PG Admin Page Migration Plan
## Overview
Migrate the relay admin dashboard from an embedded HTTP-served page (`api/index.html` + `api/index.js`) to a full client-ndk page (`c-relay-pg.html`) built on the client-ndk template. The page will live in the client-ndk project (`~/lt/client-ndk/www/`) alongside other pages and communicate with the relay purely through Nostr protocol subscriptions.
## Why This Migration
The current `/api` page is served directly by the relay's `lws-main` thread, which causes:
- **Slow page delivery** — HTTP file serving competes with WebSocket event processing on a single thread
- **Incomplete page loads** — 10-second `timeout_secs` kills connections when the main thread is busy
- **6+ sequential HTTP round-trips** — each asset requires a two-phase header/body delivery cycle
- **Unnecessary complexity** — embedded file generation, `lws_set_wsi_user()` overwriting, session data type dispatch
The admin page already communicates entirely via Nostr protocol:
- **Kind 23456** → Admin commands (encrypted, sent by admin)
- **Kind 23457** → Admin responses (encrypted, sent by relay)
- **Kind 24567** → Monitoring/stats events (ephemeral, broadcast by relay)
- **NIP-11** → Relay info (fetched via HTTP, independent of file serving)
- **NIP-17** → DM admin commands (gift-wrapped)
Moving to client-ndk means the relay serves zero HTML — it only speaks Nostr protocol.
## Architecture
```mermaid
graph LR
subgraph client-ndk project
A[c-relay-pg.html] --> B[init-ndk.mjs]
B --> C[NDK SharedWorker]
end
subgraph c-relay-pg
D[WebSocket Server]
E[api-worker thread]
end
C -->|kind 23456 admin cmds| D
D -->|kind 23457 responses| C
E -->|kind 24567 monitoring| D
D -->|kind 24567 events| C
C -->|kind 1059 NIP-17 DMs| D
```
## Current Admin Page Sections
| Section | Data Source | Communication |
|---------|-----------|---------------|
| Statistics | kind 24567 monitoring events with d-tags: event_kinds, time_stats, top_pubkeys, cpu_metrics | Subscribe to kind 24567 |
| Subscriptions | kind 24567 monitoring events with d-tag: subscription_details | Subscribe to kind 24567 |
| Configuration | kind 23457 admin responses | Send kind 23456 config_query, receive kind 23457 |
| Authorization | kind 23457 admin responses | Send kind 23456 auth rule commands, receive kind 23457 |
| IP Bans | kind 23457 admin responses | Send kind 23456 ip_ban_* commands, receive kind 23457 |
| Relay Events | Live subscription to all events + kind 0/10050/10002 management | Standard Nostr subscriptions |
| DM | NIP-17 gift-wrapped DMs kind 1059 | NIP-17 encrypt/wrap/publish |
| SQL Query | kind 23457 admin responses | Send kind 23456 sql_query, receive kind 23457 |
## Design Decisions
1. **Full client-ndk page** — Uses `client.css`, `init-ndk.mjs`, `HamburgerMorphing`, `relay-ui.mjs`, `blossom-ui.mjs`, `ai-ui.mjs` — all standard template infrastructure stays intact
2. **Sidenav preserved** — All existing sidenav sections (Relay, Blossom, AI) remain. Admin page navigation links are added to `divSideNavBody` as clickable items
3. **Page-specific CSS** — Follows client-ndk convention: page styles in inline `<style>` block, using only CSS variables from `client.css`
4. **Hardcoded relay URL**`wss://relay.laantungir.net` for now; future enhancement to support relay selection
5. **Auth mode: required** — Admin page requires authentication
## Migration Steps
### Step 1: Create `c-relay-pg.html` from Template
Start from `template.html` in client-ndk. Customize:
- **Title**: `C-Relay-PG`
- **Header text**: `C-Relay-PG Admin`
- **Auth mode**: `required` (default)
- **`divBody` content**: All 8 admin sections, each in its own container div, all `display:none` except Statistics (default)
- **`divSideNavBody`**: Add navigation links for each section:
```html
<div id="divAdminNav">
<div class="adminNavItem active" data-section="statistics">Statistics</div>
<div class="adminNavItem" data-section="subscriptions">Subscriptions</div>
<div class="adminNavItem" data-section="configuration">Configuration</div>
<div class="adminNavItem" data-section="authorization">Authorization</div>
<div class="adminNavItem" data-section="ip-bans">IP Bans</div>
<div class="adminNavItem" data-section="relay-events">Relay Events</div>
<div class="adminNavItem" data-section="dm">DM</div>
<div class="adminNavItem" data-section="database">Database Query</div>
</div>
```
### Step 2: Port Admin Communication Layer
Replace `SimplePool` + `nostr-lite.js` with NDK equivalents:
| Current index.js | New c-relay-pg.html |
|-----------------|---------------------|
| `new SimplePool()` | `subscribe()` from init-ndk.mjs |
| `relayPool.subscribeMany()` | `subscribe(filter, opts)` |
| `relayPool.publish()` | `publishEvent(event)` |
| `window.nostr.signEvent()` | Handled by NDK MessageBasedSigner |
| `window.NostrTools.nip42` | Handled by NDK NIP-42 support |
| `window.NostrTools.nip44` | `window.NostrTools.nip44` from nostr.bundle.js — same as before |
| `nlLite` nostr-login-lite | `initNDKPage()` + `getPubkey()` |
Key function — `sendAdminCommand(commandArray)`:
```javascript
async function sendAdminCommand(commandArray) {
const conversationKey = window.NostrTools.nip44.v2.utils.getConversationKey(
adminPrivateKey, relayPubkey
);
const encrypted = window.NostrTools.nip44.v2.encrypt(
JSON.stringify(commandArray), conversationKey
);
const event = {
created_at: Math.floor(Date.now() / 1000),
kind: 23456,
tags: [['p', relayPubkey]],
content: encrypted
};
return await publishEvent(event);
}
```
Note: NIP-44 encryption requires the admin's private key. The current page uses `window.nostr.nip44.encrypt()` which delegates to the browser extension. NDK's `publishEvent` handles signing, but encryption must be done before publishing. We'll use `window.nostr.nip44.encrypt()` if available (NIP-07 extension), same as the current page does.
### Step 3: Port Subscription Setup
Using NDK's `subscribe()` and `window.addEventListener('ndkEvent', ...)`:
```javascript
// Subscribe to admin responses + monitoring events from relay
const adminSub = subscribe(
{ kinds: [23457, 24567], authors: [RELAY_PUBKEY] },
{ closeOnEose: false, cacheUsage: 'ONLY_RELAY' }
);
// Live event feed
const liveSub = subscribe(
{ limit: 50 },
{ closeOnEose: false, cacheUsage: 'ONLY_RELAY' }
);
// Route events to handlers
window.addEventListener('ndkEvent', (e) => {
const evt = e.detail;
if (evt.kind === 23457) processAdminResponse(evt);
else if (evt.kind === 24567) processMonitoringEvent(evt);
else addEventToLiveFeed(evt);
});
```
### Step 4: Port NIP-44 Encryption
Admin commands require NIP-44 encryption. The template already loads `nostr.bundle.js` which includes `window.NostrTools.nip44`. Additionally, `window.nostr.nip44.encrypt()` is available from NIP-07 extensions. Use the same approach as the current page.
### Step 5: Port UI Sections into divBody
Each section's HTML goes into `divBody` as a container div. Page-specific styles go in the inline `<style>` block following client-ndk conventions:
- Use CSS variables only (no hardcoded colors)
- Tables use `var(--primary-color)`, `var(--muted-color)`, `var(--accent-color)`
- Buttons follow the `var(--button-*)` pattern
- Font is always `var(--font-family)`
Sections to port:
1. **Statistics** — DB overview table, event kinds table, time stats table, top pubkeys table, event rate chart area
2. **Subscriptions** — Active subscription details table
3. **Configuration** — Config key/value table with inline edit, refresh button
4. **Authorization** — Auth rules table, whitelist/blacklist inputs, WoT level selector
5. **IP Bans** — Ban stats, manual ban form, IP whitelist, ban list table with filters
6. **Relay Events** — Live event feed table, kind 0/10050/10002 management forms
7. **DM** — NIP-17 message textarea + send button, inbox display
8. **SQL Query** — Query dropdown, SQL textarea, execute button, results table
### Step 6: Page-Specific CSS
Following client-ndk rules from `client.css`:
- Override `#divBody` layout: `flex-direction: column; overflow-y: auto; padding: 20px;`
- Admin section containers: hidden by default, shown when nav item clicked
- Table styles: use `var(--primary-color)` for headers, `var(--muted-color)` for borders
- Button styles: border + transparent background, hover with `var(--accent-color)`
- All in inline `<style media="screen">` block in `<head>`
### Step 7: Relay URL — Hardcoded for Now
```javascript
const RELAY_WS_URL = 'wss://relay.laantungir.net';
const RELAY_HTTP_URL = 'https://relay.laantungir.net';
```
NIP-11 fetch to get relay pubkey:
```javascript
const nip11 = await fetch(RELAY_HTTP_URL, {
headers: { 'Accept': 'application/nostr+json' }
}).then(r => r.json());
const RELAY_PUBKEY = nip11.pubkey;
```
### Step 8: Admin Verification
Same flow as current page:
1. Get relay pubkey from NIP-11
2. Send kind 23456 `system_status` command
3. If relay responds with kind 23457, user is verified as admin
4. Show admin sections; otherwise show access denied
### Step 9 (Optional, Later): Remove Embedded HTTP Serving from Relay
After migration is complete and tested:
- Remove `handle_embedded_file_request()` from `src/api.c`
- Remove `handle_embedded_file_writeable()` from `src/api.c`
- Remove embedded file dispatch from `LWS_CALLBACK_HTTP` in `src/websockets.c`
- Remove `embed_web_files.sh` and `src/embedded_web_content.c`
- Keep NIP-11 serving (lightweight and required by protocol)
## File Changes Summary
### New File (in client-ndk project: ~/lt/client-ndk/www/)
- `c-relay-pg.html` — Complete admin page, self-contained with inline `<style>` and `<script type="module">`
### Files Unchanged
- All relay C code for kind 23456/23457/24567 processing — stays exactly the same
- `src/dm_admin.c` — NIP-17 DM handling stays the same
- `src/nip011.c` — NIP-11 stays the same
- All client-ndk shared files (client.css, init-ndk.mjs, relay-ui.mjs, etc.) — no modifications needed
### Files to Modify Later (optional relay cleanup)
- `src/api.c` — Remove embedded file serving functions
- `src/websockets.c` — Remove embedded file dispatch from HTTP callback
- `Makefile` — Remove embedded web content compilation step
## Risks and Mitigations
| Risk | Mitigation |
|------|-----------|
| NIP-44 encryption via window.nostr.nip44 | Same mechanism as current page — no change in encryption approach |
| NDK SharedWorker relay connection | Use ONLY_RELAY cache usage to ensure direct relay communication |
| Large page size with all sections inline | Sections are display:none by default — no performance impact |
| Event rate chart from text_graph.js | Port chart logic inline or skip initially |
| Relay pubkey needed before subscriptions | Fetch NIP-11 first in initialization sequence |
+72
View File
@@ -0,0 +1,72 @@
# Caching Code Consolidation Plan
## Goal
Move all caching_relay source code into a `caching/` directory inside c-relay-pg,
and switch the launcher to use PostgreSQL mode (`-p <pg-conn>`) so all config
comes from the c-relay-pg config table — no JSON config file needed.
## Key Finding
The caching_relay binary **already supports PG mode** via `-p <pg-conn>`. Its
`pg_config.c` reads the same config table keys the caching page sets
(`caching_root_npubs`, `caching_bootstrap_relays`, `caching_kinds`, etc.), and
`pg_inbox.c` writes fetched events to the `caching_event_inbox` table. The PG
schema tables already exist in `src/pg_schema.sql`.
## Steps
### Step 1: Create `caching/` directory and copy source files
Copy all `src/*.c` and `src/*.h` from `/home/user/lt/caching_relay/src/` into
`caching/src/` in this repo. Also copy the Makefile, build_static.sh,
Dockerfile.alpine-musl, and VERSION.
Files to copy:
- src/main.c, src/main.h
- src/config.c, src/config.h (legacy JSON config — kept for fallback)
- src/pg_config.c, src/pg_config.h (PG config reader)
- src/pg_inbox.c, src/pg_inbox.h (PG inbox writer)
- src/backfill.c, src/backfill.h
- src/follow_graph.c, src/follow_graph.h
- src/live_subscriber.c, src/live_subscriber.h
- src/relay_sink.c, src/relay_sink.h (legacy WebSocket sink — kept for fallback)
- src/relay_discovery.c, src/relay_discovery.h
- src/state.c, src/state.h
- src/debug.c, src/debug.h
- src/jsonc_strip.c, src/jsonc_strip.h
- Makefile
- VERSION
### Step 2: Adapt the Makefile
Update `caching/Makefile` to:
- Point `NOSTR_CORE_DIR` at `../nostr_core_lib` (sibling in this repo)
- Output binary to `caching/caching_relay` or `build/caching_relay`
### Step 3: Update the launcher to use PG mode
Update `src/caching_service_launcher.c`:
- Change `caching_service_start_fork()` to pass `-p <pg_conn>` instead of
`-c <config_path>`
- Update `caching_service_start()` to read `caching_service_pg_conn` from config
table instead of `caching_service_config_path`
### Step 4: Remove config_path from defaults and UI
- Remove `caching_service_config_path` from `src/default_config_event.h`
- Remove the "Caching Service Config Path" field from `api/index.html`
- Remove the field from `CACHING_CONFIG_FIELDS` in `api/index.js`
- Update `caching_service_binary_path` default to point at the new build
location (`./caching/caching_relay` or `./build/caching_relay`)
### Step 5: Build and test
- Build the caching_relay binary from the new `caching/` directory
- Build c-relay-pg with the updated launcher
- Test: set caching config on the page, apply, start service, verify it runs
in PG mode reading config from the database
### Step 6: Push
Run `./increment_and_push.sh` with a descriptive commit message.
@@ -0,0 +1,338 @@
# Caching Service: Descriptive Errors + Retry Limit
## Problem Summary
Two issues with the caching service backfill:
1. **Errors recorded as just "Error"** — When a relay fails, the
`caching_backfill_relay_progress.last_status` column stores the literal
string `"error"` with no detail. Investigation with `nak` showed the
relays currently flagged as `error` actually fail for **three different
reasons** that we conflate:
- TCP/connect timeout (e.g. `wss://relay.orly.dev`,
`wss://relay.snort.social` — "connection took too long")
- Query timeout (relay connected, REQ sent, but no EOSE within 30s —
e.g. `wss://nostr.wine`, `wss://nostr.land` which actually work fine
via `nak` but our 30s timeout is too aggressive)
- Connection refused / TLS handshake failure
Root cause: [`nostr_core_lib/nostr_core/core_relays.c:140-147`](../nostr_core_lib/nostr_core/core_relays.c:140)
invokes the progress callback with `status="error"`, `message=NULL`
for **all** failure paths (connect failure, REQ send failure). The
underlying [`nostr_ws_connect()`](../nostr_core_lib/nostr_websocket/nostr_websocket_openssl.c:146)
returns `NULL` with no error string surfaced. The caching callback in
[`caching/src/backfill.c:88`](../caching/src/backfill.c:88) just stores
`"error"` verbatim.
2. **No retry limit — infinite retry loop**
[`caching/src/backfill.c:342-354`](../caching/src/backfill.c:342) keeps
`complete=false` when a relay errors with no events, so the next tick
[`pg_inbox_pick_next_author_with_incomplete_relays`](../caching/src/pg_inbox.c)
picks the same author+relay again. The schema
`caching_backfill_relay_progress` has no `attempt_count` /
`consecutive_errors` / `last_attempt_at` column, so a permanently-dead
relay (e.g. `relay.orly.dev`) is retried **every tick forever**. The DB
confirms this — error rows have `updated_at` bumped repeatedly within
the same minute.
## Goals
1. Surface **descriptive error messages** from the relay (or our end) all
the way to the `last_status` column so an operator can see *why* a
relay is failing.
2. Stop retrying a (author, relay) pair after **3 consecutive errors**,
marking it `complete=true` so backfill moves on. Log a `WARN`.
3. The existing **"Reset Backfill Progress"** button (UI +
`caching_reset_progress` admin command) must also clear the new error
counters so a manual re-run starts fresh. (Duplicates are deduped by
the inbox, so re-running is safe — this matches the user's mental
model: "once we cache everyone's back events we never have to do it
again, but if a relay was down we can re-run".)
## Architecture
```mermaid
flowchart LR
A[backfill_tick] --> B[nostr_ws_connect]
B -->|fail| C[core_relays callback]
C -->|status=error, msg=descriptive| D[backfill_query_callback]
D --> E[pg_inbox_update_relay_progress]
E --> F{consecutive_errors >= 3?}
F -->|yes| G[mark complete=true, WARN log]
F -->|no| H[keep incomplete, retry next tick]
I[Reset Backfill button] --> J[caching_reset_progress]
J --> K[clear consecutive_errors=0, complete=false, until_cursor=0]
```
## Error Message Priority Order
The `last_status` column should reflect the **most descriptive** failure
reason available, in this priority order:
1. **Relay's own response text** (highest priority) — when a relay sends
a `NOTICE` or `CLOSED` message with content like
`"auth-required: subscribers only"`, `"private relay"`,
`"rate-limited: please slow down"`, etc. This **already works today**:
[`core_relays.c:326-342`](../nostr_core_lib/nostr_core/core_relays.c:326)
captures the message and
[`backfill.c:75-82`](../caching/src/backfill.c:75) formats it as
`"NOTICE: <msg>"` / `"CLOSED: <msg>"` into `last_status`. No change
needed for this path.
**DB verification (2026-07-28):** Querying the live DB shows only
`eose` (49 rows) and `error` (6 rows) — **zero NOTICE/CLOSED rows**.
So the relay-response path is wired but not currently firing for the
followed set. The 6 `error` rows are all connection/timeout failures
(confirmed by `nak`: `relay.orly.dev` and `relay.snort.social`
genuinely time out at TCP; the others work via `nak` but hit our
30s query timeout). This means **the descriptive-transport-error
work in Steps 1-3 is what will actually populate `last_status` for
the errors we see today**. The NOTICE/CLOSED path remains as a
ready fallback for when a relay does respond with a restriction
message.
2. **Our connection/transport error** (this plan, Steps 1-3) — when the
relay never responds at all (TCP timeout, TLS handshake failure,
connection refused), surface a descriptive string like
`"error: connect failed: Connection refused"` instead of just
`"error"`.
3. **Generic fallback**`"error"` / `"timeout"` only if no more
specific information is available.
So after this change, an operator querying the DB will see:
- `NOTICE: auth-required: please authenticate` (relay spoke, we listened)
- `CLOSED: rate-limited: too many requests` (relay spoke, we listened)
- `error: connect failed: Connection timed out` (relay never responded)
- `error: tls handshake failed` (relay never responded)
- `timeout` (relay connected but didn't send EOSE in 30s)
The relay's own words are always preferred over our transport-level
diagnosis.
## Implementation Plan
### Step 1 — Surface descriptive errors from `nostr_ws_connect`
**File:** [`nostr_core_lib/nostr_websocket/nostr_websocket_openssl.c`](../nostr_core_lib/nostr_websocket/nostr_websocket_openssl.c)
**File:** [`nostr_core_lib/nostr_websocket/nostr_websocket_tls.h`](../nostr_core_lib/nostr_websocket/nostr_websocket_tls.h)
Add a new variant that fills an error buffer:
```c
/* Connect and on failure write a short descriptive error into err_buf
* (at most err_buf_len-1 bytes, always null-terminated). err_buf may be
* NULL. Returns client handle or NULL. */
nostr_ws_client_t* nostr_ws_connect_with_error(const char* url,
char* err_buf,
size_t err_buf_len);
```
Implementation reuses the existing `nostr_ws_connect` body but, on each
early-return failure path, writes a descriptive string:
- URL parse failure → `"invalid url"`
- TCP connect failure → `"connect failed: <errno string>"`
- TLS handshake failure → `"tls handshake failed"`
- WebSocket handshake failure → `"ws handshake failed"`
- Memory allocation failure → `"out of memory"`
Keep `nostr_ws_connect()` as a thin wrapper that calls the new function
with `err_buf=NULL` (no behaviour change for existing callers).
### Step 2 — Pass descriptive error through `core_relays.c`
**File:** [`nostr_core_lib/nostr_core/core_relays.c`](../nostr_core_lib/nostr_core/core_relays.c)
In `synchronous_query_relays_with_progress` (lines 125-148), replace:
```c
relays[i].client = nostr_ws_connect(relays[i].url);
```
with:
```c
char connect_err[128] = {0};
relays[i].client = nostr_ws_connect_with_error(relays[i].url,
connect_err, sizeof(connect_err));
```
Then on the error paths, pass the descriptive string as the `message`
(4th) argument to the callback instead of `NULL`:
```c
if (callback) {
callback(relays[i].url, "error",
connect_err[0] ? connect_err : "connect failed",
0, relay_count, 0, user_data);
}
```
Also distinguish the REQ-send-failure path (line 138-142) with message
`"req send failed"`.
For the **timeout** path (line 184-189), the callback already sends
`status="timeout"` with `message=NULL` — leave that as-is (the caching
callback already records `"timeout"` distinctly, which is the right
semantic).
### Step 3 — Capture descriptive error in caching backfill callback
**File:** [`caching/src/backfill.c`](../caching/src/backfill.c)
Update `backfill_query_callback` (lines 58-94) so that when
`status == "error"`, the `event_id` parameter (which now carries the
descriptive message from Step 2) is included in `last_status`, the same
way NOTICE/CLOSED already are:
```c
if (strcmp(status, "error") == 0) {
ctx->got_eose = 0;
if (event_id && event_id[0] != '\0') {
snprintf(ctx->last_status, sizeof(ctx->last_status),
"error: %s", event_id);
} else {
snprintf(ctx->last_status, sizeof(ctx->last_status), "error");
}
}
```
This makes `last_status` store e.g. `"error: connect failed: Connection refused"`
or `"error: tls handshake failed"` instead of just `"error"`.
### Step 4 — Add error counter column to the schema
**File:** [`caching/src/pg_inbox.c`](../caching/src/pg_inbox.c)
In `pg_inbox_update_relay_progress` (around line 866, where
`last_status` is already ensured via `ALTER TABLE ... ADD COLUMN IF NOT
EXISTS`), add the same idempotent ALTER for the new column:
```c
"ALTER TABLE caching_backfill_relay_progress "
"ADD COLUMN IF NOT EXISTS consecutive_errors INTEGER NOT NULL DEFAULT 0"
```
Extend the UPDATE statement to bump `consecutive_errors` on error and
reset it to 0 on success (eose/timeout-with-events). The cleanest
approach: add a new parameter `int is_error` to
`pg_inbox_update_relay_progress` (or infer from `last_status` starting
with `"error"` — simpler, no signature change). We'll infer from
`last_status`:
```sql
UPDATE caching_backfill_relay_progress
SET until_cursor = $3,
complete = $4 OR (consecutive_errors >= 3),
events_fetched = events_fetched + $5,
last_status = CASE WHEN $6 = '' THEN last_status ELSE $6 END,
consecutive_errors = CASE
WHEN $6 LIKE 'error%' THEN consecutive_errors + 1
ELSE 0
END,
updated_at = EXTRACT(EPOCH FROM NOW())::BIGINT
WHERE author_pubkey = $1 AND relay_url = $2
```
The `complete = $4 OR (consecutive_errors >= 3)` clause auto-marks
complete once the threshold is hit, **without** requiring the caller to
know the count. This keeps the backfill.c logic simple.
> Note: `consecutive_errors >= 3` in the SET refers to the **pre-update**
> value (the value before this tick's increment). To mark complete on
> the *same* tick that the 3rd error is recorded, we want
> `consecutive_errors + 1 >= 3` when `$6 LIKE 'error%'`. So the clause
> becomes:
> ```sql
> complete = $4 OR ($6 LIKE 'error%' AND consecutive_errors + 1 >= 3)
> ```
### Step 5 — Log a WARN when a relay is auto-completed by error limit
**File:** [`caching/src/backfill.c`](../caching/src/backfill.c)
After each `pg_inbox_update_relay_progress` call in `cr_backfill_tick`,
check the returned status. The simplest approach: after the loop over
incomplete relays, re-query `pg_inbox_get_incomplete_relays` is not
needed — instead, in the per-relay branch where we currently log
`"complete"` vs `"incomplete (will retry)"` (lines 350-353 and 381-391),
detect the auto-complete case:
```c
int will_auto_complete = (strncmp(qctx.last_status, "error", 5) == 0);
/* The SQL will mark complete if consecutive_errors+1 >= 3. We can't
* see the count from here without an extra query, so log at WARN
* whenever an error path leaves the row incomplete — and at WARN
* when the row would be auto-completed. Simplest: just log WARN
* for every error result with the descriptive status. */
if (will_auto_complete) {
DEBUG_WARN("backfill: %s @ %s error: %s (will auto-complete after 3 consecutive)",
pk, relay_url, qctx.last_status);
}
```
Optionally, add a `pg_inbox_get_relay_error_count(pk, relay_url)` helper
to log the exact count. This is a nice-to-have, not required for the
fix.
### Step 6 — Reset error counters in "Reset Backfill"
**File:** [`caching/src/pg_inbox.c`](../caching/src/pg_inbox.c) — `pg_inbox_reset_backfill_progress`
The existing UPDATE on `caching_followed_pubkeys` is fine, but we also
need to reset the per-relay table. The relay-side admin command in
[`src/config.c:4411`](../src/config.c:4411) already does
`DELETE FROM caching_backfill_relay_progress` — which clears the new
column too (since the rows are gone). So **no change needed** here:
when the user clicks "Reset Backfill Progress", the relay progress
rows are deleted, and `cr_backfill_tick` will re-create them via
`pg_inbox_init_relay_progress_for_author` with
`consecutive_errors=0` (column default).
Verify this by re-reading
[`caching/src/pg_inbox.c`](../caching/src/pg_inbox.c)
`pg_inbox_init_relay_progress_for_author` — the INSERT must not
explicitly set `consecutive_errors` so the DEFAULT 0 applies. (If it
uses an explicit column list, add `consecutive_errors` omission
check.)
### Step 7 — Build and verify
1. Build nostr_core_lib: `cd nostr_core_lib && make`
2. Build caching: `cd caching && make`
3. Build relay: `./make_and_restart_relay.sh --start-caching --reset-backfill`
4. Watch `build/caching_relay.log` for `WARN` lines mentioning
`auto-complete after 3 consecutive`.
5. Query the DB:
```sql
SELECT author_pubkey, relay_url, complete, consecutive_errors, last_status
FROM caching_backfill_relay_progress
WHERE last_status LIKE 'error%'
ORDER BY consecutive_errors DESC;
```
Expect `last_status` values like `error: connect failed: ...` and
rows with `consecutive_errors >= 3` marked `complete=true`.
6. Manually verify a known-dead relay (`wss://relay.orly.dev`) hits the
limit and stops being retried (no more `updated_at` bumps on
subsequent ticks).
7. Click "Reset Backfill Progress" in the UI → confirm
`caching_backfill_relay_progress` is emptied and backfill restarts
from scratch.
## Files Touched
| File | Change |
|------|--------|
| `nostr_core_lib/nostr_websocket/nostr_websocket_tls.h` | Add `nostr_ws_connect_with_error()` decl |
| `nostr_core_lib/nostr_websocket/nostr_websocket_openssl.c` | Implement `nostr_ws_connect_with_error()`, make `nostr_ws_connect()` a wrapper |
| `nostr_core_lib/nostr_core/core_relays.c` | Use new connect variant, pass descriptive msg in error callback |
| `caching/src/backfill.c` | Capture descriptive msg in `last_status` for error status; WARN log on error |
| `caching/src/pg_inbox.c` | Add `consecutive_errors` column (idempotent ALTER); extend UPDATE to auto-complete at 3 |
## Out of Scope (deliberately)
- No exponential backoff — the user explicitly chose "3 errors then
stop". Backoff can be added later if needed.
- No change to the 30s query timeout — separate concern; could be
raised in a follow-up if `nostr.wine`/`nostr.land` keep timing out
despite actually being reachable.
- No new UI button — the existing "Reset Backfill Progress" button
already covers the "re-run the whole cache" use case.
+275
View File
@@ -0,0 +1,275 @@
# Caching Follows Status UI with Username Resolution
## Goal
Show a list of all followed/cached pubkeys with their **usernames**, **event counts by kind**, **outbox relays**, and **backfill status** in the relay admin web UI. Also add username resolution to the existing stats top-pubkeys table.
## Architecture Overview
```mermaid
graph LR
A[Admin Web UI] -->|system_command: caching_follows_status| B[relay config.c]
B -->|SQL query| C[(PostgreSQL)]
C -->|caching_followed_pubkeys| D[follows + backfill status]
C -->|events kind=0| E[metadata: name, picture, about]
C -->|events kind=10002| F[outbox relay URLs]
C -->|events GROUP BY kind| G[event counts per kind]
B -->|JSON response| A
```
The relay already has DB access and an admin command pipeline. We add one new admin command that joins the caching tables with the events table to produce a rich per-follow status response.
## Data Sources
All data is already in PostgreSQL — no new tables needed:
| Data | Source Table | Query |
|---|---|---|
| Followed pubkeys + backfill status | `caching_followed_pubkeys` | `SELECT pubkey, is_root, backfill_complete, events_fetched, first_seen, last_seen` |
| Per-relay backfill progress | `caching_backfill_relay_progress` | `SELECT relay_url, until_cursor, complete, events_fetched WHERE author_pubkey = $1` |
| Username / profile metadata | `events` (kind=0) | `SELECT content FROM events WHERE pubkey = $1 AND kind = 0 ORDER BY created_at DESC LIMIT 1` — content is JSON with `name`, `picture`, `about`, `nip05` |
| Outbox relays | `events` (kind=10002) | `SELECT content, tags FROM events WHERE pubkey = $1 AND kind = 10002 ORDER BY created_at DESC LIMIT 1` — tags contain `["r", "wss://..."]` entries |
| Event counts by kind | `events` | `SELECT kind, count(*) FROM events WHERE pubkey = $1 GROUP BY kind ORDER BY kind` |
## Implementation Steps
### Step 1: Username/Profile Resolution Helper
**File:** `src/db_ops_postgres.c` (and `src/db_ops.h` for the declaration)
Add a reusable function that, given a pubkey, returns the most recent kind-0 metadata as a JSON object with `name`, `picture`, `about`, `nip05`, `display_name` fields parsed from the event content.
```c
// Returns a cJSON object: {"name":"...", "picture":"...", "about":"...", "nip05":"...", "display_name":"..."}
// Returns NULL if no kind-0 event exists for this pubkey.
cJSON* db_get_profile_metadata(const char* pubkey);
```
**SQL:**
```sql
SELECT content FROM events
WHERE pubkey = $1 AND kind = 0
ORDER BY created_at DESC LIMIT 1
```
The `content` column is a JSON string (NIP-01 metadata). Parse it with cJSON and extract the fields. This function will be used by both the caching follows command and the stats top-pubkeys enrichment.
**Also add to SQLite backend** (`src/db_ops_sqlite.c`) for parity, though the primary target is PostgreSQL.
### Step 2: Outbox Relay Resolution Helper
**File:** `src/db_ops_postgres.c` (and `src/db_ops.h`)
Add a function that returns the outbox relay URLs from the most recent kind-10002 event for a pubkey.
```c
// Returns a cJSON array of relay URL strings: ["wss://relay1", "wss://relay2", ...]
// Returns NULL if no kind-10002 event exists.
cJSON* db_get_outbox_relays(const char* pubkey);
```
**SQL:**
```sql
SELECT tags FROM events
WHERE pubkey = $1 AND kind = 10002
ORDER BY created_at DESC LIMIT 1
```
The `tags` column is JSONB. Extract all `["r", "url"]` tag entries. In PostgreSQL this can be done in SQL:
```sql
SELECT tag->>1 AS relay_url
FROM events e,
jsonb_array_elements(e.tags) AS tag
WHERE e.pubkey = $1 AND e.kind = 10002
AND jsonb_typeof(tag) = 'array'
AND jsonb_array_length(tag) >= 2
AND tag->>0 = 'r'
ORDER BY e.created_at DESC
LIMIT 1
```
Actually, simpler: fetch the tags JSONB for the latest kind-10002 and parse in C with cJSON (consistent with how the rest of the codebase works).
### Step 3: New Admin Command `caching_follows_status`
**File:** `src/config.c` (in `handle_system_command_unified()`, near the existing `caching_status` handler at line 4128)
Add a new command `caching_follows_status` that:
1. Queries `caching_followed_pubkeys` for all followed pubkeys
2. For each pubkey, calls `db_get_profile_metadata()` to get the username
3. For each pubkey, queries event counts by kind: `SELECT kind, count(*) FROM events WHERE pubkey = $1 GROUP BY kind ORDER BY kind`
4. For each pubkey, calls `db_get_outbox_relays()` to get outbox relay list
5. For each pubkey, queries `caching_backfill_relay_progress` for per-relay status
6. Builds a JSON response:
```json
{
"command": "caching_follows_status",
"status": "success",
"timestamp": 1785234000,
"follows": [
{
"pubkey": "4d7842051782...",
"npub": "npub1f4uyypgh...",
"name": "username_or_null",
"picture": "url_or_null",
"is_root": false,
"backfill_complete": false,
"events_fetched": 6965,
"first_seen": 1785233973,
"last_seen": 1785233973,
"event_counts": [
{"kind": 0, "count": 1},
{"kind": 1, "count": 658},
{"kind": 6, "count": 1}
],
"total_events_in_db": 660,
"outbox_relays": [
"wss://nostr-01.yakihonne.com",
"wss://nostr.land",
"wss://relay.snort.social"
],
"relay_progress": [
{"relay_url": "wss://nos.lol", "until_cursor": 1782686796, "complete": false, "events_fetched": 1000},
{"relay_url": "wss://relay.damus.io", "until_cursor": 1785234229, "complete": true, "events_fetched": 0}
]
}
],
"total_follows": 8,
"total_events_in_db": 9843
}
```
**Performance consideration:** With up to 5000 followed pubkeys (`caching_max_followed_pubkeys`), running per-pubkey queries in a loop could be slow. Two approaches:
- **Option A (simple, fine for <100 follows):** Loop in C, call the helper functions per pubkey. With 8-50 follows this is fast enough.
- **Option B (scalable, for large follow sets):** Use a single SQL query with CTEs and LEFT JOINs to get everything in one round-trip. This is more complex but handles 5000 follows.
**Recommendation:** Start with Option A. If follow counts grow large, optimize to Option B later. The per-pubkey queries are all indexed (`idx_events_pubkey_kind`, `idx_events_pubkey`).
### Step 4: Frontend — Caching Follows Table
**File:** `api/index.html`
Add a new section to the caching page (below the existing config form and service/inbox status), containing a table:
```html
<div id="cachingFollowsSection">
<h3>Followed Pubkeys</h3>
<table id="caching-follows-table">
<thead>
<tr>
<th>Name</th>
<th>npub</th>
<th>Root?</th>
<th>Events in DB</th>
<th>Backfill</th>
<th>Outbox Relays</th>
<th>Event Counts by Kind</th>
<th>Last Seen</th>
</tr>
</thead>
<tbody id="caching-follows-table-body">
<tr><td colspan="8">Loading...</td></tr>
</tbody>
</table>
</div>
```
**File:** `api/index.js`
1. Add a `fetchCachingFollowsStatus()` function that sends `['system_command', 'caching_follows_status']` via the admin WebSocket.
2. Add a `handleCachingFollowsResponse(responseData)` function that renders the table:
- Each row shows: name (or "unknown" with a muted style), npub (as a clickable njump.me link), root badge, total events in DB, backfill status (complete/pending with relay count), outbox relays (comma-separated or expandable), event counts by kind (e.g. "k0:1, k1:658, k6:1"), last seen timestamp.
- Sort by total_events_in_db descending (most active first).
3. Hook into the existing caching page auto-refresh: in the `if (pageName === 'caching')` block at line 5445, also call `fetchCachingFollowsStatus()` and add it to the `cachingRefreshInterval` polling.
4. Add response routing in `handleSystemCommandResponse()` near line 2082:
```js
if (responseData.command === 'caching_follows_status') {
handleCachingFollowsResponse(responseData);
}
```
### Step 5: Frontend — Username in Stats Top-Pubkeys Table
**File:** `api/index.js`
Modify `populateStatsPubkeysFromMonitoring()` (line 4882) and `populateStatsPubkeys()` (line 4844) to add a "Name" column.
**Challenge:** The top-pubkeys monitoring data (`src/api.c:470` `query_top_pubkeys()`) currently returns only `{pubkey, event_count}`. To add usernames, we have two options:
- **Option A (backend enrichment):** Modify `query_top_pubkeys()` in `src/api.c` to also call `db_get_profile_metadata()` for each of the top 10 pubkeys and include `name` in the response. This is the cleanest approach — 10 extra queries on a 30-second polling cycle is negligible.
- **Option B (frontend enrichment):** The frontend separately queries kind-0 metadata for each pubkey. This is messier and adds latency.
**Recommendation:** Option A. Modify `query_top_pubkeys()` to include `name` and `picture` fields.
**Changes:**
1. `src/api.c:470` — in `query_top_pubkeys()`, after getting each pubkey row, call `db_get_profile_metadata(pubkey)` and add `name` to the pubkey object.
2. `api/index.js:4882` — in `populateStatsPubkeysFromMonitoring()`, add a "Name" column before the npub column. Display `pubkey.name || 'unknown'`.
3. `api/index.js:4844` — same change in `populateStatsPubkeys()`.
4. `api/index.html` — add a `<th>Name</th>` column to the top-pubkeys table header.
### Step 6: npub Conversion
The frontend already has `window.NostrTools.nip19.npubEncode()` available (used at `api/index.js:4902`). The backend `caching_follows_status` response should include both the hex pubkey and the npub for convenience, but the frontend can also convert. Including npub in the backend response is preferred since the backend has access to the same nostr library.
**File:** `src/config.c` — use `nostr_bytes_to_hex` / `nostr_decode_npub` from nostr_core_lib, or simply return the hex pubkey and let the frontend convert (it already does this in the stats page). **Recommendation: let the frontend convert** — it already has the pattern at line 4902, and it avoids adding nostr_core_lib dependency to the config.c command handler.
## File Change Summary
| File | Change |
|---|---|
| `src/db_ops.h` | Declare `db_get_profile_metadata()` and `db_get_outbox_relays()` |
| `src/db_ops_postgres.c` | Implement both functions (PostgreSQL) |
| `src/db_ops_sqlite.c` | Implement both functions (SQLite, for parity) |
| `src/db_ops.c` | Wire up dispatch for both functions |
| `src/config.c` | Add `caching_follows_status` command handler in `handle_system_command_unified()` |
| `src/api.c` | Enrich `query_top_pubkeys()` with `name` field |
| `api/index.html` | Add follows table to caching page; add Name column to stats top-pubkeys table |
| `api/index.js` | Add `fetchCachingFollowsStatus()`, `handleCachingFollowsResponse()`, route the response, add Name column to pubkey tables, hook into caching page auto-refresh |
## Mermaid: Data Flow
```mermaid
sequenceDiagram
participant UI as Admin Web UI
participant Relay as relay config.c
participant DB as PostgreSQL
participant Cache as caching_relay process
UI->>Relay: system_command: caching_follows_status
Relay->>DB: SELECT * FROM caching_followed_pubkeys
DB-->>Relay: 8 rows
loop each pubkey
Relay->>DB: SELECT content FROM events WHERE kind=0 AND pubkey=$1
DB-->>Relay: metadata JSON
Relay->>DB: SELECT kind,count(*) FROM events WHERE pubkey=$1 GROUP BY kind
DB-->>Relay: event counts
Relay->>DB: SELECT tags FROM events WHERE kind=10002 AND pubkey=$1
DB-->>Relay: outbox relay tags
Relay->>DB: SELECT * FROM caching_backfill_relay_progress WHERE author_pubkey=$1
DB-->>Relay: per-relay status
end
Relay-->>UI: JSON response with follows array
UI->>UI: render table with usernames, event counts, relays
```
## Edge Cases
- **No kind-0 metadata for a pubkey:** Show "unknown" in the name column with muted styling. The pubkey is still followed and being cached; we just don't have their profile yet. The backfill should eventually fetch their kind-0.
- **No kind-10002 for a pubkey:** Show "none" in the outbox relays column. The caching service falls back to bootstrap relays for these authors.
- **Large follow sets (5000):** The per-pubkey loop could be slow. Add a `LIMIT` parameter to the command (default 100, configurable). The UI can paginate or show "showing first 100 of 5000".
- **Kind-0 content is not valid JSON:** Parse defensively — if cJSON parsing fails, treat as no metadata.
- **Replaceable events:** Kind 0 is replaceable, so `ORDER BY created_at DESC LIMIT 1` gets the latest. The unique index `uq_events_replaceable_pubkey_kind` ensures only one kind-0 per pubkey is stored, so this is already handled at the DB level.
## Future Enhancements (not in this plan)
- Add/remove follows manually via admin commands (currently follows come from root npub's kind-3)
- Delete individual events via admin command
- Real-time backfill tick log streaming
- Per-follow detail view (click a row to see all events for that author)
- Search/filter the follows table by name or npub
+503
View File
@@ -0,0 +1,503 @@
# Caching Relay Daemon - Architecture Plan
> **Status:** Draft v3 - adds NIP-65 outbox model: bootstrap relays for
> discovery, kind-10002 per-pubkey relay lists, minimum covering set relay
> selection, local-relay-first on subsequent startups.
## Goal
A standalone C99 daemon that acts as a "caching relay feeder". It:
1. Reads a `.jsonc` config file listing one or more **root npubs** (e.g. your own npub), a set of upstream relays, a local relay URL, and configurable event kinds.
2. For each root npub, fetches its kind-3 contact list to discover **followed pubkeys**.
3. Subscribes **live** to events of the configured kinds from the union of followed pubkeys (root npubs + their follows).
4. Performs a **throttled backfill** of historical events per followed pubkey, spread out over time so upstream relays are not hammered.
5. Re-publishes every fetched event to the **local relay** via a plain WebSocket `EVENT` client connection (relay-agnostic - works with c-relay, c-relay-pg, or any Nostr relay).
6. Builds as a **statically linked C99 binary** following the c-relay model, linking against `nostr_core_lib` and `c_utils_lib`.
The user's Nostr client then points only at the local relay and gets a fast, pre-populated feed without doing any fan-out itself.
## Design Principles
- **Relay-agnostic on the sink side.** The daemon is just a Nostr client publishing `EVENT` messages over WebSocket. It does not touch the local relay's database directly. This keeps it decoupled and safe.
- **Reuse `nostr_core_lib`.** All Nostr protocol concerns (event validation, kind-3 parsing, relay pool, WebSocket client, NIP-19 npub decoding) come from `nostr_core_lib`. The daemon is orchestration logic only.
- **Single statically linked binary.** Built with the same Makefile pattern as c-relay: link `libnostr_core_x64.a` (or arm64) + `libc_utils.a` + system libs (`-lwebsockets -lsqlite3 -lssl -lcrypto -lsecp256k1 -lcurl -lz -ldl -lpthread -lm`).
- **C99, `-Wall -Wextra -std=c99 -g -O2`** matching c-relay's `CFLAGS`.
- **No config-file framework.** Hand-rolled `.jsonc` parser using cJSON (strip `//` and `/* */` comments before parsing, since cJSON does not natively support JSONC).
## Architecture Overview
```mermaid
flowchart TD
subgraph Config
CFG[caching_relay.jsonc]
end
subgraph Daemon
MAIN[main.c - lifecycle / signals]
CFGP[config.c - parse jsonc]
FOLLOW[follow_graph.c - root npubs + kind-3 resolution]
BACKFILL[backfill.c - throttled historical pull]
LIVE[live_subscriber.c - open subscriptions]
SINK[relay_sink.c - publish EVENT to local relay]
STATE[state.c - in-memory follow set + seen cache]
end
subgraph nostr_core_lib
UPOOL[core_relay_pool.c - upstream pool - query/subscribe]
SPOOL[core_relay_pool.c - sink pool - publish only]
NIP01[nip001.c - validate / parse]
NIP19[nip019.c - npub decode]
WS[nostr_websocket - client WS]
end
subgraph Upstream
R1[wss://relay.damus.io]
R2[wss://nos.lol]
R3[wss://...]
end
subgraph Local
LOCAL[wss://127.0.0.1:8888 - c-relay / c-relay-pg / any]
end
CFG --> CFGP
CFGP --> MAIN
MAIN --> FOLLOW
FOLLOW -->|query kind 3| UPOOL
UPOOL --> R1
UPOOL --> R2
UPOOL --> R3
FOLLOW --> STATE
MAIN --> LIVE
LIVE -->|subscribe authors + kinds| UPOOL
LIVE -->|on_event| SINK
MAIN --> BACKFILL
BACKFILL -->|query per pubkey since T| UPOOL
BACKFILL -->|on_event| SINK
SINK -->|publish_async| SPOOL
SPOOL -->|EVENT json| LOCAL
STATE --> LIVE
STATE --> BACKFILL
CFGP -->|read/write state| CFG
```
## Data Flow
```mermaid
sequenceDiagram
participant D as Daemon
participant UP as upstream_pool
participant SP as sink_pool
participant U as Upstream Relays
participant L as Local Relay
D->>D: parse caching_relay.jsonc + state
D->>UP: add_relay x N upstreams
D->>SP: add_relay local only
D->>UP: query_sync kind=3 authors=root_npubs
U-->>UP: kind-3 events
UP-->>D: followed pubkeys set
D->>D: merge root + follows into author set
D->>UP: subscribe live authors=author_set kinds=configured since=now
loop live loop - pump upstream_pool_run
U-->>UP: EVENT
UP-->>D: on_event callback
D->>SP: publish_async EVENT
SP->>L: EVENT json
D->>SP: pump sink_pool_run to flush callbacks
end
loop backfill - progressive window expansion
D->>UP: query_sync authors=pubkey_i kinds since=now-window limit=K
U-->>UP: historical events
UP-->>D: events
D->>SP: publish_async each EVENT
D->>D: sleep tick_interval_seconds
D->>D: on full round-robin pass - advance window, write state to jsonc
end
```
## Config File Format
`caching_relay.jsonc` (JSONC = JSON with comments). The config file is the
**single source of truth** and is also the daemon's persistent state store:
the daemon rewrites it to disk whenever the backfill window advances, so a
restart resumes exactly where it left off.
```jsonc
{
// Root npubs whose follows list we crawl
"root_npubs": [
"npub1...",
"npub1..."
],
// Upstream relays to pull events from
"upstream_relays": [
"wss://relay.damus.io",
"wss://nos.lol",
"wss://relay.nostr.band"
],
// Local relay to feed events into (publish-only, never queried)
"local_relay": "ws://127.0.0.1:8888",
// Event kinds to cache
"kinds": [1, 3, 6, 10000, 30023],
// ---- Backfill: progressive window expansion ----
"backfill": {
"enabled": true,
// Window schedule in seconds-from-now, applied in order.
// The daemon first pulls everything newer than (now - 24h).
// Once complete, it expands to (now - 7d), then (now - 30d), etc.
"window_schedule_seconds": [86400, 604800, 2592000, 7776000, 31536000],
// Per-pubkey query limit per tick (keeps individual queries light)
"events_per_tick": 50,
// Delay between pubkey backfill ticks (throttle to be polite)
"tick_interval_seconds": 5,
// Delay between completing one window and starting the next
"window_cooldown_seconds": 60
},
// ---- Live subscription ----
"live": {
"enabled": true,
"resubscribe_interval_seconds": 300
},
// Refresh the follow graph periodically
"follow_graph_refresh_seconds": 600,
// ---- Persistent state (managed by the daemon; do not hand-edit) ----
// The daemon writes these back to this file as backfill progresses.
// On restart it reads them to avoid re-pulling already-cached history.
"state": {
// How far back we have fully backfilled, as a unix timestamp.
// Starts at 0 / absent on first run. Advances as each window completes.
"backfilled_until": 0,
// Index into window_schedule_seconds we are currently working on.
"current_window_index": 0,
// Round-robin cursor: which followed pubkey we backfill next.
"backfill_cursor": 0
}
}
```
**State write-back rules:**
- The daemon rewrites the `.jsonc` file (preserving comments is *not* required
on rewrite - it may emit plain JSON once it has been modified at runtime;
the comments are only for the user's initial authoring convenience).
- Write-back happens: (a) when a window completes and `backfilled_until`
advances, (b) on graceful shutdown, (c) periodically (e.g. every 60s) so a
crash loses at most one minute of cursor progress.
- A write-ahead temp file + rename is used so the config is never left
half-written.
## Progressive Window-Expansion Backfill
Instead of a fixed per-pubkey cursor, the daemon uses a **global window** that
expands backwards in time in discrete steps. This is simpler, gives a natural
"recent first" experience, and is trivially resumable from the config file.
### Window schedule
`backfill.window_schedule_seconds` is an ordered list, e.g.
`[86400, 604800, 2592000, 7776000, 31536000]`
= 1 day, 1 week, 1 month, 3 months, 1 year.
### Algorithm
1. On startup, read `state.backfilled_until` (unix ts) and
`state.current_window_index` from the config.
2. If `backfilled_until == 0` (first run), set the current target window to
`window_schedule[0]` (e.g. 24h). The backfill `since` cutoff is
`now - window_schedule[0]`.
3. Round-robin through every followed pubkey. For each pubkey, issue
`nostr_relay_pool_query_sync` with:
- `authors = [pubkey]`
- `kinds = configured kinds`
- `since = now - current_window_seconds` (i.e. the *full* current window,
not an incremental slice - the local relay dedups, so re-overlap is free
and simpler than tracking per-pubkey cursors)
- `limit = events_per_tick`
4. Sleep `tick_interval_seconds` between pubkeys (throttle).
5. When one full round-robin pass over all followed pubkeys completes for the
current window:
- Set `state.backfilled_until = now - current_window_seconds`.
- Advance `state.current_window_index += 1`.
- Persist the config file (temp + rename).
- Sleep `window_cooldown_seconds` before starting the next (wider) window.
6. Repeat with the next (wider) window. The `since` cutoff moves further back
in time, so each window pulls the *additional* older slice. Because the
local relay dedups inserts, events that fall in the overlap with the
previous window are simply ignored on insert - no correctness issue.
7. After the last (widest) window completes, the daemon switches to
**steady-state**: it only keeps the live subscription running and
periodically re-runs the widest window to catch any events that migrated
into scope (e.g. a followed user backfilling their own old notes to a
different relay). `backfilled_until` stays pinned at the oldest window.
### Restart behavior
- On restart, the daemon reads `backfilled_until` and `current_window_index`.
- It resumes at the *current* window (the one that was in progress when it
stopped). Because each window re-pulls `since = now - window_seconds`, a
partial window just re-runs from the start of the round-robin - cheap and
correct.
- `state.backfill_cursor` records which pubkey in the round-robin was next;
this is a minor optimization and may be reset to 0 on restart without harm.
### Throttling summary
- `tick_interval_seconds` paces individual pubkey queries (e.g. 5s).
- `window_cooldown_seconds` paces window-to-window transitions (e.g. 60s).
- `events_per_tick` caps each query's result size (e.g. 50).
- Per-relay query latency from `nostr_relay_pool_get_relay_query_latency`
can be used to prefer fast relays for backfill; slow relays are still used
for the live subscription (where completeness matters more than speed).
## File Layout
```
caching_relay/
plans/plan.md (this file)
Makefile (c-relay-style, static link)
build_static.sh (Alpine MUSL static builder, adapted from c-relay)
Dockerfile.alpine-musl (adapted from c-relay)
src/
main.c (lifecycle, signal handling, main loop)
main.h
config.c / config.h (jsonc parse + validation)
follow_graph.c / .h (resolve root npubs -> kind-3 -> followed set)
live_subscriber.c / .h (open-ended live subscription on the pool)
backfill.c / .h (throttled historical pull worker)
relay_sink.c / .h (publish EVENT to local relay via dedicated sink pool)
state.c / .h (in-memory author set + seen-event ring buffer; config state read/write)
jsonc_strip.c / .h (strip // and /* */ comments before cJSON_Parse)
log.c / log.h (colored stderr logging, c-relay style)
examples/
caching_relay.jsonc (sample config)
README.md
```
## Build Model (mirrors c-relay)
- `Makefile` with:
- `CC = gcc`, `CFLAGS = -Wall -Wextra -std=c99 -g -O2`
- `INCLUDES = -I. -Isrc -I../nostr_core_lib -I../nostr_core_lib/nostr_core -I../nostr_core_lib/cjson -I../nostr_core_lib/nostr_websocket -I../c_utils_lib/src`
- `LIBS = -lwebsockets -lssl -lcrypto -lsecp256k1 -lcurl -lz -ldl -lpthread -lm -L../c_utils_lib -lc_utils`
- Note: no `-lsqlite3` - the daemon itself does not use SQLite. (c-relay uses
SQLite for its event store, but that is the local relay's concern, not the
daemon's.)
- `NOSTR_CORE_LIB = ../nostr_core_lib/libnostr_core_x64.a` (or arm64)
- Build `nostr_core_lib` with `--nips=1,6,19` (1 basic, 6 keys, 19 npub bech32).
NIP-42 is deferred to a later phase (public relays only for now). Kind-3
parsing is plain cJSON tag walking in NIP-01, no special NIP-02 module
needed.
- Single static link line: `$(CC) $(CFLAGS) $(INCLUDES) $(MAIN_SRC) -o $(TARGET) $(NOSTR_CORE_LIB) $(C_UTILS_LIB) $(LIBS)`
- `build_static.sh` adapted from c-relay's Alpine MUSL Docker builder to produce a truly static binary.
## Key nostr_core_lib APIs Used
- `nostr_relay_pool_create()` / `nostr_relay_pool_add_relay()` / `nostr_relay_pool_destroy()`
- `nostr_relay_pool_query_sync()` - one-shot historical/backfill queries (kind-3 fetch, per-pubkey backfill)
- `nostr_relay_pool_subscribe()` - long-lived live subscription with `on_event` / `on_eose` callbacks
- `nostr_relay_pool_run()` / `nostr_relay_pool_poll()` - event loop driving both live sub and backfill
- `nostr_relay_pool_publish_async()` - publish fetched events to the local relay
via a **dedicated single-relay sink pool** (separate from the upstream query
pool, so the local relay is never included in `query_sync` fan-out)
- `nostr_validate_event()` - validate before republishing (defensive; upstream events should already be valid)
- NIP-19: `nostr_nip19_decode` (or equivalent) to convert `npub1...` -> hex pubkey for filters
- cJSON for filter construction and kind-3 tag parsing (`["p", "<hex>", "<relay>", "<petname>"]`)
## Concurrency Model
Keep it simple and single-threaded with a cooperative event loop, matching c-relay's spirit:
- **Two `nostr_relay_pool_t` instances**, both driven from the main thread:
1. `upstream_pool` - holds all `upstream_relays`. Used for `query_sync`
(kind-3 fetch, backfill) and the long-lived live `subscribe`.
2. `sink_pool` - holds only `local_relay`. Used exclusively for
`publish_async` of fetched events. Never queried.
- The main loop alternates between:
- `nostr_relay_pool_run(upstream_pool, timeout_ms)` to pump live sub events,
- a time-sliced backfill step (one pubkey `query_sync` per
`tick_interval_seconds`),
- `nostr_relay_pool_run(sink_pool, 0)` to flush pending publish callbacks.
- Live subscription's `on_event` callback calls `relay_sink_publish()` which
enqueues an `publish_async` on the sink pool.
- Backfill `query_sync` is synchronous and blocks the upstream loop briefly -
acceptable since `events_per_tick` is small and `tick_interval_seconds`
provides pacing.
- If profiling later shows backfill blocking the live sub too much, backfill
can be moved to a second thread with its own upstream pool. Start without
that.
## State Persistence
- **No SQLite in the daemon.** The daemon's only persistent state is the
backfill window cursor, and that lives in the `.jsonc` config file itself
under the `state` object (see Config File Format above).
- The followed-pubkey set is re-derived from kind-3 on every
`follow_graph_refresh_seconds` tick and on startup - it is not persisted.
- Event dedup is delegated entirely to the local relay (c-relay's
`INSERT OR IGNORE` on event id). The daemon keeps a small in-memory ring
buffer of recently-published event ids only to avoid redundant publish
*attempts* within a single run; this is not persisted.
- Config write-back uses a temp file + atomic rename so the config is never
left half-written, even on crash.
## Signals & Lifecycle
- `SIGINT` / `SIGTERM` -> graceful shutdown: close subscriptions, destroy pool, close sink WS.
- `SIGHUP` -> reload config (re-read jsonc, refresh follow graph, adjust subscriptions).
- Logs to stderr with c-relay-style color prefixes.
## Decisions Resolved
1. **Sink pool.** Use a *dedicated* single-relay `nostr_relay_pool_t` for the
local sink, separate from the upstream pool. The local relay is never
queried, only published to. **Confirmed.**
- **Update (v3):** On subsequent startups, the local relay IS queried for
kind-3 and kind-10002 events (fast local cache lookup). It is still never
queried for general event backfill -- only for discovery metadata.
2. **NIP-42 auth.** Deferred to a later phase. Phase 1 targets public relays
only. **Confirmed.**
3. **Kind-3 freshness.** Use the most recent kind-3 per root npub
(`nostr_relay_pool_get_event` with `authors=[npub], kinds=[3]`). Re-resolve
on `follow_graph_refresh_seconds` interval. **Confirmed.**
4. **State persistence.** No SQLite in the daemon. The `.jsonc` config file is
the state store; the daemon writes back `state.backfilled_until`,
`state.current_window_index`, and `state.backfill_cursor` as backfill
progresses. **Confirmed.**
5. **Backfill strategy.** Progressive window expansion
(24h -> 7d -> 30d -> 90d -> 365d), round-robin per pubkey within each
window, full-window re-pull (local relay dedups). **Confirmed.**
6. **NIP-65 outbox model.** `upstream_relays` in config are **bootstrap
relays** only. The daemon discovers each followed pubkey's outbox relays
via kind 10002, then computes the **minimum covering set** of relays
(greedy set cover) that covers all followed pubkeys. Bootstrap relays are
always included in the final set. Pubkeys with no kind 10002 fall back to
bootstrap relays. **Confirmed.**
7. **Local-relay-first on subsequent startups.** On startup, if
`state.backfilled_until > 0`, the daemon queries the local relay first for
kind-3 and kind-10002 events (fast, no network). Falls back to bootstrap
relays for any pubkey not found locally. **Confirmed.**
8. **Admin kinds.** Root (admin) npubs get a separate kind list (`admin_kinds`)
with `["*"]` support for all kinds. Two live subscriptions: follows_sub
(non-admin, regular kinds) and admin_sub (admin, admin_kinds). **Confirmed.**
## NIP-65 Outbox Model Design (Phase 2)
### New module: `relay_discovery.c`
Responsibilities:
- For each followed pubkey, fetch their most recent kind 10002 (relay list).
- First-time: query from bootstrap relays.
- Subsequent: query from local relay first, bootstrap fallback.
- Parse `r` tags from kind 10002 events: `["r", "<url>"]` or
`["r", "<url>", "read"]` / `["r", "<url>", "write"]`.
- We care about "read" relays (we are reading events FROM them).
- If no read/write marker, assume both.
- Build a map: `pubkey -> list of outbox relay URLs`.
- Publish all kind-10002 events to the local relay (cache them for next startup).
### Greedy set cover algorithm
```
Input: pubkey_to_relays map {pubkey -> [relay1, relay2, ...]}
Output: minimal set of relay URLs covering all pubkeys
1. uncovered = set of all pubkeys
2. selected = empty set
3. Add all bootstrap relays to selected (always included)
4. For each bootstrap relay, remove its known pubkeys from uncovered
(bootstrap relays cover pubkeys with no 10002)
5. While uncovered is not empty:
a. Find relay R that covers the most pubkeys in uncovered
b. If no relay covers any uncovered pubkey, break (orphaned pubkeys)
c. Add R to selected
d. Remove all pubkeys covered by R from uncovered
6. Return selected
```
### Data structures
```c
/* Per-pubkey outbox relay list */
typedef struct {
char pubkey[CR_HEX_LEN];
char relays[CR_MAX_RELAYS_PER_PUBKEY][CR_URL_LEN];
int relay_count;
} cr_outbox_entry_t;
/* Relay-to-pubkeys coverage map (for set cover) */
typedef struct {
char url[CR_URL_LEN];
char pubkeys[CR_MAX_PUBKEYS_PER_RELAY][CR_HEX_LEN];
int pubkey_count;
} cr_relay_coverage_t;
/* Result of relay discovery */
typedef struct {
cr_outbox_entry_t *outboxes; /* per-pubkey relay lists */
int outbox_count;
char selected_relays[CR_MAX_UPSTREAM][CR_URL_LEN];
int selected_count;
} cr_relay_map_t;
```
### Startup sequence change
```
OLD:
load config -> create pools -> resolve follow graph -> open live sub -> backfill
NEW:
load config -> create pools ->
resolve follow graph (local-first on subsequent) ->
discover outbox relays (kind 10002, local-first on subsequent) ->
compute minimum covering set ->
add selected relays to upstream_pool ->
log selected relays + coverage ->
open live sub -> backfill (per-pubkey from their outbox relays)
```
### Backfill change
Instead of fanning out each backfill query to ALL upstream relays, backfill
queries each pubkey from **that pubkey's specific outbox relays** (or bootstrap
relays as fallback). This is more efficient and more polite to relays that
don't have that pubkey's events.
### New config fields
None required. `upstream_relays` is reinterpreted as bootstrap relays.
Optionally a `max_outbox_relays` cap could be added later if the covering set
grows too large.
## Implementation Todo List
### Phase 0 - Local relay sanity check (DONE)
0. **Start a local c-relay and verify read/write with `nak`.** DONE.
### Phase 1 - Daemon implementation (DONE)
1-12. All implemented, built, and tested. See git history. DONE.
### Phase 2 - NIP-65 outbox model
1. Implement `relay_discovery.c/.h`: fetch kind-10002 per pubkey, parse `r`
tags, build `pubkey -> relays` map, publish 10002 events to local relay.
2. Implement greedy set cover: compute minimum covering relay set from the
outbox map, always include bootstrap relays.
3. Update `follow_graph.c`: query local relay first for kind-3 on subsequent
startups, fall back to bootstrap relays.
4. Update `backfill.c`: query each pubkey from their specific outbox relays
instead of all upstream relays.
5. Update `main.c`: insert relay discovery phase between follow graph
resolution and live subscription. Add discovered relays to upstream_pool.
Log selected relays and coverage.
6. Update `caching_relay_config.jsonc`: update comments to say "bootstrap
relays" instead of "upstream relays".
7. Build and test NIP-65 outbox model end-to-end with a real npub.
8. Update `README.md` if any flowchart details change during implementation.
+543
View File
@@ -0,0 +1,543 @@
# Caching Relay PostgreSQL Inbox Integration Plan
> **Status:** Revised and simplified architecture.
>
> **Decision:** Keep caching as a separate application. The caching application
> focuses on polite upstream collection and writes raw event JSON to a minimal
> PostgreSQL inbox. c-relay-pg removes small priority-ordered batches and handles
> them through relay-owned event processing. The design deliberately accepts a
> small crash-loss window between dequeue and canonical storage; live overlap and
> backfill retries recover those events naturally.
## 1. Goal
Connect the standalone caching application to c-relay-pg without publishing
cached events through a loopback WebSocket and without allowing the caching
application to write directly to the canonical [`events`](../src/pg_schema.sql:12)
table.
The responsibilities are intentionally narrow:
- **Caching application:** sensibly and politely acquire the desired events.
- **PostgreSQL inbox:** temporarily hold structurally plausible event JSON.
- **c-relay-pg:** remain the sole authority for accepting, storing, and
broadcasting events.
This integration is PostgreSQL-only. The relay's normal SQLite operation remains
unchanged, but the external caching service is unavailable with that backend.
## 2. Simplified Architecture
```mermaid
flowchart LR
ROOT[Configured root npubs] --> CACHE[Caching application]
UP[Upstream relays] --> CACHE
CACHE --> INBOX[PostgreSQL caching event inbox]
INBOX --> POLLER[c-relay-pg bounded poller]
POLLER --> INGEST[Relay-owned event ingestion]
INGEST --> EVENTS[Canonical events table]
INGEST --> CLIENTS[Active subscriptions]
UI[Caching admin page] --> CFG[Shared caching configuration]
CFG --> CACHE
CACHE --> STATUS[Service heartbeat and progress]
STATUS --> UI
```
No loopback sink relay pool is used. No direct insert into the canonical event
store is allowed from the caching application.
## 3. Deliberate Simplicity Rules
The first implementation will not include:
- multiple caching workers;
- multiple inbox consumers;
- row leases or claim expiration;
- a queue state machine;
- retry rows;
- a dead-letter queue;
- PostgreSQL `LISTEN`/`NOTIFY`;
- persistent relay-health history;
- exactly-once delivery;
- direct process start/stop through the web page;
- upstream NIP-42 authentication;
- support for the SQLite backend.
The design prefers harmless duplicate acquisition over complicated delivery
coordination. Event IDs already provide natural deduplication in both the inbox
and canonical store.
## 4. Responsibility Boundaries
### 4.1 Caching application
The caching application is responsible for:
1. Reading caching configuration from PostgreSQL.
2. Decoding configured root npubs.
3. Resolving the roots' newest kind-3 follow lists.
4. Discovering useful read relays from kind 10002.
5. Computing a reasonable covering relay set.
6. Maintaining live subscriptions for roots and follows.
7. Performing polite, resumable historical backfill.
8. Inserting received event JSON into the inbox.
9. Persisting only the progress needed to resume discovery and backfill.
10. Publishing a lightweight heartbeat and status snapshot.
It is not responsible for:
- deciding whether an event belongs in the canonical relay database;
- applying c-relay-pg publication authorization;
- processing NIP-09 deletions;
- implementing replacement semantics;
- executing relay administrator commands;
- broadcasting to connected relay clients.
The caching callback may perform minimal application-side checks before insert:
- the callback supplied a JSON object;
- an event ID string exists;
- the serialized event is below a configured maximum size.
Cryptographic verification is optional in the fetcher and is not trusted by
c-relay-pg. The initial version should omit it unless the existing standalone
code already provides it at negligible integration cost.
### 4.2 PostgreSQL inbox
The inbox is deliberately dumb. It rejects obvious malformed data, deduplicates
events currently waiting in the inbox, and establishes live-over-backfill
priority. It does not attempt to verify a Nostr hash or Schnorr signature.
### 4.3 c-relay-pg
c-relay-pg is responsible for:
1. Removing a bounded batch from the inbox.
2. Parsing each event JSON object.
3. Applying authoritative structural, ID, and signature validation.
4. Applying relay-wide event rules such as expiration and PoW where applicable.
5. Bypassing client-session NIP-42 requirements because the source is internal.
6. Preventing imported kind 23456 events from executing administrator commands.
7. Applying duplicate, ephemeral, replaceable, addressable, and NIP-09 behavior.
8. Storing accepted events through its normal database abstraction.
9. Running post-store work and broadcasting newly accepted events from the main
libwebsockets thread.
10. Recording simple in-memory and cumulative import counters.
## 5. Minimal PostgreSQL Schema
Add the following objects to [`src/pg_schema.sql`](../src/pg_schema.sql) and the
embedded PostgreSQL schema header.
### 5.1 `caching_event_inbox`
Suggested logical schema:
```sql
CREATE TABLE IF NOT EXISTS caching_event_inbox (
queue_id BIGSERIAL PRIMARY KEY,
event_id TEXT NOT NULL UNIQUE,
event_json JSONB NOT NULL,
source_relay TEXT,
source_class TEXT NOT NULL DEFAULT 'backfill',
priority SMALLINT NOT NULL DEFAULT 1,
received_at BIGINT NOT NULL DEFAULT EXTRACT(EPOCH FROM NOW())::BIGINT,
CHECK (jsonb_typeof(event_json) = 'object'),
CHECK (jsonb_typeof(event_json->'id') = 'string'),
CHECK (length(event_json->>'id') = 64),
CHECK (event_id = event_json->>'id'),
CHECK (jsonb_typeof(event_json->'pubkey') = 'string'),
CHECK (length(event_json->>'pubkey') = 64),
CHECK (jsonb_typeof(event_json->'sig') = 'string'),
CHECK (length(event_json->>'sig') = 128),
CHECK (jsonb_typeof(event_json->'created_at') = 'number'),
CHECK (jsonb_typeof(event_json->'kind') = 'number'),
CHECK (jsonb_typeof(event_json->'tags') = 'array'),
CHECK (jsonb_typeof(event_json->'content') = 'string'),
CHECK (source_class IN ('live', 'discovery', 'backfill')),
CHECK (priority IN (0, 1))
);
CREATE INDEX IF NOT EXISTS idx_caching_inbox_dequeue
ON caching_event_inbox(priority, received_at, queue_id);
```
Priority meanings:
- `0`: live and discovery metadata;
- `1`: historical backfill.
The caching application uses a parameterized insert with
`ON CONFLICT (event_id) DO NOTHING`. It must impose a serialized event-size
limit before sending the row. PostgreSQL schema constraints are structural
protection, not authoritative Nostr validation.
### 5.2 `caching_service_state`
Use one singleton row for inexpensive UI status:
- service version;
- service state: `starting`, `running`, `degraded`, or `stopped`;
- applied config generation;
- heartbeat timestamp;
- followed author count;
- selected and connected relay counts;
- current backfill window and author cursor;
- events fetched and inbox inserts;
- last error text and timestamp.
This is a status snapshot, not an audit log. The caching process overwrites the
same row periodically.
### 5.3 `caching_backfill_progress`
Persist only enough state for the caching application to resume politely:
- author pubkey;
- window index;
- immutable window anchor;
- current inclusive `until` cursor;
- completion flag;
- updated timestamp.
Use a composite primary key on author and window. Do not store individual fetched
event IDs here; inbox and canonical event IDs handle deduplication.
Timestamp-only Nostr pagination can saturate when many events share one second.
For the first version, increase the query limit up to a fixed safety ceiling when
a full page ends at one timestamp. If that ceiling remains saturated, leave the
author incomplete, log it, and retry later rather than falsely marking it done.
## 6. Simple Destructive Dequeue
c-relay-pg uses one PostgreSQL transaction to remove and return a small batch:
```sql
WITH selected AS (
SELECT queue_id
FROM caching_event_inbox
ORDER BY priority ASC, received_at ASC, queue_id ASC
LIMIT $1
FOR UPDATE
)
DELETE FROM caching_event_inbox AS inbox
USING selected
WHERE inbox.queue_id = selected.queue_id
RETURNING inbox.event_id,
inbox.event_json,
inbox.source_relay,
inbox.source_class,
inbox.received_at;
```
Only one c-relay-pg inbox consumer is supported. `SKIP LOCKED`, leases, and claim
owners are therefore unnecessary.
### 6.1 Accepted trade-off
There is a small loss window if c-relay-pg commits the delete and crashes before
storing the returned events. This is accepted deliberately:
- live subscriptions reconnect with overlap;
- backfill revisits incomplete and steady-state windows;
- duplicate reacquisition is safe;
- the reduced implementation complexity is worth the rare temporary loss.
The poller should keep batches small so the loss window and memory footprint are
small.
## 7. c-relay-pg Inbox Consumer
### 7.1 Polling
- Compile the consumer only for `DB_BACKEND_POSTGRES`.
- Poll a small batch at a configurable interval.
- Poll quickly while rows are found and back off to a slower interval when empty.
- Always order live/discovery rows before backfill rows.
- Allow only one batch in memory at a time.
- Stop polling immediately during relay shutdown.
Initial conservative defaults should be small, for example a few dozen rows per
batch and subsecond-to-multisecond active polling with a longer idle interval.
Exact defaults should be selected during integration testing rather than encoded
as architectural requirements.
### 7.2 Ingestion behavior
Do not send dequeued events through a loopback WebSocket. Refactor the existing
inbound event handling into a reusable internal ingestion entry point with an
explicit source mode:
- `CLIENT_EVENT`: normal client authentication and response behavior;
- `CACHING_INBOX_EVENT`: no client session or NIP-42 requirement, no OK response,
and no administrator command execution.
The shared path should preserve:
- event ID and signature verification;
- event limits;
- expiration and PoW behavior;
- NIP-09 authorization and deletion behavior;
- ephemeral handling;
- PostgreSQL replacement semantics in
[`postgres_db_insert_event_with_json()`](../src/db_ops_postgres.c:1596);
- duplicate detection;
- post-store monitoring;
- main-thread broadcast.
Avoid creating a second implementation of these rules solely for inbox events.
### 7.3 Main-thread broadcast
Database and cryptographic work may run off the libwebsockets thread, but
[`store_event_post_actions()`](../src/main.c:1161) and active subscription
broadcast must be queued to the main thread, following the existing asynchronous
completion pattern in
[`process_async_event_completions()`](../src/websockets.c:725).
Newly stored events are broadcast. Duplicates and stale replacements are not.
Validated ephemeral events are broadcast but not stored.
### 7.4 Failure behavior
Because rows have already been removed, malformed or invalid events are simply
counted and logged at a rate-limited level. They are not requeued.
A temporary canonical database failure should stop further inbox polling until
the relay database is healthy. The current in-memory batch may be lost under the
accepted simple-delivery model.
## 8. Caching Application Acquisition Strategy
The external application should spend most of its design effort here.
### 8.1 Follow graph
- Decode configured root npubs.
- Fetch the newest kind 3 for each root from bootstrap relays.
- Include roots themselves in the author set.
- Parse valid `p` tags, deduplicate authors, and enforce a configured author cap.
- Refresh periodically and replace live subscriptions when the set changes.
Local-first querying is optional in this architecture. PostgreSQL already holds
canonical kind-3 events, so the caching application may query the canonical event
store read-only for discovery metadata before contacting upstream relays. It must
not write canonical rows.
### 8.2 NIP-65 relay discovery
- Fetch newest kind 10002 events in bounded author batches.
- Use unmarked or `read` relay tags; ignore `write`-only tags.
- Normalize and deduplicate `wss://` URLs.
- Enforce per-author and global relay caps.
- Use the greedy covering-set logic from the standalone implementation.
- Retain configured bootstrap relays as fallback.
### 8.3 Friendly live subscriptions
- Separate root and followed-author subscriptions when their kind lists differ.
- Batch author lists into reasonable filter sizes.
- Reconnect with bounded exponential backoff and jitter.
- Use a small timestamp overlap when resubscribing.
- Insert callback event JSON into the inbox and continue; do not wait for
c-relay-pg processing.
### 8.4 Friendly backfill
- Use recent-first progressive windows.
- Persist an immutable anchor for each active window.
- Query one author at a time from that author's declared outbox relays, with
bootstrap fallback.
- Use configurable page size and delay between queries.
- Apply relay-specific backoff after errors or timeouts.
- Keep global and per-relay request rates low.
- Advance progress only after the returned page has been inserted into the
inbox or identified as already queued.
- Periodically repeat a bounded widest-window sweep after initial completion.
## 9. Shared Configuration
Keep administrator intent in the existing [`config`](../src/pg_schema.sql:192)
table with category `caching` and `requires_restart = 0`.
Suggested keys:
- `caching_enabled`;
- `caching_config_generation`;
- `caching_root_npubs`;
- `caching_bootstrap_relays`;
- `caching_kinds`;
- `caching_admin_kinds`;
- `caching_live_enabled`;
- `caching_live_resubscribe_seconds`;
- `caching_backfill_enabled`;
- `caching_backfill_windows`;
- `caching_backfill_page_size`;
- `caching_backfill_tick_interval_ms`;
- `caching_backfill_window_cooldown_seconds`;
- `caching_follow_graph_refresh_seconds`;
- `caching_relay_discovery_refresh_seconds`;
- `caching_max_followed_pubkeys`;
- `caching_max_upstream_relays`;
- `caching_max_relays_per_pubkey`;
- `caching_query_timeout_ms`;
- `caching_inbox_batch_size`;
- `caching_inbox_active_poll_ms`;
- `caching_inbox_idle_poll_ms`;
- `caching_max_event_json_bytes`.
The web UI updates these through the existing encrypted administrator API. After
a successful update, increment `caching_config_generation`. The caching
application polls the generation and reloads valid changes. No cross-process
command queue is needed.
`caching_enabled = false` tells the external application to close upstream
activity. systemd or the container runtime, not the web page, owns the process
lifecycle.
## 10. Admin UI
### 10.1 Sidenav placement
In [`api/index.html`](../api/index.html:14), insert **Caching** immediately after
**Relay Events** and before **DM**:
1. Statistics
2. Subscriptions
3. Configuration
4. Authorization
5. IP BAN
6. Relay Events
7. **Caching**
8. DM
9. Database Query
Register `cachingSection` in [`switchPage()`](../api/index.js:5349).
### 10.2 Page layout
Reuse the existing admin UI classes and divide the page into three blocks.
**Service status**
- enabled configuration;
- service heartbeat and state;
- desired and applied config generation;
- followed author count;
- selected and connected relay counts;
- current backfill window and cursor;
- fetched and inbox-inserted counters;
- last service error.
**Relay inbox status**
- pending live/discovery count;
- pending backfill count;
- oldest inbox row age;
- relay-consumed, accepted, duplicate, invalid, and failed counters;
- last successful inbox poll.
**Configuration**
- root npubs and bootstrap relays;
- normal and root kind lists;
- live and backfill toggles;
- backfill windows and pacing;
- discovery refresh intervals;
- author and relay safety caps;
- inbox batch and polling settings;
- **Apply Configuration** button;
- **Reset Backfill Progress** button with confirmation.
The UI reads service state from the singleton status row and lightweight inbox
aggregates. Poll only while the Caching page is visible and use a modest refresh
interval.
## 11. Process and Database Security
Use separate PostgreSQL roles:
- **Caching service role:** read caching configuration and permitted canonical
discovery metadata; insert/select its own status and progress; insert into the
inbox; no insert/update/delete permission on canonical events.
- **c-relay-pg role:** normal relay permissions plus dequeue permission on the
inbox and read access to caching status.
Use parameterized SQL throughout. Restrict public upstreams to normalized
`wss://` URLs. Apply a database statement timeout to caching queries so the
fetcher cannot hold resources indefinitely.
## 12. Lifecycle
### 12.1 Caching service
- Starts and stops independently under systemd or a container runtime.
- On startup, loads configuration and progress, updates heartbeat state, then
begins discovery/live/backfill.
- On shutdown, closes subscriptions, saves current progress, marks status
stopped, and disconnects from PostgreSQL.
- Its failure does not stop c-relay-pg; cached acquisition merely pauses.
### 12.2 c-relay-pg
- Starts the inbox poller only for PostgreSQL builds after database, writer pool,
and WebSocket systems are ready.
- Stops new polls before shutting down the writer pool or WebSocket context.
- Drains already-created main-thread completions before destroying those
dependencies.
- A missing inbox table should be logged as caching unavailable, not terminate
the relay, unless schema migration policy requires otherwise.
## 13. Implementation Sequence
1. Add `caching_event_inbox`, `caching_service_state`, and
`caching_backfill_progress` to the PostgreSQL schema and embedded schema.
2. Add PostgreSQL database abstraction functions for bounded destructive dequeue
and lightweight inbox counts.
3. Refactor c-relay-pg inbound event processing into a shared source-aware
ingestion entry point without changing normal client behavior.
4. Add the PostgreSQL-only inbox poller, bounded in-memory batch, and main-thread
post-action/broadcast completion path.
5. Add caching configuration defaults and validation to c-relay-pg.
6. Adapt the standalone caching application to read PostgreSQL configuration,
insert raw event JSON into the inbox, and update heartbeat/status.
7. Correct the standalone backfill implementation so limited queries paginate
and do not falsely complete an author/window.
8. Persist simple per-author/window progress and implement polite retry/backoff.
9. Add the Caching page after Relay Events and before DM, including service,
inbox, configuration, and reset-progress controls.
10. Add PostgreSQL role/grant documentation and systemd/container deployment
examples for the separate caching process.
11. Test live priority, duplicate acquisition, invalid inbox rows, replacement,
deletion, ephemeral broadcast, process restarts, destructive-dequeue crash
behavior, upstream outages, and graceful shutdown.
12. Build and validate c-relay-pg only through
[`make_and_restart_relay.sh`](../make_and_restart_relay.sh), using
`--preserve-database` where test state must survive.
## 14. Acceptance Criteria
- The caching application cannot write canonical event rows.
- Received upstream events require only minimal fetcher checks before inbox
insertion.
- Inbox constraints reject structurally implausible rows and deduplicate pending
event IDs.
- c-relay-pg removes small batches ordered live/discovery before backfill.
- c-relay-pg remains the authoritative ID/signature validator and storage owner.
- Imported events never execute relay administrator commands or require a client
NIP-42 session.
- Newly accepted events are broadcast to active subscriptions from the main
libwebsockets thread.
- Duplicate and stale replacement events are not rebroadcast.
- The caching service behaves politely through bounded filters, paced backfill,
targeted outbox queries, and retry backoff.
- Backfill progress survives caching-service restarts and does not falsely mark
saturated timestamps complete.
- A caching-service failure does not stop the relay.
- The accepted destructive-dequeue crash window is documented and recoverable
through live overlap and repeated backfill.
- The Caching UI appears after Relay Events and before DM and distinguishes
external service health from relay inbox consumption.
+113
View File
@@ -0,0 +1,113 @@
# Caching Status "Not Implemented" Fix Plan
## Root Cause
The caching page shows "Service status unavailable (caching_status command not
implemented on relay)" — but the command **is** implemented at
[`src/config.c:4128`](../src/config.c:4128). The real problem is **admin
authorization failure**: every admin command from the browser is rejected at
publish time with:
```
Unauthorized admin event attempt: invalid admin pubkey
```
This rejection happens at [`src/main.c:2249`](../src/main.c:2249) because the
browser's pubkey is not in the relay's `admin_pubkey` config list. The kind
23456 event never reaches the command handler, so `caching_status` never
executes, and the placeholder text persists.
### Confirmed admin key
- Relay admin pubkey (hex): `6a04ab98d9e4774ad806e302dddeb63bea16b5cb5f223ee77478e861bb583eb3`
- Relay admin npub: `npub13lm5wf8dvsdnc2894pkhch9uf8phvw9varrv8zf4sc885hhdmc8q6lx7ks`
- Source: [`.relay.laantungir.net.keys`](../.relay.laantungir.net.keys:1)
The browser extension (nos2x) was using pubkey `8ff74724...`, which is not an
admin on the port 7777 relay.
## Secondary Issue (latent)
Even after auth is fixed, the caching status UI would render **empty blocks**
due to a schema mismatch between backend and frontend:
| Frontend expects ([`api/index.js:7046`](../api/index.js:7046)) | Backend emits ([`src/config.c:4135`](../src/config.c:4135)) |
|---|---|
| `data.service.enabled` | `data.caching_enabled` |
| `data.service.running` | *(not emitted)* |
| `data.inbox.enabled` | `data.caching_inbox_enabled` |
| `data.inbox.queue_depth` | `data.inbox_pending_live` + `data.inbox_pending_backfill` |
| *(not expected)* | `data.inbox_total_dequeued`, `data.inbox_total_accepted`, `data.inbox_total_rejected`, `data.inbox_oldest_age_seconds` |
## Fix Steps
```mermaid
flowchart TD
A[Step 1: Fix admin auth] --> B[Step 2: Align response schema]
B --> C[Step 3: Fix frontend handler]
C --> D[Step 4: Improve error messaging]
D --> E[Step 5: Test end-to-end]
```
### Step 1 — Fix admin authorization (config, user action)
Load the admin private key (corresponding to `6a04ab98...` /
`npub13lm5wf8dvsdnc2894pkhch9uf8phvw9varrv8zf4sc885hhdmc8q6lx7ks`) into the
nos2x browser extension so admin commands authenticate. Use `nak` on the
command line to convert/derive the nsec if needed.
This unblocks **all** admin commands, not just caching.
### Step 2 — Align backend response schema (code, [`src/config.c:4128`](../src/config.c:4128))
Restructure the `data` object in the `caching_status` handler to emit nested
`service` and `inbox` objects matching the frontend handler:
```json
{
"command": "caching_status",
"status": "success",
"data": {
"service": {
"enabled": false,
"running": false,
"connected_relays": 0,
"events_cached": 0
},
"inbox": {
"enabled": false,
"running": true,
"queue_depth": 0,
"last_poll": 0,
"total_dequeued": 0,
"total_accepted": 0,
"total_rejected": 0,
"total_duplicates": 0,
"pending_live": 0,
"pending_backfill": 0,
"oldest_age_seconds": 0
}
}
}
```
Populate `service.running` / `connected_relays` / `events_cached` from the
caching service launcher state if available; otherwise emit zeros/defaults.
### Step 3 — Update frontend handler (code, [`api/index.js:7027`](../api/index.js:7027))
Update `handleCachingStatusResponse()` to render the inbox poller stats the
backend actually produces (dequeued/accepted/rejected/pending/oldest_age), and
map `data.inbox.pending_live + pending_backfill` to queue depth.
### Step 4 — Replace misleading placeholder (code, [`api/index.js:6889`](../api/index.js:6889))
- Change the pre-send placeholder from "caching_status command not implemented"
to a neutral "Loading..." message.
- On auth failure, show the actual error (e.g., "Not authorized: pubkey not
registered as admin") instead of the generic "unavailable" text.
### Step 5 — Test end-to-end on port 7777
Verify the caching page shows real service/inbox status after auth is fixed
and the schema is aligned.
+201
View File
@@ -0,0 +1,201 @@
# Multi-Admin Support Plan
## Overview
Enable multiple Nostr pubkeys to act as relay administrators. Currently the relay stores a single `admin_pubkey` string in the `config` table and uses `strcmp()` equality checks throughout the codebase. This plan converts that to a comma-separated list of pubkeys stored in the same config key, with a centralized `is_admin_pubkey()` helper that all authorization gates call.
## Design Principles
1. **Minimal schema change** — keep using the existing `config` table with key `admin_pubkey`. The value changes from a single 64-char hex string to a comma-separated list of 64-char hex strings.
2. **Primary admin concept** — the first pubkey in the list is the "primary admin" (the original one). Only the primary admin can add/remove other admins. This prevents privilege escalation.
3. **Centralized check** — introduce one function `is_admin_pubkey(const char* pubkey)` that all 8 authorization gates call instead of inline `strcmp()`.
4. **In-memory cache** — parse the comma-separated list once on startup/config-change into a static array for O(1)-ish lookups without repeated string parsing.
5. **Backward compatible** — a single pubkey with no commas works identically to today.
## Data Model
```
config table:
key = "admin_pubkey"
value = "hex1,hex2,hex3" (comma-separated, no spaces)
data_type = "string"
```
Maximum admins: 10 (compile-time constant `MAX_ADMIN_PUBKEYS`).
## Architecture
```mermaid
flowchart TD
A[Config Table: admin_pubkey = hex1,hex2,hex3] --> B[reload_admin_pubkeys_cache]
B --> C[Static array: g_admin_pubkeys with count]
D[Any authorization check] --> E[is_admin_pubkey - pubkey]
E --> C
F[CLI: -a flag] --> G[first_time_startup_sequence]
G --> A
H[Admin command: add_admin / remove_admin] --> I[modify_admin_list]
I --> A
I --> B
```
## Implementation Steps
### Step 1: Add centralized admin check infrastructure in config.c / config.h
**New constants and types:**
```c
#define MAX_ADMIN_PUBKEYS 10
```
**New functions in config.h:**
```c
int is_admin_pubkey(const char* pubkey); // Returns 1 if pubkey is in admin list
int get_admin_pubkey_count(void); // Returns number of admins
const char* get_primary_admin_pubkey(void); // Returns first admin pubkey
int reload_admin_pubkeys_cache(void); // Parse config value into cache
int add_admin_pubkey(const char* pubkey); // Add pubkey to admin list (primary admin only)
int remove_admin_pubkey(const char* pubkey); // Remove pubkey from admin list (primary admin only)
```
**Implementation in config.c:**
- Static array `g_admin_pubkeys[MAX_ADMIN_PUBKEYS][65]` and `g_admin_pubkey_count`
- `reload_admin_pubkeys_cache()` reads `admin_pubkey` from config table, splits on commas, validates each is 64 hex chars, populates array
- `is_admin_pubkey()` iterates the cached array with `strcmp()`
- `get_primary_admin_pubkey()` returns `g_admin_pubkeys[0]` — used where only the primary admin matters (WoT sync, etc.)
- `add_admin_pubkey()` / `remove_admin_pubkey()` modify the comma-separated string in the config table and call `reload_admin_pubkeys_cache()`
### Step 2: Replace all 8 authorization strcmp gates
Each of these currently does:
```c
const char* admin_pubkey = get_config_value("admin_pubkey");
if (strcmp(sender_pubkey, admin_pubkey) == 0) { /* authorized */ }
```
Replace with:
```c
if (is_admin_pubkey(sender_pubkey)) { /* authorized */ }
```
**Files and locations:**
| File | Function | Line | Change |
|------|----------|------|--------|
| src/config.c | `process_admin_config_event()` | ~1441 | Replace strcmp with `is_admin_pubkey()` |
| src/config.c | `handle_kind_23456_unified()` | ~2727 | Replace strcmp with `is_admin_pubkey()` |
| src/main.c | `verify_admin_event()` | ~1905 | Replace strcmp with `is_admin_pubkey()` |
| src/main.c | Event storage / WoT trigger | ~1174 | Replace strcmp with `is_admin_pubkey()` |
| src/websockets.c | NIP-42 auth bypass | ~2324 | Replace strcmp with `is_admin_pubkey()` |
| src/websockets.c | NIP-17 DM admin check | ~3560 | Replace strcmp with `is_admin_pubkey()` |
| src/dm_admin.c | `process_admin_dm()` | ~503 | Replace strcmp with `is_admin_pubkey()` |
| src/request_validator.c | Event validation bypass | ~307 | Replace strcmp with `is_admin_pubkey()` |
Each replacement also eliminates the `get_config_value("admin_pubkey")` call and its associated `free()`, simplifying the code.
### Step 3: Update startup / initialization pipeline
**config.c `first_time_startup_sequence()`:**
- No change needed — it already stores a single pubkey. The comma-separated format with one entry is identical to the current format.
**config.c `populate_all_config_values_atomic()`:**
- No change needed — inserts the initial admin_pubkey as a single value.
**config.c `add_pubkeys_to_config_table()`:**
- No change needed — stores single pubkey on migration.
**config.c `apply_cli_overrides_atomic()`:**
- No change needed — CLI override replaces the entire admin_pubkey value.
**main.c startup validation:**
- Update the pre-warm validation at ~line 2438 to call `reload_admin_pubkeys_cache()` and validate that at least one admin pubkey exists, rather than checking for exactly 64 chars.
**Call `reload_admin_pubkeys_cache()`** after:
- `populate_all_config_values_atomic()` completes
- `reload_config_from_table()` completes
- Any admin add/remove operation
### Step 4: Add admin management commands to kind 23456 API
Add two new admin commands that can be sent as encrypted kind 23456 events:
```json
["add_admin", "pubkey_hex"]
["remove_admin", "pubkey_hex"]
["list_admins"]
```
**Authorization rule:** Only the primary admin (first in list) can add/remove other admins. This is enforced in the command handler, not in the general `is_admin_pubkey()` check.
**Implementation location:** `handle_system_command_unified()` in config.c, alongside existing commands like `system_status`, `graceful_shutdown`, etc.
### Step 5: Update WoT sync to handle multiple admins
In `wot_sync_from_admin_kind3()`:
- Currently queries kind 3 events from the single admin pubkey
- Change to use `get_primary_admin_pubkey()` — WoT trust anchor should remain the primary admin
- This is a deliberate design choice: secondary admins can manage the relay but the WoT trust graph is rooted in the primary admin's social graph
### Step 6: Update CLI to accept multiple admin pubkeys
**Option A - Comma-separated single flag (recommended):**
```bash
./c_relay_pg -a npub1...,npub2...,npub3...
```
**Changes:**
- `cli_options_t.admin_pubkey_override` becomes `char admin_pubkeys_override[MAX_ADMIN_PUBKEYS * 65]` (larger buffer for comma-separated list)
- CLI parsing in main.c splits on commas, decodes each npub/hex, reassembles as comma-separated hex
- `make_and_restart_relay.sh` `-a` flag documentation updated
### Step 7: Update config validation in api.c
In the config validation table at line ~912:
```c
{"admin_pubkey", "string", 0, 65},
```
Change max length to accommodate multiple pubkeys:
```c
{"admin_pubkey", "string", 0, MAX_ADMIN_PUBKEYS * 65},
```
### Step 8: Update get_admin_pubkey_cached
`get_admin_pubkey_cached()` currently returns the raw config value. Update to return only the primary admin pubkey for backward compatibility with any callers that expect a single 64-char string.
### Step 9: Update test scripts
Update test scripts that hardcode admin keys to work with the multi-admin system:
- `tests/sql_test.sh`
- `tests/white_black_test.sh`
- `tests/17_nip_test.sh`
- `tests/run_all_tests.sh`
- `tests/.test_keys.txt`
Add new test cases for:
- Adding a second admin via kind 23456 command
- Verifying second admin can send admin commands
- Verifying non-admin cannot send admin commands
- Removing a secondary admin
- Verifying primary admin cannot be removed
- Verifying secondary admin cannot add/remove admins
### Step 10: Update documentation
- `AGENTS.md` — update admin API event structure section
- `docs/configuration_guide.md` — document multi-admin setup
- `docs/user_guide.md` — document admin management commands
- `API.md` — document new add_admin/remove_admin/list_admins commands
- `README.md` — mention multi-admin capability
## Migration / Backward Compatibility
- Existing databases with a single `admin_pubkey` value work without any migration — a string with no commas is treated as a single-admin list
- The `is_admin_pubkey()` function handles both formats transparently
- No schema changes needed — the config table already stores strings of arbitrary length
## Security Considerations
1. **Primary admin privilege** — only the first pubkey in the list can add/remove admins, preventing secondary admins from locking out the primary
2. **No self-removal** — the primary admin cannot remove themselves (prevents accidental lockout)
3. **Max admin limit** — capped at 10 to prevent abuse and keep the linear scan fast
4. **Validation** — each pubkey in the list is validated as 64 hex characters before storage
+305
View File
@@ -0,0 +1,305 @@
# PHP Admin Page for Caching Service
## Problem
The existing Nostr-based admin API (`caching_follows_status` kind-23456
command) encrypts its JSON response with NIP-44, which has a hard 64KB
plaintext limit. With 679+ followed pubkeys, the response exceeds this
limit by ~2-4×, causing `Encryption result code: -15`
(`NOSTR_ERROR_NIP44_BUFFER_TOO_SMALL`) and the UI shows
"Failed to load".
Rather than paginating the NIP-44 admin command (complex, still limited),
we build a **separate traditional admin page** using PHP + nginx that
queries PostgreSQL directly. This bypasses the NIP-44 limit entirely,
scales to thousands of follows, and runs in parallel with the existing
Nostr admin API (no changes to the relay binary needed).
## Architecture
```mermaid
flowchart LR
Browser[Admin Browser] -->|HTTPS| Nginx
Nginx -->|/admin/ *.php| PHP_FPM[PHP-FPM]
Nginx -->|/ and /api| Relay[C-Relay-PG :8888]
PHP_FPM -->|PDO| PostgreSQL[(PostgreSQL crelay)]
Relay -->|libpq| PostgreSQL
```
- **nginx** fronts everything on 443 (existing SSL setup).
- `/` and `/api` → proxy to C-Relay-PG (existing, unchanged)
- `/admin/` → PHP-FPM (new)
- **PHP-FPM** runs as a pool (e.g. `www` or a dedicated `relay-admin`
pool). PHP files live in `/opt/c-relay-pg/admin/` (or similar).
- **PDO** connects to PostgreSQL using the same `crelay` credentials
the relay uses. Read-only queries for display; write queries only for
config updates (with auth guard).
- **No relay restart needed** — this is purely nginx + PHP-FPM
configuration + static files.
## Tech Stack
- **PHP 8.x** with PDO PostgreSQL extension (`php-pgsql`)
- **PHP-FPM** managed by systemd (or the existing nginx PHP-FPM pool)
- **nginx** `location /admin/` block with `fastcgi_pass` to PHP-FPM
- **Frontend**: vanilla HTML + JS (no build step), same visual style as
the existing `api/index.html` admin page. Optionally use HTMX or
Alpine.js for lightweight interactivity, but vanilla JS is sufficient.
## Authentication
The PHP admin page needs its own auth since it's not going through the
Nostr admin command path. Options (in order of simplicity):
1. **HTTP Basic Auth** (nginx-level) — simplest, sufficient for a
single-admin relay. Add `auth_basic` to the `/admin/` location block
with a `.htpasswd` file.
2. **Session-based login** — PHP login form, password hash in a config
file, session cookie. More flexible but more code.
3. **Nostr login (NIP-07 extension)** — the page could verify a signed
auth event from the admin's browser extension, matching against the
`admin_pubkey` in the config table. Most aligned with the existing
system but most complex.
**Recommendation:** Start with HTTP Basic Auth (option 1) for the MVP,
upgrade to Nostr login later if desired.
## Database Access
PHP connects via PDO with the same credentials the relay uses:
```
host=localhost port=5432 dbname=crelay user=crelay password=crelay
```
Store the connection string in a PHP config file
(`/opt/c-relay-pg/admin/config.php`) that is NOT web-accessible (outside
the document root or protected by nginx).
## Pages / Endpoints
### 1. Dashboard (`/admin/index.php`)
Overview cards:
- Service state, heartbeat age, config generation (from
`caching_service_state`)
- Followed author count, selected relay count, connected relay count
- Events fetched, inbox inserts, inbox pending count
- Backfill progress: X/Y authors complete
- Active backfill target (pubkey + relay) from `caching_backfill_active`
- Error relays summary (count of rows with `consecutive_errors >= 3`)
### 2. Follows (`/admin/follows.php`)
The main table that was failing via NIP-44. Paginated server-side:
- Page size: 50 or 100 follows per page
- Columns: Name (from kind-0), npub, Root?, Events in DB, Backfill
Complete, Relays (expandable), Last Seen
- JOIN with `events` table for kind-0 profile metadata (name,
display_name, picture, nip05)
- JOIN with `caching_backfill_relay_progress` for per-relay status
- Filter: all / incomplete only / root only / error relays only
- Search by name or pubkey prefix
- Sort by: events_fetched desc, name, last_seen, backfill_complete
SQL (paginated):
```sql
SELECT fp.pubkey, fp.is_root, fp.backfill_complete, fp.events_fetched,
fp.first_seen, fp.last_seen,
e.content::json->>'name' AS name,
e.content::json->>'display_name' AS display_name,
e.content::json->>'picture' AS picture,
e.content::json->>'nip05' AS nip05,
(SELECT COUNT(*) FROM events WHERE pubkey = fp.pubkey) AS total_events
FROM caching_followed_pubkeys fp
LEFT JOIN LATERAL (
SELECT content FROM events
WHERE pubkey = fp.pubkey AND kind = 0
ORDER BY created_at DESC LIMIT 1
) e ON true
ORDER BY fp.is_root DESC, fp.events_fetched DESC
LIMIT $page_size OFFSET $offset;
```
### 3. Relay Progress (`/admin/relays.php`)
Per (author, relay) backfill status from
`caching_backfill_relay_progress`:
- Columns: Author (name + npub), Relay URL, Until Cursor, Complete,
Events Fetched, Consecutive Errors, Last Status, Updated At
- Filter: incomplete only / error relays / complete / all
- Highlight rows with `consecutive_errors >= 3`
- Show `last_status` (now descriptive: `error: tls/ws handshake failed`,
`eose`, `timeout`, etc.)
### 4. Config (`/admin/config.php`)
Read and edit caching config values from the `config` table:
- Read: `caching_root_npubs`, `caching_bootstrap_relays`,
`caching_kinds`, `caching_admin_kinds`, all `caching_*` keys
- Edit: form to update values, bumps `caching_config_generation` on
save (so the caching service hot-reloads)
- This replaces the "Apply Configuration" button for caching settings
### 5. Inbox (`/admin/inbox.php`)
Monitor the `caching_event_inbox` queue:
- Pending count by source_class (live / backfill / discovery)
- Oldest pending age
- Recent events preview (last 20, with kind + pubkey + source)
- This helps diagnose if the poller is keeping up
## File Structure
```
/opt/c-relay-pg/admin/ # PHP document root (or symlink)
├── config.php # DB connection config (NOT web-served)
├── index.php # Dashboard
├── follows.php # Followed pubkeys table (paginated)
├── relays.php # Per-relay backfill progress
├── config.php # Caching config editor
├── inbox.php # Inbox queue monitor
├── api/
│ ├── follows.php # JSON endpoint for follows (AJAX)
│ ├── relays.php # JSON endpoint for relay progress
│ ├── status.php # JSON endpoint for dashboard stats
│ └── config.php # JSON endpoint for config get/set
├── assets/
│ ├── style.css # Shared CSS (match existing admin theme)
│ └── app.js # Shared JS (pagination, search, refresh)
└── .htpasswd # HTTP Basic Auth passwords
```
In the repo, these live under `admin/` (new top-level directory,
parallel to `api/`).
## nginx Configuration
Add to the existing `server` block in
[`examples/deployment/nginx-proxy/nginx.conf`](../examples/deployment/nginx-proxy/nginx.conf):
```nginx
# PHP admin page for caching service (direct PostgreSQL access)
location /admin/ {
root /opt/c-relay-pg;
index index.php;
# HTTP Basic Auth
auth_basic "Relay Admin";
auth_basic_user_file /opt/c-relay-pg/admin/.htpasswd;
# Pass .php files to PHP-FPM
location ~ \.php$ {
fastcgi_pass unix:/run/php/php8.2-fpm.sock;
fastcgi_index index.php;
include fastcgi_params;
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
}
# Deny access to config.php (contains DB credentials)
location = /admin/config.php {
deny all;
}
}
```
## Implementation Plan
### Step 1 — Server setup (one-time)
- Install PHP 8.x + PHP-FPM + `php-pgsql` on the relay server
- Create `/opt/c-relay-pg/admin/` directory
- Configure PHP-FPM pool (or use default `www` pool)
- Add the nginx `location /admin/` block
- Create `.htpasswd` with `htpasswd` / `openssl passwd`
- Reload nginx
### Step 2 — PHP config + DB connection helper
- `admin/lib/db.php` — PDO connection helper, read-only by default
- `admin/config.php` — connection string (protected from web access)
- Test: `php -r "echo 'ok';"` + simple `SELECT 1` query
### Step 3 — Dashboard page (`index.php`)
- Query `caching_service_state` for overview stats
- Query `caching_backfill_active` for active target
- Query `caching_backfill_relay_progress` for error summary
- Display as cards + summary table
- Auto-refresh every 15s via JS `setInterval` + `fetch()`
### Step 4 — Follows page (`follows.php`)
- Server-side pagination (page param in URL)
- JOIN with `events` for kind-0 profile metadata
- Sub-query for per-pubkey event count
- Expandable relay progress per follow (AJAX load on click)
- Search + filter controls
- This is the page that replaces the broken NIP-44 `caching_follows_status`
### Step 5 — Relay progress page (`relays.php`)
- Full `caching_backfill_relay_progress` table with pagination
- Filter by complete/incomplete/error
- Show `last_status` and `consecutive_errors` columns
- Color-code: green=eose, yellow=timeout, red=error, gray=complete
### Step 6 — Config editor page (`config.php``admin/config-edit.php`)
- Read all `caching_*` config keys
- Form to edit `caching_root_npubs`, `caching_bootstrap_relays`,
`caching_kinds`, etc.
- On save: `UPDATE config SET value = $1 WHERE key = $2` + bump
`caching_config_generation`
- Confirmation dialog before applying
### Step 7 — Inbox monitor page (`inbox.php`)
- Pending counts by source_class
- Oldest pending age
- Recent 20 events preview
- Poller stats (from relay log or a new stats table)
### Step 8 — Styling + polish
- Match the existing `api/index.css` dark theme
- Responsive layout (works on mobile)
- Auto-refresh for dashboard and follows pages
## Security Considerations
1. **HTTP Basic Auth** on `/admin/` — minimum barrier
2. **`config.php` denied** via nginx `location` block (contains DB password)
3. **PDO prepared statements** everywhere — no SQL injection
4. **Read-only DB user** for display pages (optional: create a
`crelay_readonly` role with SELECT-only on caching tables; use the
full `crelay` user only for the config editor)
5. **HTTPS only** — the existing nginx SSL config covers this
6. **No CORS needed** — same origin as the relay
## Out of Scope (for MVP)
- Nostr NIP-07 extension login (use HTTP Basic Auth first)
- Write operations beyond config editing (reset backfill, etc. — those
stay on the Nostr admin API)
- Real-time WebSocket updates (use polling instead — 15s interval is
sufficient for a monitoring dashboard)
- Multi-user / role-based access
## Files to Create
| File | Purpose |
|------|---------|
| `admin/config.php` | DB connection config (protected) |
| `admin/lib/db.php` | PDO connection helper |
| `admin/lib/helpers.php` | Shared helpers (pagination, formatting) |
| `admin/index.php` | Dashboard |
| `admin/follows.php` | Followed pubkeys table (paginated) |
| `admin/relays.php` | Per-relay backfill progress |
| `admin/config-edit.php` | Caching config editor |
| `admin/inbox.php` | Inbox queue monitor |
| `admin/api/status.php` | JSON: dashboard stats |
| `admin/api/follows.php` | JSON: paginated follows |
| `admin/api/relays.php` | JSON: relay progress |
| `admin/assets/style.css` | Shared CSS |
| `admin/assets/app.js` | Shared JS |
| `admin/.htpasswd` | HTTP Basic Auth passwords |
| `examples/deployment/nginx-proxy/nginx.conf` | Add `/admin/` location block |
## Deployment
1. Copy `admin/` to `/opt/c-relay-pg/admin/` on the server
2. Install `php8.2-fpm php8.2-pgsql` (or distro-equivalent)
3. Create `.htpasswd`: `htpasswd -c /opt/c-relay-pg/admin/.htpasswd admin`
4. Edit `admin/config.php` with the PostgreSQL connection string
5. Add the nginx `/admin/` location block, reload nginx
6. Visit `https://relay.yourdomain.com/admin/`
No relay restart required. The PHP admin page runs entirely in parallel
with the existing relay and Nostr admin API.
+297
View File
@@ -0,0 +1,297 @@
# Relay Admin Page Migration Plan
## Goal
Migrate the relay admin functionality from the internally-served `api/index.html` + `api/index.js` (served by the C relay binary) to an externally-hosted `relay-admin.html` page in the `client-ndk` project. This page uses the NDK SharedWorker architecture (template.html pattern) and communicates with the relay via kind 23456/23457/24567 Nostr events.
## Current State
### Working Reference: [`c-relay-pg.html`](/home/user/lt/client-ndk/www/c-relay-pg.html)
- Successfully receives kind 24567 monitoring events
- Has `SUBSCRIPTION_ONLY_MODE = true` — admin UI actions are disabled
- Contains all admin section HTML (Statistics, Subscriptions, Configuration, Authorization, IP Bans, Relay Events, DM, Database Query)
- Has sidenav with admin page links
- Has `sendAdminCommand()`, `encryptForRelay()`, `decryptFromRelay()`, `fetchRelayInfo()` functions
- Has `handleMonitoringEvent()` and `handleAdminResponse()` event handlers
### Target File: [`relay-admin.html`](/home/user/lt/client-ndk/www/relay-admin.html)
- Currently an exact copy of `template.html`
- Has full template infrastructure: hamburger menu, sidenav, footer, auth system, relay-ui, blossom-ui, ai-ui
- Has the newer `injectHeaderLoginButton` import (slightly newer than c-relay-pg.html)
- Empty `divBody` — ready for admin content
### Legacy Internal Pages: [`api/index.html`](api/index.html) + [`api/index.js`](api/index.js)
- 6,779 lines of JavaScript — the full admin UI
- Uses `nostr-tools SimplePool` directly (not NDK)
- Uses `NOSTR_LOGIN_LITE` for auth (not NDK SharedWorker)
- Has rich UI: config tables with inline editing, IP ban management with filters, SQL query console with history, WoT management, relay event forms
- All admin commands go through `sendAdminCommand()` → kind 23456 with NIP-44 encryption
- Responses come back as kind 23457 events, decrypted and routed to handlers
## Architecture Comparison
```mermaid
graph TD
subgraph Legacy - api/index.html
A1[NOSTR_LOGIN_LITE auth] --> A2[SimplePool direct WS]
A2 --> A3[Kind 23456 admin commands]
A2 --> A4[Kind 24567 monitoring sub]
A3 --> A5[Kind 23457 responses]
end
subgraph New - relay-admin.html
B1[NDK SharedWorker auth] --> B2[NDK subscribe/publishEvent]
B2 --> B3[Kind 23456 admin commands]
B2 --> B4[Kind 24567 monitoring sub]
B3 --> B5[Kind 23457 responses]
end
```
### Key Difference: NDK vs SimplePool
| Aspect | Legacy api/index.js | New relay-admin.html |
|--------|-------------------|---------------------|
| Auth | `NOSTR_LOGIN_LITE` direct | `initNDKPage()` via SharedWorker |
| Subscriptions | `SimplePool.subscribeMany()` | `subscribe()` from init-ndk.mjs |
| Publishing | `SimplePool.publish()` + manual signing | `publishEvent()` — auto-signed by worker |
| Encryption | `window.nostr.nip44.encrypt/decrypt` | Same — `window.nostr.nip44.encrypt/decrypt` |
| Event listening | SimplePool callbacks | `window.addEventListener('ndkEvent', ...)` |
| Relay URL | Auto-derived from page URL | Configurable constant `ADMIN_RELAY_WS_URL` |
## Migration Strategy
The approach is to merge the admin functionality from `c-relay-pg.html` into `relay-admin.html`, removing the `SUBSCRIPTION_ONLY_MODE` restriction and enabling full admin UI. We use the NDK `publishEvent()` and `subscribe()` APIs instead of SimplePool.
### What stays from template (relay-admin.html already has)
- Full hamburger menu + sidenav infrastructure
- Footer with relay status animations
- Auth system with required/optional/none modes
- Relay-ui, blossom-ui, ai-ui sidenav sections
- Theme toggle, logout, version display
### What gets added from c-relay-pg.html
- Admin nav items in sidenav
- Admin section HTML panels in divBody
- Admin-specific CSS styles
- Relay pubkey fetch via NIP-11
- NIP-44 encrypt/decrypt helpers
- `sendAdminCommand()` for kind 23456
- Kind 24567 monitoring subscription
- Kind 23457 response handling with per-section routing
- All admin UI button handlers
- Boot diagnostics panel
- System info panel
## Detailed Implementation Steps
### Step 1: Add Admin Nav Links to Sidenav
The admin links go directly inside `divSideNavBody` as simple styled items — **not** inside a collapsible `.sidenavSection` container. They should be always-visible, non-collapsible links that match the font/styling of the sidenav sections but remain in the open body area. Place them before `divFiles`:
```html
<div id="divSideNavBody">
<!-- Admin nav links - always visible, not collapsible -->
<div id="divAdminNav">
<div class="adminNavItem active" data-section="statistics">Statistics</div>
<div class="adminNavItem" data-section="subscriptions">Subscriptions</div>
<div class="adminNavItem" data-section="configuration">Configuration</div>
<div class="adminNavItem" data-section="authorization">Authorization</div>
<div class="adminNavItem" data-section="ip-bans">IP Bans</div>
<div class="adminNavItem" data-section="relay-events">Relay Events</div>
<div class="adminNavItem" data-section="dm">DM</div>
<div class="adminNavItem" data-section="database">Database Query</div>
</div>
<div id="divFiles"></div>
</div>
```
The `.adminNavItem` styling uses the same font-family and sizing as sidenav section content (`font-size: 70%`, `font-family: var(--font-family)`) with a simple border-bottom separator, hover accent color, and bold+background for the active state. No section title header, no collapse toggle icon.
### Step 2: Add Admin Section HTML Panels
Port the section HTML from `c-relay-pg.html` into `divBody`. Each section follows the pattern:
```html
<div id="section-{name}" class="adminSection">
<div class="adminSectionHeader">{Title}</div>
<!-- Section-specific content -->
</div>
```
Sections to port:
1. **Statistics** — CPU metrics, time stats, top pubkeys, event kinds cards
2. **Subscriptions** — Subscription details log
3. **Configuration** — Config key/value inputs, fetch/set buttons, log
4. **Authorization** — Pubkey input, whitelist/blacklist/query buttons, log
5. **IP Bans** — IP input, duration, ban/query buttons, log
6. **Relay Events** — Kind 0 metadata form, live event feed
7. **DM** — Message textarea, send button, log
8. **Database Query** — SQL textarea, run button, log
9. **System** (always visible) — Relay WS/HTTP URLs, relay/user pubkeys
10. **Boot Diagnostics** (always visible) — Boot log
### Step 3: Add Admin CSS
Port the admin-specific styles from `c-relay-pg.html` `<style>` block. These use CSS variables from `client.css` so they integrate with the theme system:
- `#divBody` flex layout overrides
- `.adminNavItem` styles with hover/active states
- `.adminSection` show/hide with `.active` class
- `.adminGrid`, `.adminCard`, `.adminTable` layout
- `.adminInput`, `.adminTextArea`, `.adminBtn` form elements
- `.adminLog` scrollable log panels
### Step 4: Add Admin State Variables
```javascript
const ADMIN_RELAY_WS_URL = 'wss://relay.laantungir.net';
const ADMIN_RELAY_HTTP_URL = 'https://relay.laantungir.net';
let relayPubkey = '';
let adminInitialized = false;
let adminUiBound = false;
const adminSubscriptions = [];
const MAX_LOG_LINES = 200;
```
### Step 5: Add Core Admin Functions
Port from `c-relay-pg.html`:
1. **`appendLog(id, msg)`** — Prepend timestamped messages to log panels
2. **`setSystemInfo()`** — Populate system info table
3. **`fetchRelayInfo()`** — NIP-11 fetch to get relay pubkey
4. **`encryptForRelay(content)`** — NIP-44 encrypt using `window.nostr.nip44.encrypt`
5. **`decryptFromRelay(content)`** — NIP-44 decrypt using `window.nostr.nip44.decrypt`
6. **`sendAdminCommand(commandArray, sink)`** — Encrypt + publish kind 23456 event via `publishEvent()`
### Step 6: Add Subscription Setup
```javascript
async function setupAdminSubscriptions() {
// Subscribe to kind 24567 monitoring events
const monitoringSub = subscribe(
{ kinds: [24567], since: Math.floor(Date.now() / 1000) - 60 },
{ closeOnEose: false, cacheUsage: 'ONLY_RELAY' }
);
// Subscribe to kind 23457 admin responses
const responseSub = subscribe(
{ kinds: [23457], '#p': [currentPubkey], since: Math.floor(Date.now() / 1000) - 60 },
{ closeOnEose: false, cacheUsage: 'ONLY_RELAY' }
);
// Listen for events via ndkEvent window event
window.addEventListener('ndkEvent', async (evt) => {
const event = evt.detail;
if (event.kind === 24567) handleMonitoringEvent(event);
else if (event.kind === 23457) await handleAdminResponse(event);
else handleLiveEvent(event);
});
}
```
### Step 7: Add Event Handlers
Port from `c-relay-pg.html`:
1. **`handleMonitoringEvent(event)`** — Route d-tag values to stat cards
2. **`handleAdminResponse(event)`** — Decrypt kind 23457 and route to logs
3. **`handleLiveEvent(event)`** — Sample non-admin events to live feed
4. **`activateSection(sectionKey)`** — Toggle active section visibility
5. **`bindAdminUi()`** — Wire up all button click handlers
### Step 8: Add Button Handlers
All handlers follow the same pattern: gather input → `sendAdminCommand(array, logId)`:
- **btnRefreshStats**`['system_command', 'system_status']`
- **btnConfigRefresh**`['config_query', 'all']`
- **btnConfigSet**`[key, value]`
- **btnWhitelist**`['whitelist', 'pubkey', pk]`
- **btnBlacklist**`['blacklist', 'pubkey', pk]`
- **btnAuthQuery**`['auth_query', 'all']`
- **btnBanIp**`['ip_ban', 'add', ip, duration]`
- **btnIpQuery**`['ip_ban', 'query', 'all']`
- **btnPublishKind0** → Direct `publishEvent()` with kind 0
- **btnSendDm**`['dm', message]`
- **btnRunSql**`['sql_query', query]`
### Step 9: Integration into main() Flow
```javascript
(async function main() {
// 1. Template init (hamburger, theme, auth)
initHamburgerMenu();
// ... existing template setup ...
// 2. Auth
await initializeAuthentication(authMode);
await initializeAuthenticatedPageFeatures();
// 3. Admin init (NEW)
await initializeAdminFeatures();
// 4. Template finalization
await updateVersionDisplay();
})();
```
Where `initializeAdminFeatures()`:
```javascript
async function initializeAdminFeatures() {
if (!isAuthenticated || adminInitialized) return;
bindAdminUi();
await fetchRelayInfo();
await setupAdminSubscriptions();
setSystemInfo();
adminInitialized = true;
}
```
## Response Routing Enhancement
The current `c-relay-pg.html` broadcasts all responses to all logs. For a better UX, we should parse the decrypted response and route to the appropriate log:
```javascript
async function handleAdminResponse(event) {
const plain = await decryptFromRelay(event.content);
const parsed = JSON.parse(plain);
// Route based on query_type
if (parsed.query_type === 'config_all' || parsed.query_type === 'config_update') {
appendLog('configLog', plain);
} else if (parsed.query_type === 'auth_query' || parsed.query_type === 'auth_rule') {
appendLog('authLog', plain);
} else if (parsed.query_type === 'sql_query') {
appendLog('sqlLog', plain);
} else if (parsed.query_type === 'ip_ban') {
appendLog('ipBanLog', plain);
} else {
// Fallback: broadcast to all
appendLog('configLog', `RESP <- ${plain}`);
}
}
```
## Files Modified
| File | Location | Change |
|------|----------|--------|
| `relay-admin.html` | `~/lt/client-ndk/www/` | Add admin HTML, CSS, and JavaScript |
## Files NOT Modified
- `template.html` — Stays as the clean template
- `c-relay-pg.html` — Stays as the debug/test reference
- `api/index.html` / `api/index.js` — Legacy, stays for now (still served by relay binary)
- No C code changes needed — the relay already handles kind 23456/23457/24567
## Future Enhancements (Out of Scope)
- Rich config table with inline editing (like api/index.html has)
- IP ban table with filter tabs
- SQL query history dropdown
- WoT management section
- Event rate chart visualization
- Admin verification handshake
+215
View File
@@ -0,0 +1,215 @@
# Thread Pool PostgreSQL Optimization Plan
## Current State
### Thread Inventory
| Thread | Name | Role | PG Connection | Config Key |
|--------|------|------|--------------|------------|
| lws-main | `lws-main` | WebSocket event loop, HTTP, NIP-11, admin events, subscription matching, broadcast, monitoring event generation | `g_pg_conn` (main) | N/A — always runs |
| reader-1 | `db-read-1` | Async REQ and COUNT query execution | `g_thread_pg_conn` (TLS) | `thread_pool_readers` (default: 1) |
| writer | `db-write` | Async EVENT store, delete, sub logging, IP ban save, WAL checkpoint | `g_thread_pg_conn` (TLS) | N/A — always 1 |
| event-worker | `event-worker` | Async EVENT ingestion — duplicate check, signature validation, `store_event_core()`, ephemeral event handling | Own `worker_db` via `db_open_worker_connection()` | N/A — always 1 |
**Total: 4 threads, 4 PG connections**
### event-worker Thread (websockets.c — separate from thread pool)
The `event-worker` is a dedicated async pipeline for incoming EVENT messages, defined in [`src/websockets.c`](src/websockets.c:543). It is **not** part of the thread pool — it has its own job queue, mutex, and condvar.
**What it does for each incoming EVENT:**
1. Checks if event ID already exists in DB (`event_id_exists_in_db()`)
2. Validates the event with `nostr_validate_unified_request()` (signature check)
3. For ephemeral events (kind 20000-29999): marks for broadcast only, no storage
4. For regular events: calls `store_event_core()` to insert into PostgreSQL
5. Pushes a completion struct and wakes `lws-main` via `lws_cancel_service()`
6. `lws-main` picks up the completion, sends OK response, and broadcasts to subscribers
**Why it exists separately from the thread pool writer:**
- The thread pool writer handles generic DB operations (sub logging, IP bans, etc.)
- The event-worker handles the full EVENT ingestion pipeline including validation and duplicate detection — not just the DB insert
- It was added to move CPU-intensive signature verification off the main thread
**Relationship to thread pool writer:**
There is overlap — both can write to the events table. The event-worker calls `store_event_core()` directly, while the thread pool writer uses `db_insert_event_with_json()`. Some EVENT paths still go through the thread pool writer (sync path for admin/DM/protected events).
### What Runs on lws-main Today (Shouldn't Block)
- WebSocket accept/read/write for all Nostr clients
- JSON parsing of REQ/EVENT/CLOSE/COUNT messages
- Event signature verification (CPU-bound)
- Subscription matching and broadcast (iterates all subscriptions)
- NIP-11 HTTP responses
- NIP-42 auth challenge generation
- Admin event processing (kind 23456) — includes NIP-44 decrypt + config changes
- **Monitoring event generation** — queries DB, signs events, broadcasts (BLOCKS)
- **Sync config reads**`get_config_value()` hits PG on main thread (BLOCKS)
### SQLite-Era Constraints (No Longer Apply)
- Single writer thread was required (SQLite serializes writes)
- WAL checkpoint job was required (SQLite-specific)
- Reader/writer separation was critical for avoiding SQLITE_BUSY
- `db_wal_checkpoint_truncate()` on writer shutdown — SQLite-only
---
## Changes for PostgreSQL
### Phase 1: Increase Reader Thread Count
**What:** Change default reader count from 1 to 4.
**Why:** With SQLite, multiple readers had diminishing returns due to file-level locking. PostgreSQL handles concurrent reads perfectly — each reader has its own connection and queries execute in parallel.
**Files:**
- `src/main.c` line ~2524: Change default from 1 to 4
- Config key `thread_pool_readers` already exists — just change the default
**Risk:** Low. Each reader opens its own PG connection via `db_open_worker_connection()`. PostgreSQL default `max_connections = 100` easily handles 4-8 readers.
### Phase 2: Allow Multiple Writer Threads
**What:** Make writer thread count configurable (default: 2). Convert the single `pthread_t writer` to an array like readers.
**Why:** PostgreSQL supports concurrent writes with row-level locking. The single-writer bottleneck was a SQLite constraint. With multiple writers, EVENT inserts from different clients can execute in parallel.
**Files:**
- `src/thread_pool.h`: Add `int writer_threads` to `thread_pool_config_t`
- `src/thread_pool.c`:
- Change `pthread_t writer` to `pthread_t* writers` + `int writer_count`
- `thread_pool_init()`: create N writer threads
- `thread_pool_shutdown()`: join all writer threads
- `writer_worker_main()`: set thread name to `db-write-N`
- `src/main.c`: Add config key `thread_pool_writers` (default: 2)
**Risk:** Medium. Need to verify that no write job assumes serialized execution. Current write jobs:
- `STORE_EVENT` — PG handles concurrent inserts with ON CONFLICT
- `LOG_SUB_CREATED/CLOSED/DISCONNECTED` — independent rows, safe
- `UPDATE_SUB_EVENTS_SENT` — updates by sub_id, safe
- `IP_BAN_SAVE` — writes to ip_bans table, safe
- `WAL_CHECKPOINT` — SQLite-only, skip for PG (see Phase 3)
All write jobs operate on independent rows or use conflict resolution. Multiple writers are safe.
### Phase 3: Remove SQLite-Specific WAL Checkpoint Logic
**What:** Make WAL checkpoint a no-op when running PostgreSQL backend.
**Why:** PostgreSQL manages its own WAL internally. The `db_wal_checkpoint_passive()` and `db_wal_checkpoint_truncate()` calls are SQLite-specific.
**Files:**
- `src/thread_pool.c`:
- `execute_wal_checkpoint_job()`: Return OK immediately if PG backend
- `writer_worker_main()` shutdown path: Skip `db_wal_checkpoint_truncate()` if PG backend
- `src/db_ops.c` or `src/db_ops_postgres.c`: Ensure WAL checkpoint functions are no-ops for PG
**Risk:** Low. These are already effectively no-ops since the PG backend doesn't implement them, but making it explicit avoids log noise.
### Phase 4: Move Monitoring Event Generation Off Main Thread
**What:** Run monitoring queries and event generation on a reader thread instead of lws-main.
**Why:** The monitoring system (`generate_monitoring_event_for_type()` in `src/api.c`) currently:
1. Queries PostgreSQL for stats (blocks main thread 5-50ms)
2. Creates and signs a Nostr event (CPU work on main thread)
3. Broadcasts to subscriptions (main thread)
Step 1 and 2 should happen on a worker thread. Only step 3 (broadcast) needs to happen on lws-main.
**Approach:** Add a new job type `THREAD_POOL_JOB_MONITORING_QUERY` to the read queue. The reader thread executes the query and prepares the event JSON. The completion callback on lws-main just broadcasts the pre-built event.
**Files:**
- `src/thread_pool.h`: Add `THREAD_POOL_JOB_MONITORING_QUERY` job type and payload struct
- `src/thread_pool.c`: Add `execute_monitoring_query_job()` handler
- `src/api.c`: Refactor `generate_monitoring_event_for_type()` to submit async job
- `src/websockets.c`: Handle monitoring completion in the event loop callback
**Risk:** Medium. The monitoring event signing requires the relay private key. Need to pass it to the worker thread or pre-compute the unsigned event and sign on completion.
### Phase 5: Add Configurable Thread Pool Sizing via Config Events
**What:** Allow thread pool reader/writer counts to be set via the admin config event system.
**Config keys:**
- `thread_pool_readers` — number of reader threads (default: 4)
- `thread_pool_writers` — number of writer threads (default: 2)
- `thread_pool_max_queue_depth` — max pending jobs per queue (default: 4096)
**Note:** Thread count changes require relay restart (cannot dynamically resize thread pool).
**Files:**
- `src/default_config_event.h`: Add defaults for new config keys
- `src/main.c`: Read config values when initializing thread pool
**Risk:** Low. Config keys already work this way for `thread_pool_readers`.
### Phase 6: Fix Error Messages (Cosmetic)
**What:** Update SQLite-specific error messages in thread pool to be backend-neutral.
**Files:**
- `src/thread_pool.c` line 508: "Reader worker failed to open SQLite connection" → "Reader worker failed to open database connection"
- `src/thread_pool.c` line 530: "Writer worker failed to open SQLite connection" → "Writer worker failed to open database connection"
**Risk:** None.
---
## Target Thread Layout After Implementation
```
c_relay_pg process
├── lws-main — WebSocket event loop (never blocks on DB)
├── db-read-1 — Async REQ/COUNT/monitoring queries
├── db-read-2 — Async REQ/COUNT/monitoring queries
├── db-read-3 — Async REQ/COUNT/monitoring queries
├── db-read-4 — Async REQ/COUNT/monitoring queries
├── event-worker — Async EVENT ingestion (validate + store)
├── db-write-1 — Async sub logging, IP bans, misc writes
└── db-write-2 — Async sub logging, IP bans, misc writes
```
**Total: 8 threads, 8 PG connections** (configurable)
---
## PostgreSQL Connection Budget
| Component | Connections | Notes |
|-----------|------------|-------|
| lws-main | 1 | Sync config reads, admin event processing |
| Reader threads (4) | 4 | One per reader |
| event-worker | 1 | EVENT ingestion pipeline |
| Writer threads (2) | 2 | Sub logging, IP bans, misc |
| **Total per instance** | **8** | Well within PG default max_connections=100 |
| Future: admin-ws | +1 | When/if added later |
| Future: pg-listener | +1 | When/if added later |
| **Future total** | **10** | Still very comfortable |
For load balancing with N relay instances: N × 8 connections. With 10 instances = 80 connections. PgBouncer recommended at that scale.
---
## Implementation Order
| Phase | Description | Complexity | Impact |
|-------|-------------|-----------|--------|
| 1 | Increase reader count to 4 | Trivial — one line change | High — parallel query execution |
| 2 | Multiple writer threads | Medium — refactor writer array | Medium — parallel event inserts |
| 3 | Remove WAL checkpoint logic | Low — conditional no-op | Low — cleaner code |
| 4 | Move monitoring off main thread | Medium — new job type + async flow | High — unblocks event loop |
| 5 | Config event integration | Low — add config keys | Low — operational convenience |
| 6 | Fix error messages | Trivial | None — cosmetic |
**Recommended start:** Phase 1 + 6 (trivial, immediate benefit), then Phase 3 (cleanup), then Phase 4 (biggest architectural improvement), then Phase 2 (nice-to-have), then Phase 5 (polish).
---
## Future: Load Balancing Considerations
The thread pool design is already compatible with horizontal scaling because:
1. **No shared in-process state between instances** — all state is in PostgreSQL
2. **Subscription matching is per-instance** — each instance tracks its own connected clients
3. **Cross-instance event notification** via PostgreSQL LISTEN/NOTIFY (future phase)
4. **Connection pooling** via PgBouncer when connection count becomes a concern
The only addition needed for multi-instance is a LISTEN/NOTIFY subscriber thread that receives "new event stored" notifications from PostgreSQL and triggers subscription matching on the local instance. This is the `pg-listener` thread discussed earlier — it can be added independently of the thread pool changes in this plan.
-10
View File
@@ -1,10 +0,0 @@
https://github.com/rushmi0/Fenrir-s
https://github.com/barkyq/gnost-relay
https://github.com/lpicanco/knostr
https://github.com/bezysoftware/netstr
https://github.com/lebrunel/nex
https://github.com/CodyTseng/nostr-relay-nestjs
https://github.com/mattn/nostr-relay
https://github.com/Cameri/nostream
https://github.com/Giszmo/NostrPostr/tree/master/NostrRelay
https://github.com/fiatjaf/relayer/tree/master/examples/basic
+1 -1
View File
@@ -1 +1 @@
1048978
3125854
+493 -100
View File
@@ -16,7 +16,9 @@ extern void log_query_execution(const char* query_type, const char* sub_id,
int get_active_connection_count(void);
#include <time.h>
#include <sys/stat.h>
#include <sys/select.h>
#include <unistd.h>
#include <fcntl.h>
#include <strings.h>
#include <stdbool.h>
#include "api.h"
@@ -31,6 +33,7 @@ int get_active_connection_count(void);
// External subscription manager (from main.c via subscriptions.c)
extern subscription_manager_t g_subscription_manager;
extern struct lws_context *ws_context;
// Global variables for config change system
static pending_config_change_t* pending_changes_head = NULL;
@@ -40,7 +43,6 @@ static int pending_changes_count = 0;
// Forward declarations for database functions
int store_event(cJSON* event);
int broadcast_event_to_subscriptions(cJSON* event);
// Forward declarations for config functions
char* get_relay_private_key(void);
const char* get_config_value(const char* key);
@@ -68,6 +70,309 @@ int get_monitoring_throttle_seconds(void) {
return get_config_int("kind_24567_reporting_throttle_sec", 5);
}
// Forward declaration for the round-robin monitoring generator (defined below).
static int generate_round_robin_monitoring_event(void);
// Forward declaration for has_any_subscription_for_kind (subscriptions.c).
// Returns 1 if any active subscription would receive the given event kind
// (explicit kind filter match OR no kind filter at all).
int has_any_subscription_for_kind(int event_kind);
// Forward declaration for the periodic kind-1 status post generator.
int generate_and_post_status_event(void);
// =====================================================================
// api-worker thread (Phase 1 + 2 + 5 of api_upgrade_plan.md)
//
// The worker owns ALL monitoring event generation on a dedicated thread
// with its own PG connection. It is NOT triggered by event storage or
// subscription changes (Phase 2 removed those hooks). Instead it runs on
// a timer (throttle_sec) and, on PostgreSQL, wakes reactively via
// LISTEN/NOTIFY on the 'event_stored' channel (Phase 5).
//
// Loop:
// - If no one is subscribed to kind 24567: condvar-timedwait for
// throttle_sec (zero DB work, zero notify polling). Wakeable for
// STATUS_POST jobs and shutdown via the self-pipe.
// - If subscribers exist: select() on {PG socket, self-pipe} with
// timeout = throttle_sec. On NOTIFY or timeout, run one round-robin
// monitoring tick. On self-pipe wake, process STATUS_POST.
//
// A self-pipe is used so api_worker_enqueue_status_post() and shutdown
// can wake the select() / condvar wait from another thread safely.
// =====================================================================
typedef enum {
API_WORK_JOB_STATUS_POST = 1
} api_work_job_type_t;
typedef struct api_work_completion {
api_work_job_type_t type;
int result;
struct api_work_completion* next;
} api_work_completion_t;
static pthread_mutex_t g_api_work_mutex = PTHREAD_MUTEX_INITIALIZER;
static pthread_cond_t g_api_work_cond = PTHREAD_COND_INITIALIZER;
// Pending STATUS_POST request flag (protected by g_api_work_mutex).
static int g_api_status_post_pending = 0;
static pthread_mutex_t g_api_completion_mutex = PTHREAD_MUTEX_INITIALIZER;
static api_work_completion_t* g_api_completion_head = NULL;
static api_work_completion_t* g_api_completion_tail = NULL;
static pthread_t g_api_worker_thread;
static volatile int g_api_worker_running = 0;
// Self-pipe fds for waking the worker's select()/condvar. [0]=read, [1]=write.
static int g_api_wake_pipe[2] = { -1, -1 };
static void api_worker_wake(void) {
if (g_api_wake_pipe[1] >= 0) {
char byte = 1;
ssize_t rc = write(g_api_wake_pipe[1], &byte, 1);
(void)rc;
}
}
static void api_worker_drain_wake_pipe(void) {
if (g_api_wake_pipe[0] < 0) return;
char buf[64];
while (read(g_api_wake_pipe[0], buf, sizeof(buf)) > 0) {
// drain
}
}
static void api_worker_push_completion(api_work_completion_t* completion) {
if (!completion) return;
pthread_mutex_lock(&g_api_completion_mutex);
completion->next = NULL;
if (!g_api_completion_tail) {
g_api_completion_head = completion;
g_api_completion_tail = completion;
} else {
g_api_completion_tail->next = completion;
g_api_completion_tail = completion;
}
pthread_mutex_unlock(&g_api_completion_mutex);
}
static api_work_completion_t* api_worker_pop_completion(void) {
pthread_mutex_lock(&g_api_completion_mutex);
api_work_completion_t* completion = g_api_completion_head;
if (completion) {
g_api_completion_head = completion->next;
if (!g_api_completion_head) {
g_api_completion_tail = NULL;
}
}
pthread_mutex_unlock(&g_api_completion_mutex);
return completion;
}
// Dequeue a pending STATUS_POST request (returns 1 if one was pending).
static int api_worker_take_status_post(void) {
pthread_mutex_lock(&g_api_work_mutex);
int pending = g_api_status_post_pending;
g_api_status_post_pending = 0;
pthread_mutex_unlock(&g_api_work_mutex);
return pending;
}
int api_worker_enqueue_status_post(void) {
if (!g_api_worker_running) {
return -1;
}
pthread_mutex_lock(&g_api_work_mutex);
g_api_status_post_pending = 1;
pthread_cond_broadcast(&g_api_work_cond);
pthread_mutex_unlock(&g_api_work_mutex);
api_worker_wake();
return 0;
}
static void* api_worker_main(void* arg) {
(void)arg;
pthread_setname_np(pthread_self(), "api-worker");
// Open a dedicated DB worker connection for the full api-worker lifetime.
void* worker_db = NULL;
if (api_open_temp_db_connection(&worker_db) != 0) {
DEBUG_WARN("api-worker: failed to open dedicated DB connection, using fallback behavior");
}
// CRITICAL: bind the worker connection to THIS thread. The __thread
// pointer g_thread_pg_conn is set by db_open_worker_connection() in the
// caller's thread (lws-main), not here. Without this rebind, monitoring
// query functions fall back to the shared g_pg_conn and race with the
// main thread — which is why the dashboard stopped receiving updates.
if (worker_db) {
db_set_thread_connection(worker_db);
}
// Phase 5: register LISTEN event_stored on the worker connection so PG
// can push notifications when new events are inserted. Returns -1 on
// SQLite / unsupported backends; the worker then falls back to pure
// timer-based polling.
int listen_active = 0;
if (worker_db) {
if (db_worker_listen(worker_db, "event_stored") == 0) {
listen_active = 1;
DEBUG_LOG("api-worker: LISTEN event_stored registered");
} else {
DEBUG_LOG("api-worker: LISTEN not supported on this backend; using timer-only mode");
}
}
while (g_api_worker_running) {
int throttle_sec = get_monitoring_throttle_seconds();
if (throttle_sec <= 0) {
// Monitoring disabled. Sleep briefly and re-check config.
struct timespec ts;
ts.tv_sec = 1;
ts.tv_nsec = 0;
nanosleep(&ts, NULL);
// Still honor a STATUS_POST request if one arrived.
if (api_worker_take_status_post()) {
int rc = generate_and_post_status_event();
api_work_completion_t* c = calloc(1, sizeof(*c));
if (c) { c->type = API_WORK_JOB_STATUS_POST; c->result = rc; api_worker_push_completion(c); }
}
continue;
}
// Is anyone listening to kind 24567 monitoring events?
int has_subs = has_any_subscription_for_kind(24567);
if (!has_subs) {
// No subscribers: sleep on the condvar for throttle_sec. Zero DB
// work, zero notify polling. Wakeable for STATUS_POST / shutdown.
pthread_mutex_lock(&g_api_work_mutex);
if (g_api_worker_running && !g_api_status_post_pending) {
struct timespec deadline;
clock_gettime(CLOCK_REALTIME, &deadline);
deadline.tv_sec += throttle_sec;
pthread_cond_timedwait(&g_api_work_cond, &g_api_work_mutex, &deadline);
}
int do_status = g_api_status_post_pending;
g_api_status_post_pending = 0;
pthread_mutex_unlock(&g_api_work_mutex);
api_worker_drain_wake_pipe();
if (do_status) {
int rc = generate_and_post_status_event();
api_work_completion_t* c = calloc(1, sizeof(*c));
if (c) { c->type = API_WORK_JOB_STATUS_POST; c->result = rc; api_worker_push_completion(c); }
}
continue;
}
// Subscribers exist. Wait for either a NOTIFY, the throttle timeout,
// or a self-pipe wake (STATUS_POST / shutdown) — whichever comes first.
if (listen_active && worker_db) {
int prc = db_worker_poll_notify(worker_db, throttle_sec * 1000);
// prc: 1 = notification, 0 = timeout, -1 = error
(void)prc;
} else {
// No LISTEN support: sleep for throttle_sec on the condvar.
pthread_mutex_lock(&g_api_work_mutex);
if (g_api_worker_running && !g_api_status_post_pending) {
struct timespec deadline;
clock_gettime(CLOCK_REALTIME, &deadline);
deadline.tv_sec += throttle_sec;
pthread_cond_timedwait(&g_api_work_cond, &g_api_work_mutex, &deadline);
}
pthread_mutex_unlock(&g_api_work_mutex);
}
if (!g_api_worker_running) {
break;
}
api_worker_drain_wake_pipe();
// Run one round-robin monitoring tick (builds + signs + broadcasts
// one kind 24567 event with the next d-tag in the schedule).
(void)generate_round_robin_monitoring_event();
// Honor any STATUS_POST request that arrived during the wait.
if (api_worker_take_status_post()) {
int rc = generate_and_post_status_event();
api_work_completion_t* c = calloc(1, sizeof(*c));
if (c) { c->type = API_WORK_JOB_STATUS_POST; c->result = rc; api_worker_push_completion(c); }
}
}
// Cleanup: unbind the thread connection before closing it.
if (worker_db) {
db_clear_thread_connection();
api_close_temp_db_connection(worker_db);
}
return NULL;
}
int start_api_worker(void) {
if (g_api_worker_running) {
return 0;
}
// Create the self-pipe used to wake select()/condvar waits.
if (g_api_wake_pipe[0] < 0) {
if (pipe(g_api_wake_pipe) != 0) {
g_api_wake_pipe[0] = g_api_wake_pipe[1] = -1;
DEBUG_WARN("api-worker: pipe() failed; wakeups will rely on condvar only");
} else {
// Non-blocking both ends so wake() never blocks and drain is safe.
int flags = fcntl(g_api_wake_pipe[1], F_GETFL, 0);
if (flags >= 0) fcntl(g_api_wake_pipe[1], F_SETFL, flags | O_NONBLOCK);
flags = fcntl(g_api_wake_pipe[0], F_GETFL, 0);
if (flags >= 0) fcntl(g_api_wake_pipe[0], F_SETFL, flags | O_NONBLOCK);
}
}
g_api_status_post_pending = 0;
g_api_worker_running = 1;
if (pthread_create(&g_api_worker_thread, NULL, api_worker_main, NULL) != 0) {
g_api_worker_running = 0;
return -1;
}
return 0;
}
void stop_api_worker(void) {
if (!g_api_worker_running) {
return;
}
pthread_mutex_lock(&g_api_work_mutex);
g_api_worker_running = 0;
g_api_status_post_pending = 0;
pthread_cond_broadcast(&g_api_work_cond);
pthread_mutex_unlock(&g_api_work_mutex);
api_worker_wake();
pthread_join(g_api_worker_thread, NULL);
api_work_completion_t* completion = NULL;
while ((completion = api_worker_pop_completion()) != NULL) {
free(completion);
}
if (g_api_wake_pipe[0] >= 0) { close(g_api_wake_pipe[0]); g_api_wake_pipe[0] = -1; }
if (g_api_wake_pipe[1] >= 0) { close(g_api_wake_pipe[1]); g_api_wake_pipe[1] = -1; }
}
void api_worker_process_completions(void) {
api_work_completion_t* completion = NULL;
while ((completion = api_worker_pop_completion()) != NULL) {
if (completion->result != 0) {
DEBUG_TRACE("api-worker job %d completed with rc=%d", (int)completion->type, completion->result);
}
free(completion);
}
}
// Query event kind distribution from database
cJSON* query_event_kind_distribution(void) {
void* temp_db = NULL;
@@ -188,10 +493,34 @@ cJSON* query_top_pubkeys(void) {
cJSON* pubkey_item = cJSON_GetObjectItemCaseSensitive(row, "pubkey");
cJSON* count_item = cJSON_GetObjectItemCaseSensitive(row, "count");
cJSON_AddStringToObject(pubkey_obj, "pubkey",
cJSON_IsString(pubkey_item) ? pubkey_item->valuestring : "");
const char* pk_str = cJSON_IsString(pubkey_item) ? pubkey_item->valuestring : "";
cJSON_AddStringToObject(pubkey_obj, "pubkey", pk_str);
cJSON_AddNumberToObject(pubkey_obj, "event_count",
cJSON_IsNumber(count_item) ? count_item->valuedouble : 0.0);
// Enrich with profile name from kind-0 metadata.
if (pk_str[0] != '\0') {
cJSON* metadata = db_get_profile_metadata(pk_str);
if (metadata) {
cJSON* name = cJSON_GetObjectItemCaseSensitive(metadata, "name");
cJSON* display_name = cJSON_GetObjectItemCaseSensitive(metadata, "display_name");
const char* display = NULL;
if (display_name && cJSON_IsString(display_name) && display_name->valuestring[0])
display = display_name->valuestring;
else if (name && cJSON_IsString(name) && name->valuestring[0])
display = name->valuestring;
cJSON_AddStringToObject(pubkey_obj, "name", display ? display : "");
cJSON* picture = cJSON_GetObjectItemCaseSensitive(metadata, "picture");
if (picture && cJSON_IsString(picture) && picture->valuestring[0])
cJSON_AddStringToObject(pubkey_obj, "picture", picture->valuestring);
cJSON_Delete(metadata);
} else {
cJSON_AddStringToObject(pubkey_obj, "name", "");
}
} else {
cJSON_AddStringToObject(pubkey_obj, "name", "");
}
cJSON_AddItemToArray(pubkeys_array, pubkey_obj);
}
cJSON_Delete(rows);
@@ -362,52 +691,48 @@ cJSON* query_subscription_details(void) {
return subscriptions_data;
}
// Generate event-driven monitoring events (triggered by event storage)
int generate_event_driven_monitoring(void) {
// Generate event_kinds monitoring event
if (generate_monitoring_event_for_type("event_kinds", query_event_kind_distribution) != 0) {
DEBUG_ERROR("Failed to generate event_kinds monitoring event");
return -1;
}
static int generate_round_robin_monitoring_event(void) {
static int rr_index = 0;
// Generate time_stats monitoring event
if (generate_monitoring_event_for_type("time_stats", query_time_based_statistics) != 0) {
DEBUG_ERROR("Failed to generate time_stats monitoring event");
return -1;
}
struct monitor_event_def {
const char* d_tag;
cJSON* (*query_func)(void);
};
// Generate top_pubkeys monitoring event
if (generate_monitoring_event_for_type("top_pubkeys", query_top_pubkeys) != 0) {
DEBUG_ERROR("Failed to generate top_pubkeys monitoring event");
return -1;
}
// Weighted schedule: event_kinds is emitted most frequently since it drives
// the New Events graph and Total Events counter in the admin UI.
// Sequence cadence at throttle=5s: event_kinds(0s), cpu(5s), event_kinds(10s),
// time_stats(15s), event_kinds(20s), top_pubkeys(25s)
// => event_kinds every ~15s, cpu every ~30s, others every ~30s.
static const struct monitor_event_def events[] = {
{"event_kinds", query_event_kind_distribution},
{"cpu_metrics", query_cpu_metrics},
{"event_kinds", query_event_kind_distribution},
{"time_stats", query_time_based_statistics},
{"event_kinds", query_event_kind_distribution},
{"top_pubkeys", query_top_pubkeys}
};
const int event_count = (int)(sizeof(events) / sizeof(events[0]));
const struct monitor_event_def* selected = &events[rr_index];
rr_index = (rr_index + 1) % event_count;
// Generate CPU metrics monitoring event (also triggered by event storage)
if (generate_monitoring_event_for_type("cpu_metrics", query_cpu_metrics) != 0) {
DEBUG_ERROR("Failed to generate cpu_metrics monitoring event");
if (generate_monitoring_event_for_type(selected->d_tag, selected->query_func) != 0) {
DEBUG_ERROR("Failed to generate %s monitoring event", selected->d_tag);
return -1;
}
return 0;
}
// Generate event-driven monitoring events (triggered by event storage)
int generate_event_driven_monitoring(void) {
return generate_round_robin_monitoring_event();
}
// Generate subscription-driven monitoring events (triggered by subscription changes)
int generate_subscription_driven_monitoring(void) {
// Generate subscription_details monitoring event (admin-only)
if (generate_monitoring_event_for_type("subscription_details", query_subscription_details) != 0) {
DEBUG_ERROR("Failed to generate subscription_details monitoring event");
return -1;
}
// Generate CPU metrics monitoring event (also triggered by subscription changes)
if (generate_monitoring_event_for_type("cpu_metrics", query_cpu_metrics) != 0) {
DEBUG_ERROR("Failed to generate cpu_metrics monitoring event");
return -1;
}
return 0;
return generate_round_robin_monitoring_event();
}
// Generate and broadcast monitoring event (legacy function - now calls event-driven version)
@@ -494,6 +819,11 @@ int generate_monitoring_event_for_type(const char* d_tag_value, cJSON* (*query_f
// Broadcast the ephemeral event to active subscriptions (no database storage)
broadcast_event_to_subscriptions(monitoring_event);
// If running on api-worker thread, wake the lws service loop so queued frames flush promptly.
if (ws_context) {
lws_cancel_service(ws_context);
}
cJSON_Delete(monitoring_event);
free(content_json);
@@ -501,60 +831,6 @@ int generate_monitoring_event_for_type(const char* d_tag_value, cJSON* (*query_f
return 0;
}
// Monitoring hook called when an event is stored
void monitoring_on_event_stored(void) {
// Check if monitoring is disabled (throttle = 0)
int throttle_seconds = get_monitoring_throttle_seconds();
if (throttle_seconds == 0) {
return; // Monitoring disabled
}
// Check throttling
static time_t last_monitoring_time = 0;
time_t current_time = time(NULL);
if (current_time - last_monitoring_time < throttle_seconds) {
return;
}
// Check if anyone is subscribed to monitoring events (kind 24567)
// This is the ONLY activation check needed - if someone subscribes, they want monitoring
if (!has_subscriptions_for_kind(24567)) {
return; // No subscribers = no expensive operations
}
// Generate event-driven monitoring events only when someone is listening
last_monitoring_time = current_time;
generate_event_driven_monitoring();
}
// Monitoring hook called when subscriptions change (create/close)
void monitoring_on_subscription_change(void) {
// Check if monitoring is disabled (throttle = 0)
int throttle_seconds = get_monitoring_throttle_seconds();
if (throttle_seconds == 0) {
return; // Monitoring disabled
}
// Check throttling
static time_t last_monitoring_time = 0;
time_t current_time = time(NULL);
if (current_time - last_monitoring_time < throttle_seconds) {
return;
}
// Check if anyone is subscribed to monitoring events (kind 24567)
// This is the ONLY activation check needed - if someone subscribes, they want monitoring
if (!has_subscriptions_for_kind(24567)) {
return; // No subscribers = no expensive operations
}
// Generate subscription-driven monitoring events only when someone is listening
last_monitoring_time = current_time;
generate_subscription_driven_monitoring();
}
/**
* Generate and post a kind 1 status event with relay statistics
* Called periodically from main event loop based on configuration
@@ -696,11 +972,17 @@ static const config_definition_t known_configs[] = {
// Relay keys
{"relay_pubkey", "string", 0, 65},
{"relay_privkey", "string", 0, 65},
{"admin_pubkey", "string", 0, 65},
{"admin_pubkey", "string", 0, ADMIN_PUBKEY_LIST_MAX_LENGTH},
// Kind 1 Status Posts
{"kind_1_status_posts_hours", "int", 0, 8760},
// Thread Pool Settings
{"thread_pool_enabled", "bool", 0, 1},
{"thread_pool_readers", "int", 1, 64},
{"thread_pool_writers", "int", 1, 32},
{"thread_pool_max_queue_depth", "int", 128, 65536},
// Sentinel
{NULL, NULL, 0, 0}
};
@@ -1097,6 +1379,89 @@ int handle_embedded_file_writeable(struct lws* wsi) {
return 0;
}
static void add_relay_thread_snapshot(cJSON* cpu_stats, pid_t relay_pid) {
if (!cpu_stats || relay_pid <= 0) return;
cJSON* threads = cJSON_CreateArray();
if (!threads) return;
char cmd[256];
snprintf(cmd, sizeof(cmd), "ps -T -p %d -o tid=,comm=,pcpu=,pmem=,etime=", relay_pid);
FILE* fp = popen(cmd, "r");
if (!fp) {
cJSON_Delete(threads);
return;
}
char line[512];
int row_count = 0;
while (fgets(line, sizeof(line), fp) && row_count < 32) {
int tid = 0;
char comm[64] = {0};
double pcpu = 0.0, pmem = 0.0;
char elapsed[64] = {0};
if (sscanf(line, "%d %63s %lf %lf %63s", &tid, comm, &pcpu, &pmem, elapsed) == 5) {
cJSON* row = cJSON_CreateObject();
if (row) {
cJSON_AddNumberToObject(row, "tid", tid);
cJSON_AddStringToObject(row, "thread", comm);
cJSON_AddNumberToObject(row, "cpu_percent", pcpu);
cJSON_AddNumberToObject(row, "mem_percent", pmem);
cJSON_AddStringToObject(row, "elapsed", elapsed);
cJSON_AddItemToArray(threads, row);
row_count++;
}
}
}
pclose(fp);
cJSON_AddItemToObject(cpu_stats, "relay_threads", threads);
}
static void add_postgres_backend_snapshot(cJSON* cpu_stats) {
if (!cpu_stats) return;
cJSON* backends = cJSON_CreateArray();
if (!backends) return;
FILE* fp = popen("ps -C postgres -o pid=,comm=,pcpu=,pmem=,etime=,args=", "r");
if (!fp) {
cJSON_Delete(backends);
return;
}
char line[1024];
int row_count = 0;
while (fgets(line, sizeof(line), fp) && row_count < 48) {
int pid = 0;
char comm[64] = {0};
double pcpu = 0.0, pmem = 0.0;
char elapsed[64] = {0};
char args[768] = {0};
if (sscanf(line, "%d %63s %lf %lf %63s %767[^\n]", &pid, comm, &pcpu, &pmem, elapsed, args) >= 5) {
cJSON* row = cJSON_CreateObject();
if (row) {
cJSON_AddNumberToObject(row, "pid", pid);
cJSON_AddStringToObject(row, "command", comm);
cJSON_AddNumberToObject(row, "cpu_percent", pcpu);
cJSON_AddNumberToObject(row, "mem_percent", pmem);
cJSON_AddStringToObject(row, "elapsed", elapsed);
if (args[0] != '\0') {
cJSON_AddStringToObject(row, "args", args);
if (strstr(args, " c-relay-pg ") || strstr(args, " c-relay-pg") || strstr(args, " crelay ")) {
cJSON_AddBoolToObject(row, "is_crelay_connection", 1);
}
}
cJSON_AddItemToArray(backends, row);
row_count++;
}
}
}
pclose(fp);
cJSON_AddItemToObject(cpu_stats, "postgres_backends", backends);
}
// Query CPU usage metrics
cJSON* query_cpu_metrics(void) {
cJSON* cpu_stats = cJSON_CreateObject();
@@ -1139,6 +1504,10 @@ cJSON* query_cpu_metrics(void) {
pid_t pid = getpid();
cJSON_AddNumberToObject(cpu_stats, "process_id", (double)pid);
// Include relay thread/process snapshots (mirrors c-relay-pg-threads.sh style data)
add_relay_thread_snapshot(cpu_stats, pid);
add_postgres_backend_snapshot(cpu_stats);
// Get active WebSocket connection count
cJSON_AddNumberToObject(cpu_stats, "active_connections", (double)get_active_connection_count());
@@ -1194,13 +1563,17 @@ char* generate_stats_json(void) {
cJSON_AddStringToObject(response, "query_type", "stats_query");
cJSON_AddNumberToObject(response, "timestamp", (double)time(NULL));
// Get database file size
const char* db_path = db_get_database_path();
struct stat db_stat;
// Get database storage size (PostgreSQL only)
long long db_size = 0;
if (db_path && db_path[0] != '\0' && stat(db_path, &db_stat) == 0) {
db_size = db_stat.st_size;
#ifdef DB_BACKEND_POSTGRES
db_stmt_t* db_size_stmt = NULL;
if (db_prepare("SELECT pg_database_size(current_database())", &db_size_stmt) == DB_OK) {
if (db_step_stmt(db_size_stmt) == DB_ROW) {
db_size = db_column_int64_value(db_size_stmt, 0);
}
db_finalize_stmt(db_size_stmt);
}
#endif
cJSON_AddNumberToObject(response, "database_size_bytes", db_size);
// Get active subscriptions count from in-memory manager
@@ -1264,9 +1637,29 @@ char* generate_stats_json(void) {
double count_val = cJSON_IsNumber(count_item) ? count_item->valuedouble : 0.0;
double pct = (total_events > 0) ? ((count_val * 100.0) / (double)total_events) : 0.0;
cJSON_AddStringToObject(pubkey_obj, "pubkey", cJSON_IsString(pubkey_item) ? pubkey_item->valuestring : "");
const char* pk_str = cJSON_IsString(pubkey_item) ? pubkey_item->valuestring : "";
cJSON_AddStringToObject(pubkey_obj, "pubkey", pk_str);
cJSON_AddNumberToObject(pubkey_obj, "event_count", count_val);
cJSON_AddNumberToObject(pubkey_obj, "percentage", pct);
// Enrich with profile name from kind-0 metadata.
if (pk_str[0] != '\0') {
cJSON* metadata = db_get_profile_metadata(pk_str);
if (metadata) {
cJSON* name = cJSON_GetObjectItemCaseSensitive(metadata, "name");
cJSON* display_name = cJSON_GetObjectItemCaseSensitive(metadata, "display_name");
const char* display = NULL;
if (display_name && cJSON_IsString(display_name) && display_name->valuestring[0])
display = display_name->valuestring;
else if (name && cJSON_IsString(name) && name->valuestring[0])
display = name->valuestring;
cJSON_AddStringToObject(pubkey_obj, "name", display ? display : "");
cJSON_Delete(metadata);
} else {
cJSON_AddStringToObject(pubkey_obj, "name", "");
}
} else {
cJSON_AddStringToObject(pubkey_obj, "name", "");
}
cJSON_AddItemToArray(top_pubkeys, pubkey_obj);
}
cJSON_Delete(pubkey_rows);
@@ -1556,13 +1949,13 @@ char* generate_stats_text(void) {
// Database Overview section
offset += snprintf(stats_text + offset, 16384 - offset,
"Database Size %.2f MB (%lld bytes)\n"
"Database Size %.2f MB\n"
"Total Events %lld\n"
"Active Subscriptions %d\n"
"Oldest Event %s\n"
"Newest Event %s\n"
"\n",
db_mb, db_bytes, total, active_subs, oldest_str, newest_str);
db_mb, total, active_subs, oldest_str, newest_str);
// Event Kind Distribution section
offset += snprintf(stats_text + offset, 16384 - offset,
+6 -2
View File
@@ -60,10 +60,14 @@ char* execute_sql_query(const char* query, const char* request_id, char* error_m
int handle_sql_query_unified(cJSON* event, const char* query, char* error_message, size_t error_size, struct lws* wsi);
// Monitoring system functions
void monitoring_on_event_stored(void);
void monitoring_on_subscription_change(void);
int get_monitoring_throttle_seconds(void);
// Dedicated API worker thread lifecycle
int start_api_worker(void);
void stop_api_worker(void);
void api_worker_process_completions(void);
int api_worker_enqueue_status_post(void);
// Kind 1 status posts
int generate_and_post_status_event(void);
+253
View File
@@ -0,0 +1,253 @@
/*
* Caching Inbox Poller
*
* Periodically dequeues events from the PostgreSQL `caching_event_inbox` table
* and feeds them through the shared ingest_event() pipeline with
* EVENT_SOURCE_CACHING_INBOX. The poller runs on the main libwebsockets
* service thread (no separate thread) and is compiled only for the PostgreSQL
* backend. For SQLite builds every entry point is a no-op.
*
* The poller maintains a simple two-state machine:
* POLLER_IDLE - slow poll interval (no rows were found last cycle)
* POLLER_ACTIVE - fast poll interval (a full batch was dequeued last cycle)
*/
#include "caching_inbox_poller.h"
#include <stddef.h>
#include <string.h>
#include <time.h>
#include "debug.h"
#include "config.h"
#include "db_ops.h"
#include "main.h"
#ifdef DB_BACKEND_POSTGRES
#include <cjson/cJSON.h>
#endif
// ---- Configuration keys (read each tick) -------------------------------------
#define CFG_KEY_ENABLED "caching_inbox_enabled"
#define CFG_KEY_BATCH_SIZE "caching_inbox_batch_size"
#define CFG_KEY_ACTIVE_POLL_MS "caching_inbox_active_poll_ms"
#define CFG_KEY_IDLE_POLL_MS "caching_inbox_idle_poll_ms"
// ---- Defaults ----------------------------------------------------------------
#define DEFAULT_BATCH_SIZE 150
#define DEFAULT_ACTIVE_POLL_MS 200
#define DEFAULT_IDLE_POLL_MS 5000
// ---- State machine -----------------------------------------------------------
typedef enum {
POLLER_IDLE = 0,
POLLER_ACTIVE = 1
} poller_state_t;
// ---- Module state (PostgreSQL only; trivial for SQLite) ----------------------
#ifdef DB_BACKEND_POSTGRES
static poller_state_t g_state = POLLER_IDLE;
static long long g_last_poll_ms = 0; // monotonic-ish ms timestamp of last poll
static int g_initialized = 0;
// Statistics counters
static int g_total_dequeued = 0;
static int g_total_accepted = 0;
static int g_total_rejected = 0;
static int g_total_duplicates = 0;
static int g_last_batch_size = 0;
#endif
// ---- Helpers -----------------------------------------------------------------
#ifdef DB_BACKEND_POSTGRES
// Return a coarse millisecond timestamp using time(NULL) scaled to ms.
// We avoid clock_gettime(CLOCK_MONOTONIC) portability concerns and keep the
// resolution sufficient for the configured poll intervals (>= 200ms).
static long long now_ms(void) {
return (long long)time(NULL) * 1000LL;
}
#endif
// ---- Public API --------------------------------------------------------------
int caching_inbox_poller_init(void) {
#ifdef DB_BACKEND_POSTGRES
g_state = POLLER_IDLE;
g_last_poll_ms = 0;
g_total_dequeued = 0;
g_total_accepted = 0;
g_total_rejected = 0;
g_total_duplicates = 0;
g_last_batch_size = 0;
g_initialized = 1;
DEBUG_LOG("caching_inbox_poller: initialized (PostgreSQL backend)");
#else
// SQLite build: no-op. The inbox feature is PostgreSQL-only.
#endif
return 0;
}
int caching_inbox_poller_tick(void) {
#ifdef DB_BACKEND_POSTGRES
if (!g_initialized) {
return 0;
}
// Read configuration each tick (kept simple; values are cheap to read).
int enabled = get_config_bool(CFG_KEY_ENABLED, 0);
if (!enabled) {
return 0;
}
int batch_size = get_config_int(CFG_KEY_BATCH_SIZE, DEFAULT_BATCH_SIZE);
if (batch_size <= 0) {
batch_size = DEFAULT_BATCH_SIZE;
}
int active_poll_ms = get_config_int(CFG_KEY_ACTIVE_POLL_MS, DEFAULT_ACTIVE_POLL_MS);
if (active_poll_ms < 0) {
active_poll_ms = DEFAULT_ACTIVE_POLL_MS;
}
int idle_poll_ms = get_config_int(CFG_KEY_IDLE_POLL_MS, DEFAULT_IDLE_POLL_MS);
if (idle_poll_ms < 0) {
idle_poll_ms = DEFAULT_IDLE_POLL_MS;
}
// Throttle: only poll when the configured interval has elapsed.
long long now = now_ms();
long long interval_ms = (g_state == POLLER_ACTIVE) ? active_poll_ms : idle_poll_ms;
if (g_last_poll_ms != 0 && (now - g_last_poll_ms) < interval_ms) {
return 0;
}
g_last_poll_ms = now;
// Dequeue a bounded batch in a single SQL transaction.
int count = 0;
cJSON* rows = db_caching_inbox_dequeue_batch(batch_size, &count);
if (count <= 0 || rows == NULL) {
// Empty queue (or dequeue failure). Back off to idle mode.
if (rows != NULL) {
// Defensive: count==0 but a non-null array was returned.
cJSON_Delete(rows);
}
if (g_state == POLLER_ACTIVE) {
DEBUG_TRACE("caching_inbox_poller: queue empty, switching to IDLE");
}
g_state = POLLER_IDLE;
g_last_batch_size = 0;
// Note: db_caching_inbox_dequeue_batch logs its own errors on failure;
// a NULL return with count==0 is the normal "empty queue" case.
return 0;
}
DEBUG_LOG("caching_inbox_poller: dequeued %d event(s) (batch_size=%d, state=%s)",
count, batch_size, g_state == POLLER_ACTIVE ? "ACTIVE" : "IDLE");
g_last_batch_size = count;
g_total_dequeued += count;
// Feed each dequeued event through the shared ingestion pipeline.
cJSON* row = NULL;
cJSON_ArrayForEach(row, rows) {
cJSON* event_json_obj = cJSON_GetObjectItemCaseSensitive(row, "event_json");
if (!event_json_obj || !cJSON_IsString(event_json_obj)) {
DEBUG_WARN("caching_inbox_poller: row missing event_json string; skipping");
g_total_rejected++;
continue;
}
const char* event_json = cJSON_GetStringValue(event_json_obj);
size_t event_json_len = strlen(event_json);
int rc = ingest_event(event_json, event_json_len,
EVENT_SOURCE_CACHING_INBOX, NULL, NULL);
if (rc == 0) {
g_total_accepted++;
} else {
g_total_rejected++;
DEBUG_WARN("caching_inbox_poller: ingest_event rejected an event (rc=%d)", rc);
}
}
cJSON_Delete(rows);
// State transition: a full batch suggests more work is waiting.
if (count >= batch_size) {
if (g_state != POLLER_ACTIVE) {
DEBUG_TRACE("caching_inbox_poller: full batch, switching to ACTIVE");
}
g_state = POLLER_ACTIVE;
} else {
if (g_state != POLLER_IDLE) {
DEBUG_TRACE("caching_inbox_poller: partial batch, switching to IDLE");
}
g_state = POLLER_IDLE;
}
return 0;
#else
// SQLite build: no-op.
return 0;
#endif
}
void caching_inbox_poller_shutdown(void) {
#ifdef DB_BACKEND_POSTGRES
DEBUG_LOG("caching_inbox_poller: shutdown (dequeued=%d accepted=%d rejected=%d)",
g_total_dequeued, g_total_accepted, g_total_rejected);
g_initialized = 0;
g_state = POLLER_IDLE;
g_last_poll_ms = 0;
g_last_batch_size = 0;
#else
// SQLite build: no-op.
#endif
}
void caching_inbox_poller_get_stats(int* out_total_dequeued,
int* out_total_accepted,
int* out_total_rejected,
int* out_total_duplicates,
int* out_last_batch_size,
int* out_pending_live,
int* out_pending_backfill,
int* out_oldest_age_seconds) {
#ifdef DB_BACKEND_POSTGRES
if (out_total_dequeued) *out_total_dequeued = g_total_dequeued;
if (out_total_accepted) *out_total_accepted = g_total_accepted;
if (out_total_rejected) *out_total_rejected = g_total_rejected;
if (out_total_duplicates) *out_total_duplicates = g_total_duplicates;
if (out_last_batch_size) *out_last_batch_size = g_last_batch_size;
// Pending counts and oldest age come from the database; query on demand.
int live = 0, backfill = 0, oldest = 0;
if (out_pending_live || out_pending_backfill) {
if (db_caching_inbox_pending_counts(&live, &backfill) != 0) {
live = 0;
backfill = 0;
}
}
if (out_pending_live) *out_pending_live = live;
if (out_pending_backfill) *out_pending_backfill = backfill;
if (out_oldest_age_seconds) {
if (db_caching_inbox_oldest_age(&oldest) != 0) {
oldest = 0;
}
*out_oldest_age_seconds = oldest;
}
#else
if (out_total_dequeued) *out_total_dequeued = 0;
if (out_total_accepted) *out_total_accepted = 0;
if (out_total_rejected) *out_total_rejected = 0;
if (out_total_duplicates) *out_total_duplicates = 0;
if (out_last_batch_size) *out_last_batch_size = 0;
if (out_pending_live) *out_pending_live = 0;
if (out_pending_backfill) *out_pending_backfill = 0;
if (out_oldest_age_seconds) *out_oldest_age_seconds = 0;
#endif
}
+27
View File
@@ -0,0 +1,27 @@
#ifndef CACHING_INBOX_POLLER_H
#define CACHING_INBOX_POLLER_H
// Initialize the inbox poller. Called once at startup (PostgreSQL only).
// Returns 0 on success, -1 on error.
int caching_inbox_poller_init(void);
// Perform one polling cycle. Called from the main lws service loop.
// Dequeues a bounded batch and feeds events through ingest_event().
// Returns 0 on success, -1 on error.
int caching_inbox_poller_tick(void);
// Shut down the poller and free resources.
void caching_inbox_poller_shutdown(void);
// Get current poller statistics for status display.
// All out-parameters are optional (may be NULL).
void caching_inbox_poller_get_stats(int* out_total_dequeued,
int* out_total_accepted,
int* out_total_rejected,
int* out_total_duplicates,
int* out_last_batch_size,
int* out_pending_live,
int* out_pending_backfill,
int* out_oldest_age_seconds);
#endif // CACHING_INBOX_POLLER_H
+277
View File
@@ -0,0 +1,277 @@
#define _GNU_SOURCE
#include "caching_service_launcher.h"
#include "config.h"
#include "debug.h"
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <signal.h>
#include <sys/wait.h>
#include <sys/types.h>
#include <errno.h>
#include <fcntl.h>
// Tracked child PID for fork mode.
// -1 means not running (or not launched by us).
static pid_t g_caching_child_pid = -1;
// ---------------------------------------------------------------------------
// Fork/exec implementation
// ---------------------------------------------------------------------------
static int caching_service_start_fork(const char* binary_path, const char* pg_conn) {
if (!binary_path || binary_path[0] == '\0') {
DEBUG_ERROR("caching_service_start: caching_service_binary_path is not set");
return -1;
}
if (!pg_conn || pg_conn[0] == '\0') {
DEBUG_ERROR("caching_service_start: caching_service_pg_conn is not set");
return -1;
}
// Check that the binary exists and is executable
if (access(binary_path, X_OK) != 0) {
DEBUG_ERROR("caching_service_start: binary '%s' not found or not executable: %s",
binary_path, strerror(errno));
return -1;
}
// If already running, don't start again
if (g_caching_child_pid > 0) {
// Check if the process is still alive
int status;
pid_t result = waitpid(g_caching_child_pid, &status, WNOHANG);
if (result == 0) {
// Still running
DEBUG_WARN("caching_service_start: caching service already running (PID %d)",
g_caching_child_pid);
return 0;
}
// Process exited — reset PID
g_caching_child_pid = -1;
}
DEBUG_INFO("caching_service_start: forking '%s' with -p (PG mode)", binary_path);
pid_t pid = fork();
if (pid < 0) {
DEBUG_ERROR("caching_service_start: fork failed: %s", strerror(errno));
return -1;
}
if (pid == 0) {
// Child process
// Detach from parent's process group so signals to the relay
// don't automatically propagate to the caching service.
setsid();
// Redirect stdin from /dev/null
int devnull = open("/dev/null", O_RDONLY);
if (devnull >= 0) {
dup2(devnull, STDIN_FILENO);
close(devnull);
}
// Redirect stdout/stderr to a log file for debugging.
// The log file is created in the relay's working directory (build/).
int logfile = open("caching_relay.log", O_WRONLY | O_CREAT | O_APPEND, 0644);
if (logfile >= 0) {
dup2(logfile, STDOUT_FILENO);
dup2(logfile, STDERR_FILENO);
close(logfile);
} else {
// Fallback to /dev/null if log file can't be opened
devnull = open("/dev/null", O_WRONLY);
if (devnull >= 0) {
dup2(devnull, STDOUT_FILENO);
dup2(devnull, STDERR_FILENO);
close(devnull);
}
}
// Build argv for the caching_relay binary.
// The caching_relay binary supports PostgreSQL mode via -p <pg-conn>,
// which reads all config from the c-relay-pg config table and writes
// fetched events to the caching_event_inbox table. No JSON config file
// is needed in this mode.
char* argv[5];
int argc = 0;
argv[argc++] = (char*)binary_path;
argv[argc++] = (char*)"-p";
argv[argc++] = (char*)pg_conn;
argv[argc] = NULL;
execv(binary_path, argv);
// If we get here, exec failed
// Use _exit to avoid flushing parent's stdio buffers
_exit(127);
}
// Parent process
g_caching_child_pid = pid;
DEBUG_INFO("caching_service_start: caching service started (PID %d)", pid);
return 0;
}
static int caching_service_stop_fork(void) {
if (g_caching_child_pid <= 0) {
DEBUG_LOG("caching_service_stop: caching service not running");
return 0;
}
DEBUG_INFO("caching_service_stop: sending SIGTERM to PID %d", g_caching_child_pid);
if (kill(g_caching_child_pid, SIGTERM) != 0) {
if (errno == ESRCH) {
// Process already exited
DEBUG_LOG("caching_service_stop: process already exited");
g_caching_child_pid = -1;
return 0;
}
DEBUG_ERROR("caching_service_stop: kill failed: %s", strerror(errno));
return -1;
}
// Wait briefly for graceful shutdown
int status;
int wait_ms = 0;
while (wait_ms < 5000) {
pid_t result = waitpid(g_caching_child_pid, &status, WNOHANG);
if (result == g_caching_child_pid) {
// Child exited
if (WIFEXITED(status)) {
DEBUG_INFO("caching_service_stop: process exited with status %d",
WEXITSTATUS(status));
} else if (WIFSIGNALED(status)) {
DEBUG_INFO("caching_service_stop: process killed by signal %d",
WTERMSIG(status));
}
g_caching_child_pid = -1;
return 0;
}
usleep(100000); // 100ms
wait_ms += 100;
}
// Force kill if still running after 5 seconds
DEBUG_WARN("caching_service_stop: process did not exit gracefully, sending SIGKILL");
kill(g_caching_child_pid, SIGKILL);
waitpid(g_caching_child_pid, &status, 0);
g_caching_child_pid = -1;
return 0;
}
static int caching_service_is_running_fork(void) {
if (g_caching_child_pid <= 0) {
return 0;
}
int status;
pid_t result = waitpid(g_caching_child_pid, &status, WNOHANG);
if (result == 0) {
return 1; // Still running
}
// Process exited
g_caching_child_pid = -1;
return 0;
}
// ---------------------------------------------------------------------------
// Systemd implementation (stub — not yet implemented)
// ---------------------------------------------------------------------------
static int caching_service_start_systemd(void) {
DEBUG_ERROR("caching_service_start: systemd launch mode not yet implemented");
return -1;
}
static int caching_service_stop_systemd(void) {
DEBUG_ERROR("caching_service_stop: systemd launch mode not yet implemented");
return -1;
}
static int caching_service_is_running_systemd(void) {
// Could check systemctl is-active caching-relay
return 0;
}
// ---------------------------------------------------------------------------
// Public API
// ---------------------------------------------------------------------------
int caching_service_start(void) {
const char* launch_mode = get_config_value("caching_service_launch_mode");
if (!launch_mode || strcmp(launch_mode, "fork") == 0) {
const char* binary_path = get_config_value("caching_service_binary_path");
// The caching_relay binary uses PostgreSQL mode (-p <pg-conn>) to read
// all config from the c-relay-pg config table and write events to the
// caching_event_inbox table. No JSON config file is needed.
const char* pg_conn = get_config_value("caching_service_pg_conn");
int rc = caching_service_start_fork(binary_path, pg_conn);
if (binary_path) free((char*)binary_path);
if (pg_conn) free((char*)pg_conn);
if (launch_mode) free((char*)launch_mode);
return rc;
}
if (strcmp(launch_mode, "systemd") == 0) {
free((char*)launch_mode);
return caching_service_start_systemd();
}
DEBUG_ERROR("caching_service_start: unknown launch mode '%s'", launch_mode);
free((char*)launch_mode);
return -1;
}
int caching_service_stop(void) {
const char* launch_mode = get_config_value("caching_service_launch_mode");
if (!launch_mode || strcmp(launch_mode, "fork") == 0) {
if (launch_mode) free((char*)launch_mode);
return caching_service_stop_fork();
}
if (strcmp(launch_mode, "systemd") == 0) {
free((char*)launch_mode);
return caching_service_stop_systemd();
}
free((char*)launch_mode);
return -1;
}
int caching_service_is_running(void) {
const char* launch_mode = get_config_value("caching_service_launch_mode");
int rc;
if (!launch_mode || strcmp(launch_mode, "fork") == 0) {
rc = caching_service_is_running_fork();
} else if (strcmp(launch_mode, "systemd") == 0) {
rc = caching_service_is_running_systemd();
} else {
rc = 0;
}
if (launch_mode) free((char*)launch_mode);
return rc;
}
int caching_service_get_pid(void) {
if (caching_service_is_running_fork()) {
return (int)g_caching_child_pid;
}
return -1;
}
int caching_service_shutdown(void) {
// Only stop if we launched it (fork mode with a tracked PID)
if (g_caching_child_pid > 0) {
DEBUG_INFO("caching_service_shutdown: stopping caching service (PID %d)",
g_caching_child_pid);
return caching_service_stop_fork();
}
return 0;
}
+37
View File
@@ -0,0 +1,37 @@
#ifndef CACHING_SERVICE_LAUNCHER_H
#define CACHING_SERVICE_LAUNCHER_H
// Caching service process launcher.
//
// Supports two launch modes controlled by the "caching_service_launch_mode" config key:
// "fork" — c-relay-pg forks/execs the caching_relay binary directly (default)
// "systemd" — delegates to systemctl start/stop (not yet implemented, returns error)
//
// The binary path is read from "caching_service_binary_path".
// The PostgreSQL connection string is read from "caching_service_pg_conn"
// and passed to the binary via the --pg-conn argument.
// Start the caching service.
// Returns 0 on success, -1 on error.
// On success in fork mode, the child PID is tracked internally.
int caching_service_start(void);
// Stop the caching service.
// Returns 0 on success (or if not running), -1 on error.
// In fork mode, sends SIGTERM to the tracked child process.
int caching_service_stop(void);
// Check if the caching service is currently running.
// Returns 1 if running, 0 if not running, -1 on error.
int caching_service_is_running(void);
// Get the current child PID (fork mode only).
// Returns the PID, or -1 if not running or not in fork mode.
int caching_service_get_pid(void);
// Shut down the caching service if it was launched by us.
// Called during c-relay-pg graceful shutdown.
// Returns 0 on success, -1 on error.
int caching_service_shutdown(void);
#endif // CACHING_SERVICE_LAUNCHER_H
+1261 -102
View File
File diff suppressed because it is too large Load Diff
+15 -2
View File
@@ -15,6 +15,9 @@ struct lws;
#define RELAY_URL_MAX_LENGTH 512
#define RELAY_PUBKEY_MAX_LENGTH 65
#define RELAY_CONTACT_MAX_LENGTH 256
#define MAX_ADMIN_PUBKEYS 10
#define ADMIN_PUBKEY_HEX_LEN 64
#define ADMIN_PUBKEY_LIST_MAX_LENGTH ((MAX_ADMIN_PUBKEYS * ADMIN_PUBKEY_HEX_LEN) + (MAX_ADMIN_PUBKEYS - 1) + 1)
#define SUBSCRIPTION_ID_MAX_LENGTH 64
#define CLIENT_IP_MAX_LENGTH 46
#define MAX_SUBSCRIPTIONS_PER_CLIENT 25
@@ -29,10 +32,12 @@ extern char g_database_path[512];
// Command line options structure for first-time startup
typedef struct {
int port_override; // -1 = not set, >0 = port value
char admin_pubkey_override[65]; // Empty string = not set, 64-char hex = override
char admin_pubkey_override[ADMIN_PUBKEY_LIST_MAX_LENGTH]; // Empty string = not set, comma-separated 64-char hex pubkeys
char relay_privkey_override[65]; // Empty string = not set, 64-char hex = override
int strict_port; // 0 = allow port increment, 1 = fail if exact port unavailable
int debug_level; // 0-5, default 0 (no debug output)
int start_caching; // 0 = don't auto-start, 1 = start caching service on startup
int reset_backfill; // 0 = no, 1 = clear caching_backfill_progress before starting
char db_connstring_override[1024];
char db_host[128];
int db_port; // 0 = not set
@@ -67,7 +72,7 @@ int get_config_int(const char* key, int default_value);
int get_config_bool(const char* key, int default_value);
// First-time startup functions
int is_first_time_startup(void);
int is_first_time_startup(const char* db_connection_target);
int first_time_startup_sequence(const cli_options_t* cli_options, char* admin_pubkey_out, char* relay_pubkey_out, char* relay_privkey_out);
int startup_existing_relay(const char* relay_pubkey, const cli_options_t* cli_options);
@@ -134,6 +139,14 @@ const char* get_relay_pubkey_cached(void);
void invalidate_config_cache(void);
int reload_config_from_table(void);
// Multi-admin helpers
int reload_admin_pubkeys_cache(void);
int is_admin_pubkey(const char* pubkey);
int get_admin_pubkey_count(void);
const char* get_primary_admin_pubkey(void);
int add_admin_pubkey(const char* pubkey);
int remove_admin_pubkey(const char* pubkey);
// Hybrid config access functions
const char* get_config_value_hybrid(const char* key);
int is_config_table_ready(void);
+85
View File
@@ -20,6 +20,13 @@ int db_open_worker_connection(const char* db_path, void** out_connection) {
}
void db_close_worker_connection(void* connection) { postgres_db_close_worker_connection(connection); }
int db_worker_listen(void* connection, const char* channel) {
return postgres_db_worker_listen(connection, channel);
}
int db_worker_poll_notify(void* connection, int timeout_ms) {
return postgres_db_worker_poll_notify(connection, timeout_ms);
}
int db_prepare(const char* sql, db_stmt_t** out_stmt) {
return postgres_db_prepare(sql, (postgres_db_stmt_t**)out_stmt);
}
@@ -92,6 +99,9 @@ int db_get_total_event_count_ll(long long* out_count) { return postgres_db_get_t
int db_get_event_count_since(time_t cutoff, long long* out_count) {
return postgres_db_get_event_count_since(cutoff, out_count);
}
int db_get_storage_size_bytes(long long* out_size) {
return postgres_db_get_storage_size_bytes(out_size);
}
cJSON* db_get_event_kind_distribution_rows(long long* out_total_events) {
return postgres_db_get_event_kind_distribution_rows(out_total_events);
}
@@ -158,6 +168,31 @@ int db_exec_sql(const char* sql) { return postgres_db_exec_sql(sql); }
int db_wal_checkpoint_passive(void) { return postgres_db_wal_checkpoint_passive(); }
int db_wal_checkpoint_truncate(void) { return postgres_db_wal_checkpoint_truncate(); }
// Caching relay inbox helpers (PostgreSQL-only).
cJSON* db_caching_inbox_dequeue_batch(int batch_size, int* out_count) {
return postgres_db_caching_inbox_dequeue_batch(batch_size, out_count);
}
int db_caching_inbox_pending_counts(int* out_live_count, int* out_backfill_count) {
return postgres_db_caching_inbox_pending_counts(out_live_count, out_backfill_count);
}
int db_caching_inbox_oldest_age(int* out_oldest_age_seconds) {
return postgres_db_caching_inbox_oldest_age(out_oldest_age_seconds);
}
int db_caching_backfill_author_counts(int* out_complete, int* out_total) {
return postgres_db_caching_backfill_author_counts(out_complete, out_total);
}
int db_caching_inbox_insert(const char* event_id, const char* event_json,
const char* source_relay, const char* source_class,
int priority) {
return postgres_db_caching_inbox_insert(event_id, event_json, source_relay, source_class, priority);
}
cJSON* db_get_profile_metadata(const char* pubkey) {
return postgres_db_get_profile_metadata(pubkey);
}
cJSON* db_get_outbox_relays(const char* pubkey) {
return postgres_db_get_outbox_relays(pubkey);
}
#else
int db_init(const char* connection_string) { return sqlite_db_init(connection_string); }
@@ -174,6 +209,13 @@ int db_open_worker_connection(const char* db_path, void** out_connection) {
}
void db_close_worker_connection(void* connection) { sqlite_db_close_worker_connection(connection); }
int db_worker_listen(void* connection, const char* channel) {
return sqlite_db_worker_listen(connection, channel);
}
int db_worker_poll_notify(void* connection, int timeout_ms) {
return sqlite_db_worker_poll_notify(connection, timeout_ms);
}
int db_prepare(const char* sql, db_stmt_t** out_stmt) {
return sqlite_db_prepare(sql, (sqlite_db_stmt_t**)out_stmt);
}
@@ -246,6 +288,9 @@ int db_get_total_event_count_ll(long long* out_count) { return sqlite_db_get_tot
int db_get_event_count_since(time_t cutoff, long long* out_count) {
return sqlite_db_get_event_count_since(cutoff, out_count);
}
int db_get_storage_size_bytes(long long* out_size) {
return sqlite_db_get_storage_size_bytes(out_size);
}
cJSON* db_get_event_kind_distribution_rows(long long* out_total_events) {
return sqlite_db_get_event_kind_distribution_rows(out_total_events);
}
@@ -312,4 +357,44 @@ int db_exec_sql(const char* sql) { return sqlite_db_exec_sql(sql); }
int db_wal_checkpoint_passive(void) { return sqlite_db_wal_checkpoint_passive(); }
int db_wal_checkpoint_truncate(void) { return sqlite_db_wal_checkpoint_truncate(); }
// Caching relay inbox helpers are PostgreSQL-only. The SQLite backend has no
// caching_event_inbox table, so the dispatch path returns errors/no-ops here
// rather than delegating to sqlite_db_ops stubs.
cJSON* db_caching_inbox_dequeue_batch(int batch_size, int* out_count) {
if (out_count) *out_count = 0;
(void)batch_size;
return NULL;
}
int db_caching_inbox_pending_counts(int* out_live_count, int* out_backfill_count) {
if (out_live_count) *out_live_count = 0;
if (out_backfill_count) *out_backfill_count = 0;
return DB_ERROR;
}
int db_caching_inbox_oldest_age(int* out_oldest_age_seconds) {
if (out_oldest_age_seconds) *out_oldest_age_seconds = 0;
return DB_ERROR;
}
int db_caching_backfill_author_counts(int* out_complete, int* out_total) {
if (out_complete) *out_complete = 0;
if (out_total) *out_total = 0;
return DB_ERROR;
}
int db_caching_inbox_insert(const char* event_id, const char* event_json,
const char* source_relay, const char* source_class,
int priority) {
(void)event_id; (void)event_json; (void)source_relay; (void)source_class; (void)priority;
return DB_ERROR;
}
cJSON* db_get_profile_metadata(const char* pubkey) {
/* SQLite backend: kind-0 metadata not supported in this context.
* The caching follows feature is PostgreSQL-only. */
(void)pubkey;
return NULL;
}
cJSON* db_get_outbox_relays(const char* pubkey) {
(void)pubkey;
return NULL;
}
#endif
+35
View File
@@ -20,6 +20,15 @@ void db_clear_thread_connection(void);
int db_open_worker_connection(const char* db_path, void** out_connection);
void db_close_worker_connection(void* connection);
// LISTEN/NOTIFY support for worker connections (PostgreSQL-only; SQLite stubs
// return -1 so callers can fall back to timer-based polling).
// db_worker_listen: issue LISTEN <channel> on the given worker connection.
// db_worker_poll_notify: block up to timeout_ms waiting for a notification on
// the given worker connection. Returns 1 if a notification was consumed,
// 0 on timeout, -1 on error/unsupported backend.
int db_worker_listen(void* connection, const char* channel);
int db_worker_poll_notify(void* connection, int timeout_ms);
// DB result codes (backend-agnostic)
#define DB_OK 0
#define DB_ERROR 1
@@ -72,6 +81,7 @@ char* db_execute_readonly_query_json(const char* query, const char* request_id,
// Monitoring/stat helpers (Phase 1 api.c migration)
int db_get_total_event_count_ll(long long* out_count);
int db_get_event_count_since(time_t cutoff, long long* out_count);
int db_get_storage_size_bytes(long long* out_size);
cJSON* db_get_event_kind_distribution_rows(long long* out_total_events);
cJSON* db_get_top_pubkeys_rows(int limit);
cJSON* db_get_subscription_details_rows(void);
@@ -117,4 +127,29 @@ int db_exec_sql(const char* sql);
int db_wal_checkpoint_passive(void);
int db_wal_checkpoint_truncate(void);
// Caching relay inbox helpers (PostgreSQL-only; SQLite path returns error/no-op).
// These back the c-relay-pg inbox poller that destructively dequeues events
// written into the caching_event_inbox table by the external caching app.
cJSON* db_caching_inbox_dequeue_batch(int batch_size, int* out_count);
int db_caching_inbox_pending_counts(int* out_live_count, int* out_backfill_count);
int db_caching_inbox_oldest_age(int* out_oldest_age_seconds);
// Backfill author progress from caching_service_state (per-author until-cursor
// drain model). Returns DB_OK and fills out_complete/out_total, or DB_ERROR.
int db_caching_backfill_author_counts(int* out_complete, int* out_total);
int db_caching_inbox_insert(const char* event_id, const char* event_json,
const char* source_relay, const char* source_class,
int priority);
// Profile metadata and outbox relay helpers.
// db_get_profile_metadata: returns the most recent kind-0 metadata for a pubkey
// as a cJSON object with name/picture/about/nip05/display_name fields parsed
// from the event content JSON. Returns NULL if no kind-0 event exists.
// Caller must cJSON_Delete() the result.
cJSON* db_get_profile_metadata(const char* pubkey);
// db_get_outbox_relays: returns relay URLs from the most recent kind-10002
// event for a pubkey, as a cJSON array of strings. Returns NULL if no
// kind-10002 event exists. Caller must cJSON_Delete() the result.
cJSON* db_get_outbox_relays(const char* pubkey);
#endif // DB_OPS_H
+610 -3
View File
@@ -40,6 +40,10 @@ static char* postgres_strdup(const char* s) {
#include "pg_schema.h"
#include <libpq-fe.h>
#include <sys/select.h>
#include <sys/socket.h>
#include <unistd.h>
#include <errno.h>
static PGconn* g_pg_conn = NULL;
static __thread PGconn* g_thread_pg_conn = NULL;
@@ -245,6 +249,86 @@ int postgres_db_open_worker_connection(const char* db_path, void** out_connectio
return DB_OK;
}
// Issue LISTEN <channel> on a worker connection. The connection must remain
// valid for the lifetime of the listen. Returns DB_OK on success, DB_ERROR on
// failure. Channel is treated as an identifier (validated alnum/underscore).
int postgres_db_worker_listen(void* connection, const char* channel) {
PGconn* conn = (PGconn*)connection;
if (!conn || PQstatus(conn) != CONNECTION_OK || !channel || !channel[0]) {
return DB_ERROR;
}
// Validate channel name to avoid SQL injection via LISTEN.
for (const char* p = channel; *p; p++) {
if (!( ((*p >= 'a' && *p <= 'z') || (*p >= 'A' && *p <= 'Z') ||
(*p >= '0' && *p <= '9') || *p == '_') )) {
postgres_set_error_text("postgres_db_worker_listen: invalid channel name");
return DB_ERROR;
}
}
char sql[128];
snprintf(sql, sizeof(sql), "LISTEN %s", channel);
PGresult* res = PQexec(conn, sql);
if (!res || PQresultStatus(res) != PGRES_COMMAND_OK) {
postgres_set_error_from_conn(conn, "postgres_db_worker_listen: LISTEN failed");
if (res) PQclear(res);
return DB_ERROR;
}
PQclear(res);
return DB_OK;
}
// Wait up to timeout_ms for a notification on the worker connection.
// Returns 1 if a notification was consumed, 0 on timeout, -1 on error.
// This does NOT register a LISTEN; the caller must have issued LISTEN first.
int postgres_db_worker_poll_notify(void* connection, int timeout_ms) {
PGconn* conn = (PGconn*)connection;
if (!conn || PQstatus(conn) != CONNECTION_OK) {
return -1;
}
// First, drain any already-queued notifications without blocking.
PQconsumeInput(conn);
PGnotify* notify = PQnotifies(conn);
if (notify) {
PQfreemem(notify);
return 1;
}
int sock = PQsocket(conn);
if (sock < 0) {
return -1;
}
fd_set fds;
FD_ZERO(&fds);
FD_SET(sock, &fds);
struct timeval tv;
tv.tv_sec = timeout_ms / 1000;
tv.tv_usec = (timeout_ms % 1000) * 1000;
int rc = select(sock + 1, &fds, NULL, NULL, &tv);
if (rc < 0) {
if (errno == EINTR) return 0; // signal interrupted: treat as timeout-ish
return -1;
}
if (rc == 0) {
return 0; // timeout
}
// Socket readable: consume input and check for notifications.
if (PQconsumeInput(conn) == 0) {
return -1;
}
notify = PQnotifies(conn);
if (notify) {
PQfreemem(notify);
return 1;
}
// Readable but no notification (could be a keepalive or other traffic).
return 0;
}
void postgres_db_close_worker_connection(void* connection) {
PGconn* conn = (PGconn*)connection;
if (!conn) return;
@@ -443,6 +527,13 @@ int postgres_db_open_worker_connection(const char* db_path, void** out_connectio
}
void postgres_db_close_worker_connection(void* connection) { (void)connection; }
int postgres_db_worker_listen(void* connection, const char* channel) {
(void)connection; (void)channel; return -1;
}
int postgres_db_worker_poll_notify(void* connection, int timeout_ms) {
(void)connection; (void)timeout_ms; return -1;
}
int postgres_db_prepare(const char* sql, postgres_db_stmt_t** out_stmt) {
(void)sql;
if (out_stmt) *out_stmt = NULL;
@@ -1090,6 +1181,37 @@ int postgres_db_get_event_count_since(time_t cutoff, long long* out_count) {
#endif
}
int postgres_db_get_storage_size_bytes(long long* out_size) {
#if defined(DB_BACKEND_POSTGRES) && defined(HAVE_LIBPQ)
if (!out_size) return DB_MISUSE;
*out_size = 0;
PGconn* conn = postgres_db_active_connection();
if (!conn || PQstatus(conn) != CONNECTION_OK) return DB_ERROR;
PGresult* res = PQexec(conn, "SELECT pg_database_size(current_database())");
if (!res || PQresultStatus(res) != PGRES_TUPLES_OK) {
if (res) {
postgres_set_error_text(PQresultErrorMessage(res));
PQclear(res);
} else {
postgres_set_error_from_conn(conn, "postgres_db_get_storage_size_bytes failed");
}
return DB_ERROR;
}
if (PQntuples(res) > 0 && !PQgetisnull(res, 0, 0)) {
*out_size = strtoll(PQgetvalue(res, 0, 0), NULL, 10);
}
PQclear(res);
return DB_OK;
#else
if (out_size) *out_size = 0;
return DB_ERROR;
#endif
}
cJSON* postgres_db_get_event_kind_distribution_rows(long long* out_total_events) {
#if defined(DB_BACKEND_POSTGRES) && defined(HAVE_LIBPQ)
if (out_total_events) *out_total_events = 0;
@@ -1589,6 +1711,70 @@ int postgres_db_insert_event_with_json(const char* id, const char* pubkey, long
id, pubkey, created_buf, kind_buf, event_type, content, sig, tags_json, event_json
};
int use_tx = (strcmp(event_type, "replaceable") == 0 || strcmp(event_type, "addressable") == 0);
if (use_tx) {
PGresult* tx_begin = PQexec(conn, "BEGIN");
if (!tx_begin || PQresultStatus(tx_begin) != PGRES_COMMAND_OK) {
if (tx_begin) {
postgres_set_error_text(PQresultErrorMessage(tx_begin));
PQclear(tx_begin);
} else {
postgres_set_error_from_conn(conn, "postgres_db_insert_event_with_json BEGIN failed");
}
if (out_step_rc) *out_step_rc = DB_ERROR;
if (out_extended_errcode) *out_extended_errcode = DB_ERROR;
return DB_ERROR;
}
PQclear(tx_begin);
PGresult* del_res = NULL;
if (strcmp(event_type, "replaceable") == 0) {
const char* del_params[4] = { pubkey, kind_buf, created_buf, id };
del_res = PQexecParams(conn,
"DELETE FROM events "
"WHERE event_type = 'replaceable' "
" AND pubkey = $1 "
" AND kind = $2::INT "
" AND (created_at < $3::BIGINT "
" OR (created_at = $3::BIGINT AND id > $4))",
4, NULL, del_params, NULL, NULL, 0);
} else {
const char* del_params[5] = { pubkey, kind_buf, created_buf, id, tags_json };
del_res = PQexecParams(conn,
"DELETE FROM events "
"WHERE event_type = 'addressable' "
" AND pubkey = $1 "
" AND kind = $2::INT "
" AND d_tag_value = COALESCE(( "
" SELECT tag->>1 "
" FROM jsonb_array_elements($5::jsonb) AS tag "
" WHERE jsonb_typeof(tag) = 'array' "
" AND jsonb_array_length(tag) >= 2 "
" AND tag->>0 = 'd' "
" LIMIT 1 "
" ), '') "
" AND (created_at < $3::BIGINT "
" OR (created_at = $3::BIGINT AND id > $4))",
5, NULL, del_params, NULL, NULL, 0);
}
if (!del_res || PQresultStatus(del_res) != PGRES_COMMAND_OK) {
if (del_res) {
postgres_set_error_text(PQresultErrorMessage(del_res));
PQclear(del_res);
} else {
postgres_set_error_from_conn(conn, "postgres_db_insert_event_with_json DELETE failed");
}
PGresult* tx_rollback = PQexec(conn, "ROLLBACK");
if (tx_rollback) PQclear(tx_rollback);
if (out_step_rc) *out_step_rc = DB_ERROR;
if (out_extended_errcode) *out_extended_errcode = DB_ERROR;
return DB_ERROR;
}
PQclear(del_res);
}
PGresult* res = PQexecParams(conn,
"INSERT INTO events (id, pubkey, created_at, kind, event_type, content, sig, tags, event_json) "
"VALUES ($1, $2, $3::BIGINT, $4::INT, $5, $6, $7, $8::jsonb, $9) "
@@ -1596,7 +1782,11 @@ int postgres_db_insert_event_with_json(const char* id, const char* pubkey, long
9, NULL, params, NULL, NULL, 0);
if (!res) {
postgres_set_error_from_conn(conn, "postgres_db_insert_event_with_json failed");
postgres_set_error_from_conn(conn, "postgres_db_insert_event_with_json INSERT failed");
if (use_tx) {
PGresult* tx_rollback = PQexec(conn, "ROLLBACK");
if (tx_rollback) PQclear(tx_rollback);
}
if (out_step_rc) *out_step_rc = DB_ERROR;
if (out_extended_errcode) *out_extended_errcode = DB_ERROR;
return DB_ERROR;
@@ -1614,15 +1804,38 @@ int postgres_db_insert_event_with_json(const char* id, const char* pubkey, long
}
postgres_set_error_text(PQresultErrorMessage(res));
PQclear(res);
if (use_tx) {
PGresult* tx_rollback = PQexec(conn, "ROLLBACK");
if (tx_rollback) PQclear(tx_rollback);
}
return is_constraint ? DB_OK : DB_ERROR;
}
const char* tuples = PQcmdTuples(res);
int inserted = (tuples && tuples[0] != '\0') ? atoi(tuples) : 0;
PQclear(res);
if (use_tx) {
PGresult* tx_commit = PQexec(conn, "COMMIT");
if (!tx_commit || PQresultStatus(tx_commit) != PGRES_COMMAND_OK) {
if (tx_commit) {
postgres_set_error_text(PQresultErrorMessage(tx_commit));
PQclear(tx_commit);
} else {
postgres_set_error_from_conn(conn, "postgres_db_insert_event_with_json COMMIT failed");
}
PGresult* tx_rollback = PQexec(conn, "ROLLBACK");
if (tx_rollback) PQclear(tx_rollback);
if (out_step_rc) *out_step_rc = DB_ERROR;
if (out_extended_errcode) *out_extended_errcode = DB_ERROR;
return DB_ERROR;
}
PQclear(tx_commit);
}
if (out_step_rc) *out_step_rc = inserted > 0 ? DB_DONE : DB_CONSTRAINT;
if (out_extended_errcode) *out_extended_errcode = inserted > 0 ? DB_OK : DB_CONSTRAINT;
PQclear(res);
return DB_OK;
#else
(void)id; (void)pubkey; (void)created_at; (void)kind; (void)event_type;
@@ -2035,3 +2248,397 @@ int postgres_db_exec_sql(const char* sql) {
int postgres_db_wal_checkpoint_passive(void) { return DB_OK; }
int postgres_db_wal_checkpoint_truncate(void) { return DB_OK; }
// ---------------------------------------------------------------------------
// Caching relay inbox helpers
//
// These functions back the c-relay-pg inbox poller that destructively
// dequeues events written into the caching_event_inbox table by the external
// caching application. They are PostgreSQL-only; the SQLite dispatch path
// returns errors/no-ops.
// ---------------------------------------------------------------------------
cJSON* postgres_db_caching_inbox_dequeue_batch(int batch_size, int* out_count) {
#if defined(DB_BACKEND_POSTGRES) && defined(HAVE_LIBPQ)
if (out_count) *out_count = 0;
if (batch_size <= 0) {
postgres_set_error_text("postgres_db_caching_inbox_dequeue_batch: batch_size must be positive");
return NULL;
}
PGconn* conn = postgres_db_active_connection();
if (!conn || PQstatus(conn) != CONNECTION_OK) return NULL;
char batch_buf[16];
snprintf(batch_buf, sizeof(batch_buf), "%d", batch_size);
const char* params[1] = { batch_buf };
PGresult* res = PQexecParams(conn,
"WITH selected AS ("
" SELECT queue_id"
" FROM caching_event_inbox"
" ORDER BY priority ASC, received_at ASC, queue_id ASC"
" LIMIT $1"
" FOR UPDATE"
") "
"DELETE FROM caching_event_inbox AS inbox "
"USING selected "
"WHERE inbox.queue_id = selected.queue_id "
"RETURNING inbox.event_id, inbox.event_json, inbox.source_relay, "
" inbox.source_class, inbox.priority, inbox.received_at",
1, NULL, params, NULL, NULL, 0);
if (!res || PQresultStatus(res) != PGRES_TUPLES_OK) {
if (res) {
postgres_set_error_text(PQresultErrorMessage(res));
PQclear(res);
} else {
postgres_set_error_from_conn(conn, "postgres_db_caching_inbox_dequeue_batch failed");
}
return NULL;
}
int n = PQntuples(res);
if (n == 0) {
PQclear(res);
if (out_count) *out_count = 0;
return NULL;
}
cJSON* rows = cJSON_CreateArray();
if (!rows) {
PQclear(res);
return NULL;
}
for (int i = 0; i < n; i++) {
cJSON* row = cJSON_CreateObject();
if (!row) {
cJSON_Delete(rows);
PQclear(res);
if (out_count) *out_count = 0;
return NULL;
}
// event_id (col 0): TEXT, never null per schema.
cJSON_AddStringToObject(row, "event_id",
PQgetisnull(res, i, 0) ? "" : PQgetvalue(res, i, 0));
// event_json (col 1): JSONB. PQgetvalue returns the canonical text form.
cJSON_AddStringToObject(row, "event_json",
PQgetisnull(res, i, 1) ? "" : PQgetvalue(res, i, 1));
// source_relay (col 2): nullable TEXT.
if (PQgetisnull(res, i, 2)) {
cJSON_AddNullToObject(row, "source_relay");
} else {
cJSON_AddStringToObject(row, "source_relay", PQgetvalue(res, i, 2));
}
// source_class (col 3): TEXT, never null per schema.
cJSON_AddStringToObject(row, "source_class",
PQgetisnull(res, i, 3) ? "" : PQgetvalue(res, i, 3));
// priority (col 4): SMALLINT.
int priority = PQgetisnull(res, i, 4) ? 0 : (int)strtol(PQgetvalue(res, i, 4), NULL, 10);
cJSON_AddNumberToObject(row, "priority", priority);
// received_at (col 5): BIGINT.
long long received_at = PQgetisnull(res, i, 5) ? 0 : strtoll(PQgetvalue(res, i, 5), NULL, 10);
cJSON_AddNumberToObject(row, "received_at", (double)received_at);
cJSON_AddItemToArray(rows, row);
}
PQclear(res);
if (out_count) *out_count = n;
return rows;
#else
if (out_count) *out_count = 0;
(void)batch_size;
return NULL;
#endif
}
int postgres_db_caching_inbox_pending_counts(int* out_live_count, int* out_backfill_count) {
#if defined(DB_BACKEND_POSTGRES) && defined(HAVE_LIBPQ)
if (out_live_count) *out_live_count = 0;
if (out_backfill_count) *out_backfill_count = 0;
PGconn* conn = postgres_db_active_connection();
if (!conn || PQstatus(conn) != CONNECTION_OK) return DB_ERROR;
PGresult* res = PQexec(conn,
"SELECT "
" COALESCE(SUM(CASE WHEN priority = 0 THEN 1 ELSE 0 END), 0), "
" COALESCE(SUM(CASE WHEN priority = 1 THEN 1 ELSE 0 END), 0) "
"FROM caching_event_inbox");
if (!res || PQresultStatus(res) != PGRES_TUPLES_OK) {
if (res) {
postgres_set_error_text(PQresultErrorMessage(res));
PQclear(res);
} else {
postgres_set_error_from_conn(conn, "postgres_db_caching_inbox_pending_counts failed");
}
return DB_ERROR;
}
long long live = PQgetisnull(res, 0, 0) ? 0 : strtoll(PQgetvalue(res, 0, 0), NULL, 10);
long long backfill = PQgetisnull(res, 0, 1) ? 0 : strtoll(PQgetvalue(res, 0, 1), NULL, 10);
if (out_live_count) *out_live_count = (int)live;
if (out_backfill_count) *out_backfill_count = (int)backfill;
PQclear(res);
return DB_OK;
#else
if (out_live_count) *out_live_count = 0;
if (out_backfill_count) *out_backfill_count = 0;
return DB_ERROR;
#endif
}
int postgres_db_caching_inbox_oldest_age(int* out_oldest_age_seconds) {
#if defined(DB_BACKEND_POSTGRES) && defined(HAVE_LIBPQ)
if (out_oldest_age_seconds) *out_oldest_age_seconds = 0;
PGconn* conn = postgres_db_active_connection();
if (!conn || PQstatus(conn) != CONNECTION_OK) return DB_ERROR;
PGresult* res = PQexec(conn,
"SELECT COALESCE(EXTRACT(EPOCH FROM NOW())::BIGINT - MIN(received_at), 0) "
"FROM caching_event_inbox");
if (!res || PQresultStatus(res) != PGRES_TUPLES_OK) {
if (res) {
postgres_set_error_text(PQresultErrorMessage(res));
PQclear(res);
} else {
postgres_set_error_from_conn(conn, "postgres_db_caching_inbox_oldest_age failed");
}
return DB_ERROR;
}
long long age = PQgetisnull(res, 0, 0) ? 0 : strtoll(PQgetvalue(res, 0, 0), NULL, 10);
if (out_oldest_age_seconds) *out_oldest_age_seconds = (int)age;
PQclear(res);
return DB_OK;
#else
if (out_oldest_age_seconds) *out_oldest_age_seconds = 0;
return DB_ERROR;
#endif
}
int postgres_db_caching_inbox_insert(const char* event_id, const char* event_json,
const char* source_relay, const char* source_class,
int priority) {
#if defined(DB_BACKEND_POSTGRES) && defined(HAVE_LIBPQ)
if (!event_id || !event_json || !source_class) return DB_MISUSE;
PGconn* conn = postgres_db_active_connection();
if (!conn || PQstatus(conn) != CONNECTION_OK) return DB_ERROR;
char priority_buf[16];
snprintf(priority_buf, sizeof(priority_buf), "%d", priority);
const char* params[5] = {
event_id,
event_json,
source_relay, // may be NULL -> libpq sends a SQL NULL
source_class,
priority_buf
};
PGresult* res = PQexecParams(conn,
"INSERT INTO caching_event_inbox "
" (event_id, event_json, source_relay, source_class, priority) "
"VALUES ($1, $2::jsonb, $3, $4, $5) "
"ON CONFLICT (event_id) DO NOTHING",
5, NULL, params, NULL, NULL, 0);
if (!res || PQresultStatus(res) != PGRES_COMMAND_OK) {
if (res) {
postgres_set_error_text(PQresultErrorMessage(res));
PQclear(res);
} else {
postgres_set_error_from_conn(conn, "postgres_db_caching_inbox_insert failed");
}
return DB_ERROR;
}
PQclear(res);
return DB_OK;
#else
(void)event_id; (void)event_json; (void)source_relay; (void)source_class; (void)priority;
return DB_ERROR;
#endif
}
int postgres_db_caching_backfill_author_counts(int* out_complete, int* out_total) {
#if defined(DB_BACKEND_POSTGRES) && defined(HAVE_LIBPQ)
if (out_complete) *out_complete = 0;
if (out_total) *out_total = 0;
PGconn* conn = postgres_db_active_connection();
if (!conn || PQstatus(conn) != CONNECTION_OK) return DB_ERROR;
PGresult* res = PQexec(conn,
"SELECT backfill_authors_complete, backfill_authors_total "
"FROM caching_service_state WHERE id = 1");
if (!res || PQresultStatus(res) != PGRES_TUPLES_OK) {
if (res) {
postgres_set_error_text(PQresultErrorMessage(res));
PQclear(res);
} else {
postgres_set_error_from_conn(conn, "postgres_db_caching_backfill_author_counts failed");
}
return DB_ERROR;
}
if (PQntuples(res) > 0) {
if (out_complete) {
*out_complete = PQgetisnull(res, 0, 0) ? 0
: (int)strtol(PQgetvalue(res, 0, 0), NULL, 10);
}
if (out_total) {
*out_total = PQgetisnull(res, 0, 1) ? 0
: (int)strtol(PQgetvalue(res, 0, 1), NULL, 10);
}
}
PQclear(res);
return DB_OK;
#else
if (out_complete) *out_complete = 0;
if (out_total) *out_total = 0;
return DB_ERROR;
#endif
}
/* ------------------------------------------------------------------ */
/* Profile metadata and outbox relay helpers */
/* ------------------------------------------------------------------ */
/* Returns the most recent kind-0 metadata for a pubkey as a cJSON object
* with name/picture/about/nip05/display_name fields parsed from the event
* content JSON. Returns NULL if no kind-0 event exists or on error.
* Caller must cJSON_Delete() the result. */
cJSON* postgres_db_get_profile_metadata(const char* pubkey) {
#if defined(DB_BACKEND_POSTGRES) && defined(HAVE_LIBPQ)
if (!pubkey || pubkey[0] == '\0') return NULL;
PGconn* conn = postgres_db_active_connection();
if (!conn || PQstatus(conn) != CONNECTION_OK) return NULL;
const char* params[1] = { pubkey };
PGresult* res = PQexecParams(conn,
"SELECT content FROM events WHERE pubkey = $1 AND kind = 0 "
"ORDER BY created_at DESC LIMIT 1",
1, NULL, params, NULL, NULL, 0);
if (!res || PQresultStatus(res) != PGRES_TUPLES_OK) {
if (res) PQclear(res);
return NULL;
}
cJSON* result = NULL;
if (PQntuples(res) > 0 && !PQgetisnull(res, 0, 0)) {
const char* content = PQgetvalue(res, 0, 0);
if (content && content[0] != '\0') {
/* Parse the kind-0 content JSON and extract known fields. */
cJSON* parsed = cJSON_Parse(content);
if (parsed) {
result = cJSON_CreateObject();
if (!result) {
cJSON_Delete(parsed);
PQclear(res);
return NULL;
}
/* Extract standard NIP-01 metadata fields. */
const char* fields[] = {"name", "display_name", "picture",
"about", "nip05", "website", "lud16", "lud06"};
for (size_t i = 0; i < sizeof(fields) / sizeof(fields[0]); i++) {
cJSON* val = cJSON_GetObjectItemCaseSensitive(parsed, fields[i]);
if (val && cJSON_IsString(val) && val->valuestring[0] != '\0') {
cJSON_AddStringToObject(result, fields[i], val->valuestring);
}
}
cJSON_Delete(parsed);
}
}
}
PQclear(res);
return result;
#else
(void)pubkey;
return NULL;
#endif
}
/* Returns relay URLs from the most recent kind-10002 event for a pubkey,
* as a cJSON array of strings. Returns NULL if no kind-10002 event exists
* or on error. Caller must cJSON_Delete() the result. */
cJSON* postgres_db_get_outbox_relays(const char* pubkey) {
#if defined(DB_BACKEND_POSTGRES) && defined(HAVE_LIBPQ)
if (!pubkey || pubkey[0] == '\0') return NULL;
PGconn* conn = postgres_db_active_connection();
if (!conn || PQstatus(conn) != CONNECTION_OK) return NULL;
const char* params[1] = { pubkey };
PGresult* res = PQexecParams(conn,
"SELECT tags FROM events WHERE pubkey = $1 AND kind = 10002 "
"ORDER BY created_at DESC LIMIT 1",
1, NULL, params, NULL, NULL, 0);
if (!res || PQresultStatus(res) != PGRES_TUPLES_OK) {
if (res) PQclear(res);
return NULL;
}
cJSON* result = NULL;
if (PQntuples(res) > 0 && !PQgetisnull(res, 0, 0)) {
const char* tags_json = PQgetvalue(res, 0, 0);
if (tags_json && tags_json[0] != '\0') {
cJSON* tags = cJSON_Parse(tags_json);
if (tags && cJSON_IsArray(tags)) {
result = cJSON_CreateArray();
if (!result) {
cJSON_Delete(tags);
PQclear(res);
return NULL;
}
int n = cJSON_GetArraySize(tags);
for (int i = 0; i < n; i++) {
cJSON* tag = cJSON_GetArrayItem(tags, i);
if (!tag || !cJSON_IsArray(tag)) continue;
int tag_len = cJSON_GetArraySize(tag);
if (tag_len < 2) continue;
cJSON* tag_name = cJSON_GetArrayItem(tag, 0);
cJSON* tag_val = cJSON_GetArrayItem(tag, 1);
if (tag_name && cJSON_IsString(tag_name) &&
strcmp(tag_name->valuestring, "r") == 0 &&
tag_val && cJSON_IsString(tag_val) &&
tag_val->valuestring[0] != '\0') {
/* Check for a marker (3rd element) - skip "read" only
* relays, keep "write" and unmarked (both) relays. */
int is_write = 1;
if (tag_len >= 3) {
cJSON* marker = cJSON_GetArrayItem(tag, 2);
if (marker && cJSON_IsString(marker) &&
strcmp(marker->valuestring, "read") == 0) {
is_write = 0;
}
}
if (is_write) {
cJSON_AddItemToArray(result,
cJSON_CreateString(tag_val->valuestring));
}
}
}
cJSON_Delete(tags);
}
}
}
PQclear(res);
return result;
#else
(void)pubkey;
return NULL;
#endif
}
+17
View File
@@ -19,6 +19,9 @@ void postgres_db_clear_thread_connection(void);
int postgres_db_open_worker_connection(const char* db_path, void** out_connection);
void postgres_db_close_worker_connection(void* connection);
int postgres_db_worker_listen(void* connection, const char* channel);
int postgres_db_worker_poll_notify(void* connection, int timeout_ms);
int postgres_db_prepare(const char* sql, postgres_db_stmt_t** out_stmt);
int postgres_db_bind_text_param(postgres_db_stmt_t* stmt, int index, const char* value);
int postgres_db_bind_int_param(postgres_db_stmt_t* stmt, int index, int value);
@@ -55,6 +58,7 @@ char* postgres_db_execute_readonly_query_json(const char* query, const char* req
int postgres_db_get_total_event_count_ll(long long* out_count);
int postgres_db_get_event_count_since(time_t cutoff, long long* out_count);
int postgres_db_get_storage_size_bytes(long long* out_size);
cJSON* postgres_db_get_event_kind_distribution_rows(long long* out_total_events);
cJSON* postgres_db_get_top_pubkeys_rows(int limit);
cJSON* postgres_db_get_subscription_details_rows(void);
@@ -94,4 +98,17 @@ int postgres_db_exec_sql(const char* sql);
int postgres_db_wal_checkpoint_passive(void);
int postgres_db_wal_checkpoint_truncate(void);
// Caching relay inbox helpers (PostgreSQL-only).
cJSON* postgres_db_caching_inbox_dequeue_batch(int batch_size, int* out_count);
int postgres_db_caching_inbox_pending_counts(int* out_live_count, int* out_backfill_count);
int postgres_db_caching_inbox_oldest_age(int* out_oldest_age_seconds);
int postgres_db_caching_backfill_author_counts(int* out_complete, int* out_total);
int postgres_db_caching_inbox_insert(const char* event_id, const char* event_json,
const char* source_relay, const char* source_class,
int priority);
// Profile metadata and outbox relay helpers.
cJSON* postgres_db_get_profile_metadata(const char* pubkey);
cJSON* postgres_db_get_outbox_relays(const char* pubkey);
#endif // DB_OPS_POSTGRES_H
+31
View File
@@ -8,6 +8,7 @@
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/stat.h>
// Database handle owned by sqlite_db_ops.
static sqlite3* g_db = NULL;
@@ -79,6 +80,15 @@ int sqlite_db_open_worker_connection(const char* sqlite_db_path, void** out_conn
return 0;
}
// SQLite has no LISTEN/NOTIFY mechanism; stubs return -1 (unsupported) so the
// api-worker falls back to timer-based polling.
int sqlite_db_worker_listen(void* connection, const char* channel) {
(void)connection; (void)channel; return -1;
}
int sqlite_db_worker_poll_notify(void* connection, int timeout_ms) {
(void)connection; (void)timeout_ms; return -1;
}
void sqlite_db_close_worker_connection(void* connection) {
if (!connection) return;
sqlite_db_clear_thread_connection();
@@ -661,6 +671,27 @@ int sqlite_db_get_event_count_since(time_t cutoff, long long* out_count) {
return 0;
}
int sqlite_db_get_storage_size_bytes(long long* out_size) {
sqlite3* db = sqlite_db_active_connection();
const char* db_path = sqlite_db_get_database_path();
if (!db || !out_size || !db_path || db_path[0] == '\0') return -1;
long long total_size = 0;
struct stat db_stat;
if (stat(db_path, &db_stat) == 0) {
total_size += (long long)db_stat.st_size;
}
char wal_path[1024];
snprintf(wal_path, sizeof(wal_path), "%s-wal", db_path);
if (stat(wal_path, &db_stat) == 0) {
total_size += (long long)db_stat.st_size;
}
*out_size = total_size;
return 0;
}
cJSON* sqlite_db_get_event_kind_distribution_rows(long long* out_total_events) {
sqlite3* db = sqlite_db_active_connection();
if (!db) return NULL;
+37 -1
View File
@@ -128,7 +128,43 @@ static const struct {
// NIP-17 Admin DM Settings
// When false (default), Kind 1059 gift wrap events are stored normally without expensive decryption attempts.
// Enable only if you intend to use NIP-17 DMs to send admin commands to the relay.
{"nip17_admin_enabled", "false"}
{"nip17_admin_enabled", "false"},
// Thread Pool Settings
{"thread_pool_enabled", "true"},
{"thread_pool_readers", "4"},
{"thread_pool_writers", "2"},
{"thread_pool_max_queue_depth", "4096"},
// Caching Relay Settings
// External caching service configuration. All caching_ keys are dynamic (no restart required).
{"caching_enabled", "false"}, // Enable caching inbox consumer
{"caching_config_generation", "0"}, // Configuration generation counter for external service
{"caching_root_npubs", "npub13lm5wf8dvsdnc2894pkhch9uf8phvw9varrv8zf4sc885hhdmc8q6lx7ks"}, // Comma-separated root npubs to follow (default: admin npub)
{"caching_bootstrap_relays", "wss://relay.damus.io,wss://nos.lol,wss://relay.primal.net,wss://laantungir.net/relay"}, // Comma-separated bootstrap relay URLs
{"caching_kinds", "0,1,3,6,10000,10002,30023"}, // Event kinds to cache for non-root follows
{"caching_admin_kinds", "*"}, // Event kinds for root npubs (* = all)
{"caching_live_enabled", "true"}, // Enable live subscriptions
{"caching_live_resubscribe_seconds", "300"}, // Live subscription resubscribe interval
{"caching_backfill_enabled", "true"}, // Enable historical backfill
{"caching_backfill_windows", "86400,604800,2592000,7776000,31536000"}, // Backfill window schedule in seconds
{"caching_backfill_page_size", "500"}, // Initial backfill query page size
{"caching_backfill_tick_interval_ms", "5000"}, // Minimum delay between backfill queries
{"caching_backfill_window_cooldown_seconds", "60"}, // Delay between completed windows
{"caching_follow_graph_refresh_seconds", "600"}, // Kind-3 follow graph refresh interval
{"caching_relay_discovery_refresh_seconds", "3600"}, // Kind-10002 relay discovery refresh interval
{"caching_max_followed_pubkeys", "5000"}, // Maximum followed author count
{"caching_max_upstream_relays", "32"}, // Maximum upstream relay count
{"caching_max_relays_per_pubkey", "5"}, // Maximum outbox relays per author
{"caching_query_timeout_ms", "15000"}, // Upstream query timeout
{"caching_inbox_enabled", "false"}, // Enable inbox poller in c-relay-pg
{"caching_inbox_batch_size", "150"}, // Inbox dequeue batch size
{"caching_inbox_active_poll_ms", "200"}, // Poll interval when inbox has events
{"caching_inbox_idle_poll_ms", "5000"}, // Poll interval when inbox is empty
{"caching_max_event_json_bytes", "65536"}, // Maximum event JSON size for inbox insert
{"caching_service_binary_path", "./caching_relay"}, // Path to caching_relay binary (relative to relay CWD which is build/)
{"caching_service_pg_conn", "host=localhost port=5432 dbname=crelay user=crelay password=crelay"}, // PostgreSQL connection string for caching service (PG-inbox mode)
{"caching_service_launch_mode", "fork"} // Launch mode: "fork" or "systemd" (systemd not yet implemented)
};
// Number of default configuration values
+1 -2
View File
@@ -501,8 +501,7 @@ int process_nip17_admin_command(cJSON* dm_event, char* error_message, size_t err
const char* sender_pubkey = cJSON_GetStringValue(sender_pubkey_obj);
// Check if sender is admin
const char* admin_pubkey = get_config_value("admin_pubkey");
int is_admin = admin_pubkey && strlen(admin_pubkey) > 0 && strcmp(sender_pubkey, admin_pubkey) == 0;
int is_admin = is_admin_pubkey(sender_pubkey);
DEBUG_INFO("DM_ADMIN: Sender pubkey: %.16s... (admin: %s)", sender_pubkey, is_admin ? "YES" : "NO");
File diff suppressed because one or more lines are too long
+573 -75
View File
@@ -27,6 +27,8 @@
#include "debug.h" // Debug system
#include "thread_pool.h" // Thread pool scaffold
#include "db_ops.h"
#include "caching_inbox_poller.h" // Caching inbox poller (PostgreSQL-only)
#include "caching_service_launcher.h" // Caching service process launcher
// Forward declarations for unified request validator
int nostr_validate_unified_request(const char* json_string, size_t json_length);
@@ -453,9 +455,6 @@ int mark_event_as_deleted(const char* event_id, const char* deletion_event_id, c
int store_event(cJSON* event);
cJSON* retrieve_event(const char* event_id);
// Forward declaration for monitoring system
void monitoring_on_event_stored(void);
// Forward declarations for NIP-11 relay information handling
void init_relay_info();
void cleanup_relay_info();
@@ -1163,22 +1162,17 @@ void store_event_post_actions(cJSON* event) {
return;
}
// Call monitoring hook after successful event storage
monitoring_on_event_stored();
// Check if this is a kind 3 event from the admin — trigger WoT sync
cJSON* kind_obj = cJSON_GetObjectItemCaseSensitive(event, "kind");
cJSON* pubkey_obj = cJSON_GetObjectItemCaseSensitive(event, "pubkey");
if (kind_obj && pubkey_obj && cJSON_GetNumberValue(kind_obj) == 3) {
int wot_level = get_config_int("wot_enabled", 0);
if (wot_level > 0) {
const char* admin_pubkey = get_config_value("admin_pubkey");
if (admin_pubkey && strcmp(cJSON_GetStringValue(pubkey_obj), admin_pubkey) == 0) {
if (is_admin_pubkey(cJSON_GetStringValue(pubkey_obj))) {
DEBUG_INFO("Admin kind 3 event stored — triggering WoT sync");
extern int wot_sync_from_admin_kind3(void);
wot_sync_from_admin_kind3();
}
if (admin_pubkey) free((char*)admin_pubkey);
}
}
}
@@ -1208,6 +1202,349 @@ int event_id_exists_in_db(const char* event_id) {
return exists;
}
/////////////////////////////////////////////////////////////////////////////////////////
// Caching inbox ingestion (PostgreSQL-only feature)
//
// The caching inbox path imports events acquired by the external caching
// application through the same authoritative validation and storage pipeline
// used for client WebSocket events, but with a different source mode that:
// - bypasses NIP-42 client-session authentication requirements;
// - never sends an OK response (no wsi/pss);
// - never executes administrator commands (kind 23456 is stored as data);
// - still performs signature/structure/expiration/PoW validation;
// - still handles replaceable/addressable/ephemeral/duplicate/NIP-09 semantics;
// - still stores through the writer thread pool;
// - still queues post-actions and broadcast to the main lws thread.
//
// The completion queue below mirrors the async event completion pattern in
// websockets.c. ingest_event() performs validation + storage off the main
// thread (it is invoked by the inbox poller) and pushes a completion record;
// process_inbox_event_completions() drains that queue on the lws service thread
// to run store_event_post_actions() and broadcast_event_to_subscriptions().
/////////////////////////////////////////////////////////////////////////////////////////
typedef struct inbox_event_completion {
char* event_json; // owned copy of the event JSON (for re-parse on main thread)
char event_id[65]; // event id for logging
int success; // 1 = accepted, 0 = rejected
int should_broadcast; // 1 = broadcast to subscriptions on main thread
int run_post_actions; // 1 = run store_event_post_actions() on main thread
struct inbox_event_completion* next;
} inbox_event_completion_t;
static pthread_mutex_t g_inbox_completion_mutex = PTHREAD_MUTEX_INITIALIZER;
static inbox_event_completion_t* g_inbox_completion_head = NULL;
static inbox_event_completion_t* g_inbox_completion_tail = NULL;
// Cumulative import counters (best-effort; not persisted).
static long long g_inbox_import_accepted = 0;
static long long g_inbox_import_rejected = 0;
static long long g_inbox_import_duplicates = 0;
static void inbox_event_completion_push(inbox_event_completion_t* completion) {
if (!completion) return;
pthread_mutex_lock(&g_inbox_completion_mutex);
completion->next = NULL;
if (!g_inbox_completion_tail) {
g_inbox_completion_head = completion;
g_inbox_completion_tail = completion;
} else {
g_inbox_completion_tail->next = completion;
g_inbox_completion_tail = completion;
}
pthread_mutex_unlock(&g_inbox_completion_mutex);
}
static inbox_event_completion_t* inbox_event_completion_pop(void) {
pthread_mutex_lock(&g_inbox_completion_mutex);
inbox_event_completion_t* completion = g_inbox_completion_head;
if (completion) {
g_inbox_completion_head = completion->next;
if (!g_inbox_completion_head) {
g_inbox_completion_tail = NULL;
}
}
pthread_mutex_unlock(&g_inbox_completion_mutex);
return completion;
}
// Drain caching-inbox ingestion completions on the lws service thread.
// Runs main-thread-only post-actions and broadcasts newly stored events.
void process_inbox_event_completions(void) {
#ifndef DB_BACKEND_POSTGRES
return;
#else
inbox_event_completion_t* completion = NULL;
while ((completion = inbox_event_completion_pop()) != NULL) {
if (completion->success && (completion->run_post_actions || completion->should_broadcast)) {
cJSON* event_obj = cJSON_Parse(completion->event_json);
if (event_obj && cJSON_IsObject(event_obj)) {
if (completion->run_post_actions) {
store_event_post_actions(event_obj);
}
if (completion->should_broadcast) {
broadcast_event_to_subscriptions(event_obj);
}
cJSON_Delete(event_obj);
} else {
DEBUG_WARN("inbox completion: failed to re-parse event %s for post-actions",
completion->event_id);
if (event_obj) {
cJSON_Delete(event_obj);
}
}
}
free(completion->event_json);
free(completion);
}
#endif /* DB_BACKEND_POSTGRES */
}
// Shared internal ingestion entry point for client events and caching inbox events.
//
// See the contract documented in main.h. This function does NOT replace the
// existing client WebSocket path in websockets.c; it is the new shared entry
// point used by the caching inbox poller (EVENT_SOURCE_CACHING_INBOX) and is
// available for future refactor of the client path (EVENT_SOURCE_CLIENT).
//
// The caching inbox path is compiled only for the PostgreSQL backend. For the
// SQLite backend, ingest_event() with EVENT_SOURCE_CACHING_INBOX returns -1.
int ingest_event(const char* event_json, size_t event_json_len,
event_source_t source, struct lws* wsi, void* pss) {
(void)wsi;
(void)pss;
if (!event_json || event_json_len == 0) {
DEBUG_WARN("ingest_event: null or empty event_json");
return -1;
}
// Caching inbox import is a PostgreSQL-only feature.
if (source == EVENT_SOURCE_CACHING_INBOX) {
#ifndef DB_BACKEND_POSTGRES
DEBUG_WARN("ingest_event: caching inbox source requires PostgreSQL backend");
return -1;
#else
// Caching inbox events never have a client session.
if (wsi || pss) {
DEBUG_WARN("ingest_event: caching inbox source must not carry wsi/pss");
return -1;
}
#endif
}
// Fast duplicate check before expensive crypto. Only meaningful when the
// canonical DB is available; mirrors the async event worker optimization.
cJSON* peek = cJSON_ParseWithLength(event_json, event_json_len);
const char* peek_id = NULL;
if (peek && cJSON_IsObject(peek)) {
cJSON* id_obj = cJSON_GetObjectItemCaseSensitive(peek, "id");
if (id_obj && cJSON_IsString(id_obj)) {
peek_id = cJSON_GetStringValue(id_obj);
}
}
char event_id_buf[65] = {0};
if (peek_id && strlen(peek_id) == 64) {
strncpy(event_id_buf, peek_id, 64);
event_id_buf[64] = '\0';
if (event_id_exists_in_db(event_id_buf)) {
DEBUG_TRACE("ingest_event: duplicate event %s already in canonical DB", event_id_buf);
if (peek) {
cJSON_Delete(peek);
}
if (source == EVENT_SOURCE_CACHING_INBOX) {
#ifdef DB_BACKEND_POSTGRES
__sync_fetch_and_add(&g_inbox_import_duplicates, 1);
#endif
}
// Duplicates are accepted (already have the event).
return 0;
}
}
if (peek) {
cJSON_Delete(peek);
peek = NULL;
}
// Authoritative validation: signature, structure, expiration, PoW.
int validation_result = nostr_validate_unified_request(event_json, event_json_len);
if (validation_result != NOSTR_SUCCESS) {
if (source == EVENT_SOURCE_CACHING_INBOX) {
#ifdef DB_BACKEND_POSTGRES
__sync_fetch_and_add(&g_inbox_import_rejected, 1);
#endif
DEBUG_WARN("ingest_event: caching inbox event %s rejected by validator (rc=%d)",
event_id_buf[0] ? event_id_buf : "?", validation_result);
}
return -1;
}
// Parse the validated event for classification, storage, and broadcast.
cJSON* event_obj = cJSON_ParseWithLength(event_json, event_json_len);
if (!event_obj || !cJSON_IsObject(event_obj)) {
if (event_obj) {
cJSON_Delete(event_obj);
}
if (source == EVENT_SOURCE_CACHING_INBOX) {
#ifdef DB_BACKEND_POSTGRES
__sync_fetch_and_add(&g_inbox_import_rejected, 1);
#endif
DEBUG_WARN("ingest_event: caching inbox event failed to parse after validation");
}
return -1;
}
// Determine kind for source-specific routing.
cJSON* kind_obj = cJSON_GetObjectItemCaseSensitive(event_obj, "kind");
int event_kind = (kind_obj && cJSON_IsNumber(kind_obj))
? (int)cJSON_GetNumberValue(kind_obj)
: -1;
int result = 0; // 0 = accepted, -1 = rejected
int should_broadcast = 0;
int run_post_actions = 0;
int is_duplicate = 0;
if (source == EVENT_SOURCE_CACHING_INBOX) {
// Caching inbox: never execute administrator commands. Kind 23456 events
// are stored as ordinary data so the admin API history is preserved
// without triggering command processing.
if (event_kind == 23456) {
DEBUG_LOG("ingest_event: caching inbox kind 23456 stored as data (no admin execution)");
}
// NIP-09 deletion requests are handled through the same deletion path
// used by client events. handle_deletion_request() performs the
// authorization check (deletion requester must own the target events).
if (event_kind == 5) {
char del_error[512] = {0};
int del_rc = handle_deletion_request(event_obj, del_error, sizeof(del_error));
if (del_rc != 0) {
DEBUG_WARN("ingest_event: caching inbox NIP-09 deletion rejected: %s",
del_error);
result = -1;
} else {
// Deletion request event itself is stored as a regular event,
// matching client behavior (store_event() is called by the
// client path's regular-event branch for kind 5 via the
// generic store path). Broadcast the deletion request so
// clients can react; no separate post-actions needed beyond
// what store_event_core() already did.
int core_rc = store_event_core(event_obj);
if (core_rc < 0) {
result = -1;
} else {
should_broadcast = 1;
run_post_actions = (core_rc == 0);
if (core_rc == 1) {
is_duplicate = 1;
}
}
}
} else if (event_kind >= 20000 && event_kind < 30000) {
// Ephemeral events: validate but do not store; broadcast only.
DEBUG_TRACE("ingest_event: caching inbox ephemeral kind %d - broadcast only", event_kind);
should_broadcast = 1;
run_post_actions = 0;
} else {
// Regular / replaceable / addressable event: store through the
// writer thread pool via store_event_core().
int core_rc = store_event_core(event_obj);
if (core_rc < 0) {
DEBUG_WARN("ingest_event: caching inbox store_event_core failed for kind %d",
event_kind);
result = -1;
} else {
should_broadcast = 1;
run_post_actions = (core_rc == 0);
if (core_rc == 1) {
is_duplicate = 1;
}
}
}
} else {
// EVENT_SOURCE_CLIENT: this shared entry point is not yet wired into
// the client WebSocket path (the existing code in websockets.c remains
// authoritative). If invoked, apply the same regular storage path so
// the function is self-consistent and ready for future refactor.
if (event_kind == 5) {
char del_error[512] = {0};
int del_rc = handle_deletion_request(event_obj, del_error, sizeof(del_error));
if (del_rc != 0) {
result = -1;
} else {
int core_rc = store_event_core(event_obj);
if (core_rc < 0) {
result = -1;
} else {
should_broadcast = 1;
run_post_actions = (core_rc == 0);
if (core_rc == 1) {
is_duplicate = 1;
}
}
}
} else if (event_kind >= 20000 && event_kind < 30000) {
should_broadcast = 1;
run_post_actions = 0;
} else {
int core_rc = store_event_core(event_obj);
if (core_rc < 0) {
result = -1;
} else {
should_broadcast = 1;
run_post_actions = (core_rc == 0);
if (core_rc == 1) {
is_duplicate = 1;
}
}
}
}
// For the caching inbox path, queue post-actions and broadcast to the main
// lws thread. The client path currently sends OK responses inline from
// websockets.c and is not routed through here yet.
if (source == EVENT_SOURCE_CACHING_INBOX) {
#ifdef DB_BACKEND_POSTGRES
if (result == 0) {
inbox_event_completion_t* completion = calloc(1, sizeof(*completion));
if (completion) {
completion->event_json = strndup(event_json, event_json_len);
if (!completion->event_json) {
free(completion);
} else {
strncpy(completion->event_id, event_id_buf,
sizeof(completion->event_id) - 1);
completion->event_id[sizeof(completion->event_id) - 1] = '\0';
completion->success = 1;
// Duplicates and stale replacements are not broadcast.
completion->should_broadcast = is_duplicate ? 0 : should_broadcast;
completion->run_post_actions = is_duplicate ? 0 : run_post_actions;
inbox_event_completion_push(completion);
}
}
// Wake the lws service loop so the completion is drained promptly.
if (ws_context) {
lws_cancel_service(ws_context);
}
if (is_duplicate) {
__sync_fetch_and_add(&g_inbox_import_duplicates, 1);
} else {
__sync_fetch_and_add(&g_inbox_import_accepted, 1);
}
} else {
__sync_fetch_and_add(&g_inbox_import_rejected, 1);
}
#endif /* DB_BACKEND_POSTGRES */
}
cJSON_Delete(event_obj);
return result;
}
// Populate event_tags from existing events (run once at startup)
int populate_event_tags_from_existing(void) {
return db_populate_event_tags_from_existing();
@@ -1517,11 +1854,21 @@ int handle_req_message(const char* sub_id, cJSON* filters, struct lws *wsi, stru
// Phase 3: push expiration filtering into SQL using indexed event_tags table.
if (expiration_enabled && filter_responses) {
#ifdef DB_BACKEND_POSTGRES
// Guard CAST with numeric check to avoid PostgreSQL errors on malformed expiration values.
snprintf(sql_ptr, remaining,
" AND NOT EXISTS (SELECT 1 FROM event_tags et_exp "
"WHERE et_exp.event_id = events.id "
"AND et_exp.tag_name = ? "
"AND et_exp.tag_value ~ '^[0-9]+$' "
"AND CAST(et_exp.tag_value AS BIGINT) <= ?::BIGINT)");
#else
snprintf(sql_ptr, remaining,
" AND NOT EXISTS (SELECT 1 FROM event_tags et_exp "
"WHERE et_exp.event_id = events.id "
"AND et_exp.tag_name = ? "
"AND CAST(et_exp.tag_value AS INTEGER) <= ?)");
#endif
sql_ptr += strlen(sql_ptr);
remaining = sizeof(sql) - strlen(sql);
add_bind_param(&bind_params, &bind_param_count, &bind_param_capacity, "expiration");
@@ -1892,25 +2239,21 @@ int is_authorized_admin_event(cJSON* event, char* error_buffer, size_t error_buf
return -1;
}
// Get admin pubkey from configuration
const char* admin_pubkey = get_config_value("admin_pubkey");
if (!admin_pubkey || strlen(admin_pubkey) == 0) {
// Verify sender is one of configured admins
if (get_admin_pubkey_count() <= 0) {
DEBUG_WARN("Unauthorized admin event attempt: no admin pubkey configured");
snprintf(error_buffer, error_buffer_size, "Unauthorized admin event attempt: no admin configured");
if (admin_pubkey) free((char*)admin_pubkey);
return -1;
}
// Compare pubkeys
if (strcmp(pubkey_json->valuestring, admin_pubkey) != 0) {
if (!is_admin_pubkey(pubkey_json->valuestring)) {
DEBUG_WARN("Unauthorized admin event attempt: pubkey mismatch");
char warning_msg[256];
snprintf(warning_msg, sizeof(warning_msg),
"Unauthorized admin event attempt from pubkey: %.32s...", pubkey_json->valuestring);
DEBUG_WARN(warning_msg);
DEBUG_INFO("DEBUG: Pubkey comparison failed - event pubkey != admin pubkey");
DEBUG_INFO("DEBUG: Pubkey comparison failed - event pubkey not in admin list");
snprintf(error_buffer, error_buffer_size, "Unauthorized admin event attempt: invalid admin pubkey");
free((char*)admin_pubkey);
return -1;
}
@@ -1919,12 +2262,9 @@ int is_authorized_admin_event(cJSON* event, char* error_buffer, size_t error_buf
if (nostr_verify_event_signature(event) != 0) {
DEBUG_WARN("Unauthorized admin event attempt: invalid signature");
snprintf(error_buffer, error_buffer_size, "Unauthorized admin event attempt: signature verification failed");
free((char*)admin_pubkey);
return -1;
}
free((char*)admin_pubkey);
// All checks passed - authorized admin event
return 0;
}
@@ -1949,7 +2289,7 @@ void print_usage(const char* program_name) {
printf(" -v, --version Show version information\n");
printf(" -p, --port PORT Override relay port (first-time startup and existing relay restarts)\n");
printf(" --strict-port Fail if exact port is unavailable (no port increment)\n");
printf(" -a, --admin-pubkey KEY Override admin public key (64-char hex or npub)\n");
printf(" -a, --admin-pubkey KEYS Override admin public key(s) (64-char hex or npub, comma-separated)\n");
printf(" -r, --relay-privkey KEY Override relay private key (64-char hex or nsec)\n");
printf(" --debug-level=N Set debug output level (0-5, default: 0)\n");
printf(" 0=none, 1=errors, 2=warnings, 3=info, 4=debug, 5=trace\n");
@@ -1959,6 +2299,8 @@ void print_usage(const char* program_name) {
printf(" --db-name NAME PostgreSQL database name (default: crelay)\n");
printf(" --db-user USER PostgreSQL user (default: crelay)\n");
printf(" --db-password PASS PostgreSQL password\n");
printf(" --start-caching Auto-start the caching service on startup\n");
printf(" --reset-backfill Clear caching_backfill_progress before starting (use with --start-caching)\n");
printf("\n");
printf("Configuration:\n");
printf(" This relay uses event-based configuration stored in the database.\n");
@@ -1998,7 +2340,9 @@ int main(int argc, char* argv[]) {
.admin_pubkey_override = {0}, // Empty string = not set
.relay_privkey_override = {0}, // Empty string = not set
.strict_port = 0, // 0 = allow port increment (default)
.debug_level = 0 // 0 = no debug output (default)
.debug_level = 0, // 0 = no debug output (default)
.start_caching = 0, // 0 = don't auto-start caching service
.reset_backfill = 0 // 0 = don't reset backfill progress
};
// Parse command line arguments
@@ -2040,25 +2384,92 @@ int main(int argc, char* argv[]) {
return 1;
}
const char* input_key = argv[i + 1];
char decoded_key[65] = {0}; // Buffer for decoded hex key
const char* input_keys = argv[i + 1];
char decoded_keys[ADMIN_PUBKEY_LIST_MAX_LENGTH] = {0};
// Try to decode the input as either hex or npub format
unsigned char pubkey_bytes[32];
if (nostr_decode_npub(input_key, pubkey_bytes) == NOSTR_SUCCESS) {
// Convert bytes back to hex string
char* hex_ptr = decoded_key;
for (int j = 0; j < 32; j++) {
sprintf(hex_ptr, "%02x", pubkey_bytes[j]);
hex_ptr += 2;
char* keys_copy = strdup(input_keys);
if (!keys_copy) {
DEBUG_ERROR("Failed to allocate memory for admin pubkey parsing.");
return 1;
}
int key_count = 0;
char* saveptr = NULL;
char* token = strtok_r(keys_copy, ",", &saveptr);
while (token) {
// Trim leading/trailing whitespace in each comma-separated token
while (*token == ' ' || *token == '\t' || *token == '\n' || *token == '\r') token++;
char* token_end = token + strlen(token);
while (token_end > token &&
(token_end[-1] == ' ' || token_end[-1] == '\t' || token_end[-1] == '\n' || token_end[-1] == '\r')) {
*--token_end = '\0';
}
} else {
DEBUG_ERROR("Invalid admin public key format. Must be 64 hex characters or valid npub format.");
if (*token == '\0') {
token = strtok_r(NULL, ",", &saveptr);
continue;
}
char decoded_key[65] = {0};
unsigned char pubkey_bytes[32];
if (nostr_decode_npub(token, pubkey_bytes) == NOSTR_SUCCESS) {
char* hex_ptr = decoded_key;
for (int j = 0; j < 32; j++) {
sprintf(hex_ptr, "%02x", pubkey_bytes[j]);
hex_ptr += 2;
}
} else {
// Fallback: accept raw 64-char hex pubkey input as well
unsigned char hex_pubkey_bytes[32];
if (strlen(token) == 64 &&
nostr_hex_to_bytes(token, hex_pubkey_bytes, 32) == NOSTR_SUCCESS) {
char* hex_ptr = decoded_key;
for (int j = 0; j < 32; j++) {
sprintf(hex_ptr, "%02x", hex_pubkey_bytes[j]);
hex_ptr += 2;
}
} else {
free(keys_copy);
DEBUG_ERROR("Invalid admin public key format. Must be 64 hex characters or valid npub format.");
print_usage(argv[0]);
return 1;
}
}
size_t current_len = strlen(decoded_keys);
size_t append_len = strlen(decoded_key) + (current_len > 0 ? 1 : 0);
if (current_len + append_len >= sizeof(decoded_keys)) {
free(keys_copy);
DEBUG_ERROR("Too many admin public keys provided (max %d).", MAX_ADMIN_PUBKEYS);
print_usage(argv[0]);
return 1;
}
if (current_len > 0) {
strcat(decoded_keys, ",");
}
strcat(decoded_keys, decoded_key);
key_count++;
if (key_count > MAX_ADMIN_PUBKEYS) {
free(keys_copy);
DEBUG_ERROR("Too many admin public keys provided (max %d).", MAX_ADMIN_PUBKEYS);
print_usage(argv[0]);
return 1;
}
token = strtok_r(NULL, ",", &saveptr);
}
free(keys_copy);
if (key_count == 0) {
DEBUG_ERROR("No valid admin public key provided.");
print_usage(argv[0]);
return 1;
}
strncpy(cli_options.admin_pubkey_override, decoded_key, sizeof(cli_options.admin_pubkey_override) - 1);
strncpy(cli_options.admin_pubkey_override, decoded_keys, sizeof(cli_options.admin_pubkey_override) - 1);
cli_options.admin_pubkey_override[sizeof(cli_options.admin_pubkey_override) - 1] = '\0';
i++; // Skip the key argument
@@ -2158,6 +2569,12 @@ int main(int argc, char* argv[]) {
} else if (strcmp(argv[i], "--strict-port") == 0) {
// Strict port mode option
cli_options.strict_port = 1;
} else if (strcmp(argv[i], "--start-caching") == 0) {
// Auto-start the caching service on startup
cli_options.start_caching = 1;
} else if (strcmp(argv[i], "--reset-backfill") == 0) {
// Clear caching_backfill_progress before starting caching service
cli_options.reset_backfill = 1;
} else if (strncmp(argv[i], "--debug-level=", 14) == 0) {
// Debug level option
char* endptr;
@@ -2208,7 +2625,13 @@ int main(int argc, char* argv[]) {
DEBUG_INFO("Nostr callback logging initialized at level %d", (int)nostr_level);
// Check if this is first-time startup or existing relay
if (is_first_time_startup()) {
char startup_db_target[1024] = {0};
const char* startup_probe_target = NULL;
if (build_db_connection_override(&cli_options, startup_db_target, sizeof(startup_db_target))) {
startup_probe_target = startup_db_target;
}
if (is_first_time_startup(startup_probe_target)) {
DEBUG_LOG("First-time startup detected");
// Initialize event-based configuration system
@@ -2219,7 +2642,7 @@ int main(int argc, char* argv[]) {
}
// Run first-time startup sequence (generates keys, sets up database path, but doesn't store private key yet)
char admin_pubkey[65] = {0};
char admin_pubkey[ADMIN_PUBKEY_LIST_MAX_LENGTH] = {0};
char relay_pubkey[65] = {0};
char relay_privkey[65] = {0};
if (first_time_startup_sequence(&cli_options, admin_pubkey, relay_pubkey, relay_privkey) != 0) {
@@ -2295,14 +2718,38 @@ int main(int argc, char* argv[]) {
} else {
// Initialize event-based configuration system
if (init_configuration_system(NULL, NULL) != 0) {
DEBUG_ERROR("Failed to initialize event-based configuration system");
nostr_cleanup();
return 1;
}
#ifdef DB_BACKEND_POSTGRES
// PostgreSQL existing relay: use configured connection target directly.
DEBUG_TRACE("Initializing existing PostgreSQL database");
char db_connection_override[1024] = {0};
const char* init_db_target = g_database_path;
if (build_db_connection_override(&cli_options, db_connection_override, sizeof(db_connection_override))) {
init_db_target = db_connection_override;
}
if (init_database(init_db_target) != 0) {
DEBUG_ERROR("Failed to initialize existing PostgreSQL database");
cleanup_configuration_system();
nostr_cleanup();
return 1;
}
DEBUG_LOG("Existing PostgreSQL database initialized");
#else
// Find existing database file
char** existing_files = find_existing_db_files();
if (!existing_files || !existing_files[0]) {
DEBUG_ERROR("No existing relay database found");
cleanup_configuration_system();
nostr_cleanup();
return 1;
}
// Extract relay pubkey from filename
char* relay_pubkey = extract_pubkey_from_filename(existing_files[0]);
if (!relay_pubkey) {
@@ -2312,22 +2759,11 @@ int main(int argc, char* argv[]) {
free(existing_files[i]);
}
free(existing_files);
cleanup_configuration_system();
nostr_cleanup();
return 1;
}
// Initialize event-based configuration system
if (init_configuration_system(NULL, NULL) != 0) {
DEBUG_ERROR("Failed to initialize event-based configuration system");
free(relay_pubkey);
for (int i = 0; existing_files[i]; i++) {
free(existing_files[i]);
}
free(existing_files);
nostr_cleanup();
return 1;
}
// Setup existing relay FIRST (sets database path)
if (startup_existing_relay(relay_pubkey, &cli_options) != 0) {
DEBUG_ERROR("Failed to setup existing relay");
@@ -2361,15 +2797,18 @@ int main(int argc, char* argv[]) {
}
DEBUG_LOG("Existing database initialized");
// Free memory
free(relay_pubkey);
for (int i = 0; existing_files[i]; i++) {
free(existing_files[i]);
}
free(existing_files);
#endif
// Keep relay_version in sync with the compiled binary version on every startup
if (update_config_in_table("relay_version", CRELAY_VERSION) != 0) {
DEBUG_ERROR("Failed to synchronize relay_version with compiled CRELAY_VERSION");
cleanup_configuration_system();
free(relay_pubkey);
for (int i = 0; existing_files[i]; i++) {
free(existing_files[i]);
}
free(existing_files);
nostr_cleanup();
close_database();
return 1;
@@ -2380,11 +2819,6 @@ int main(int argc, char* argv[]) {
if (apply_cli_overrides_atomic(&cli_options) != 0) {
DEBUG_ERROR("Failed to apply CLI overrides for existing relay");
cleanup_configuration_system();
free(relay_pubkey);
for (int i = 0; existing_files[i]; i++) {
free(existing_files[i]);
}
free(existing_files);
nostr_cleanup();
close_database();
return 1;
@@ -2397,13 +2831,6 @@ int main(int argc, char* argv[]) {
DEBUG_LOG("Config table row count after init_database(): %d", row_count);
}
}
// Free memory
free(relay_pubkey);
for (int i = 0; existing_files[i]; i++) {
free(existing_files[i]);
}
free(existing_files);
}
// Verify database is now available
@@ -2426,11 +2853,9 @@ int main(int argc, char* argv[]) {
}
const char* prewarmed_relay_pubkey = get_config_value("relay_pubkey");
const char* prewarmed_admin_pubkey = get_config_value("admin_pubkey");
if (!prewarmed_relay_pubkey || strlen(prewarmed_relay_pubkey) != 64 ||
!prewarmed_admin_pubkey || strlen(prewarmed_admin_pubkey) != 64) {
reload_admin_pubkeys_cache() != 0 || get_admin_pubkey_count() <= 0) {
if (prewarmed_relay_pubkey) free((char*)prewarmed_relay_pubkey);
if (prewarmed_admin_pubkey) free((char*)prewarmed_admin_pubkey);
DEBUG_ERROR("Critical config pre-warm validation failed (relay_pubkey/admin_pubkey missing)");
cleanup_configuration_system();
nostr_cleanup();
@@ -2438,7 +2863,6 @@ int main(int argc, char* argv[]) {
return 1;
}
free((char*)prewarmed_relay_pubkey);
free((char*)prewarmed_admin_pubkey);
// Preload relay private key runtime cache while DB is still available.
// Required for admin command decrypt/encrypt after strict mode sets g_db = NULL.
@@ -2497,8 +2921,27 @@ int main(int argc, char* argv[]) {
// Initialize kind-based index for fast subscription lookup
init_kind_index();
// Populate event_tags from existing events (for tag-based lookups)
populate_event_tags_from_existing();
// Populate event_tags from existing events (for tag-based lookups).
// This is a one-time backfill guarded by a config flag so it does NOT
// run on every startup — on large databases (tens of GB) the
// CROSS JOIN LATERAL jsonb_array_elements over every event can take a
// very long time and blocks the relay from binding its WebSocket port.
{
char* tags_backfilled = db_get_config_value_dup("event_tags_backfilled");
if (!tags_backfilled || strcmp(tags_backfilled, "true") != 0) {
DEBUG_INFO("One-time event_tags backfill: populating from existing events...");
populate_event_tags_from_existing();
/* Record completion in the config table so we skip it on future
* startups. db_set_config_value_full(key, value, type, ...). */
db_set_config_value_full("event_tags_backfilled", "true", "boolean",
"Internal flag: event_tags backfill completed",
"internal", 0);
DEBUG_INFO("event_tags backfill complete.");
} else {
DEBUG_LOG("event_tags backfill already done, skipping.");
}
if (tags_backfilled) free(tags_backfilled);
}
// Sync Web of Trust whitelist if enabled
int wot_level = get_config_int("wot_enabled", 0);
@@ -2521,7 +2964,8 @@ int main(int argc, char* argv[]) {
if (thread_pool_enabled) {
thread_pool_config_t tp_cfg;
memset(&tp_cfg, 0, sizeof(tp_cfg));
tp_cfg.reader_threads = get_config_int("thread_pool_readers", 1);
tp_cfg.reader_threads = get_config_int("thread_pool_readers", 4);
tp_cfg.writer_threads = get_config_int("thread_pool_writers", 2);
tp_cfg.max_queue_depth = get_config_int("thread_pool_max_queue_depth", 4096);
// Use active DB backend path/connection target.
// For PostgreSQL this is the libpq connection string, not g_database_path (.db filename).
@@ -2537,6 +2981,54 @@ int main(int argc, char* argv[]) {
}
}
// Initialize the caching inbox poller (PostgreSQL-only; no-op for SQLite).
// Runs on the main lws service thread via caching_inbox_poller_tick().
caching_inbox_poller_init();
// Auto-start the caching service if requested via --start-caching.
// Optionally reset backfill progress first if --reset-backfill was passed.
if (cli_options.start_caching) {
#ifdef DB_BACKEND_POSTGRES
/* Ensure caching_enabled and caching_inbox_enabled are set to true
* in the config table so the UI reflects the actual state and the
* inbox poller is active. We call db_update_config_value_only()
* directly (bypassing the config-layer wrapper) so we must also
* invalidate the in-memory config cache otherwise the poller's
* get_config_bool("caching_inbox_enabled") will keep returning
* the stale pre-update value and never dequeue any events. */
db_update_config_value_only("caching_enabled", "true");
db_update_config_value_only("caching_inbox_enabled", "true");
invalidate_config_cache();
DEBUG_INFO("CLI: --start-caching: set caching_enabled=true, caching_inbox_enabled=true");
if (cli_options.reset_backfill) {
DEBUG_INFO("CLI: resetting caching backfill progress (--reset-backfill)");
/* Clear both the legacy window table and the per-relay drain table,
* and mark all followed authors as incomplete. */
int rb_ok = (db_exec_sql("DELETE FROM caching_backfill_progress") == 0);
if (rb_ok) rb_ok = (db_exec_sql("DELETE FROM caching_backfill_relay_progress") == 0);
if (rb_ok) rb_ok = (db_exec_sql(
"UPDATE caching_followed_pubkeys "
"SET backfill_complete = FALSE, until_cursor = 0, events_fetched = 0, "
" updated_at = EXTRACT(EPOCH FROM NOW())::BIGINT") == 0);
if (rb_ok) {
DEBUG_INFO("CLI: caching backfill progress cleared (all tables reset)");
} else {
DEBUG_WARN("CLI: failed to clear caching backfill progress");
}
}
DEBUG_INFO("CLI: auto-starting caching service (--start-caching)");
int caching_rc = caching_service_start();
if (caching_rc == 0) {
DEBUG_INFO("CLI: caching service started successfully");
} else {
DEBUG_ERROR("CLI: failed to start caching service (rc=%d)", caching_rc);
}
#else
DEBUG_WARN("CLI: --start-caching requires PostgreSQL backend");
#endif
}
// Phase 5 strict mode: remove main-thread fallback to global DB handle.
// Runtime DB access must go through thread-bound worker connections.
// Ownership now resides in db_ops.c; no direct global handle mutation here.
@@ -2544,6 +3036,12 @@ int main(int argc, char* argv[]) {
// Start WebSocket Nostr relay server (port from CLI override or configuration)
int result = start_websocket_relay(cli_options.port_override, cli_options.strict_port); // Use CLI port override if specified, otherwise config
// Shut down the caching service if we launched it (fork mode).
caching_service_shutdown();
// Shut down the caching inbox poller before tearing down the thread pool.
caching_inbox_poller_shutdown();
if (thread_pool_initialized) {
thread_pool_shutdown();
}
+45 -3
View File
@@ -13,12 +13,12 @@
// Using CRELAY_ prefix to avoid conflicts with nostr_core_lib VERSION macros
#define CRELAY_VERSION_MAJOR 2
#define CRELAY_VERSION_MINOR 1
#define CRELAY_VERSION_PATCH 10
#define CRELAY_VERSION "v2.1.10"
#define CRELAY_VERSION_PATCH 23
#define CRELAY_VERSION "v2.1.23"
// Relay metadata (authoritative source for NIP-11 information)
#define RELAY_NAME "C-Relay-PG"
#define RELAY_DESCRIPTION "High-performance C Nostr relay with SQLite storage"
#define RELAY_DESCRIPTION "High-performance C Nostr relay with PostgreSQL storage"
#define RELAY_CONTACT ""
#define RELAY_SOFTWARE "https://git.laantungir.net/laantungir/c-relay-pg.git"
#define RELAY_VERSION CRELAY_VERSION // Use the same version as the build
@@ -31,6 +31,9 @@
// Forward declaration to avoid pulling cJSON headers into all includers.
typedef struct cJSON cJSON;
// Forward declaration for libwebsockets struct (used by ingest_event).
struct lws;
// Async REQ handler status used by websocket callback to defer EOSE until worker completion
#define HANDLE_REQ_ASYNC_PENDING (-2)
@@ -46,4 +49,43 @@ int store_event(cJSON* event);
// Drains completed async REQ jobs and queues EVENT/EOSE on the lws service thread.
void process_req_async_completions(void);
// Source mode for the shared event ingestion entry point.
typedef enum {
EVENT_SOURCE_CLIENT, // Normal WebSocket client event
EVENT_SOURCE_CACHING_INBOX // Event from the caching inbox (internal import)
} event_source_t;
// Process an event through the authoritative validation and storage pipeline.
// This is the shared entry point for both client events and caching inbox events.
//
// For EVENT_SOURCE_CLIENT: behaves exactly as the current client path does.
// For EVENT_SOURCE_CACHING_INBOX:
// - No NIP-42 client authentication requirement
// - No OK response sent (no wsi)
// - No administrator command execution (kind 23456 events are stored as data, not processed)
// - Still validates signature, structure, expiration, PoW
// - Still handles replaceable/addressable/ephemeral/duplicate/NIP-09 semantics
// - Still stores through the writer thread pool
// - Still queues post-actions and broadcast to the main thread
//
// Parameters:
// event_json - raw JSON string of the Nostr event
// event_json_len - length of the JSON string
// source - EVENT_SOURCE_CLIENT or EVENT_SOURCE_CACHING_INBOX
// wsi - WebSocket instance for client responses (NULL for caching inbox)
// pss - per-session data for client responses (NULL for caching inbox)
//
// Returns:
// 0 = accepted and stored (or duplicate, or ephemeral broadcast)
// -1 = rejected (validation failure, storage failure)
int ingest_event(const char* event_json, size_t event_json_len,
event_source_t source, struct lws* wsi, void* pss);
// Drains completed caching-inbox ingestion jobs on the lws service thread.
// Runs store_event_post_actions() and broadcast_event_to_subscriptions() for
// events that were stored off the main thread by ingest_event() with
// EVENT_SOURCE_CACHING_INBOX. Safe to call unconditionally; no-op when empty
// or when the PostgreSQL backend is not in use.
void process_inbox_event_completions(void);
#endif /* MAIN_H */
+304 -22
View File
@@ -1,7 +1,7 @@
#ifndef PG_SCHEMA_H
#define PG_SCHEMA_H
#define EMBEDDED_PG_SCHEMA_VERSION "2"
#define EMBEDDED_PG_SCHEMA_VERSION "5"
static const char* const EMBEDDED_PG_SCHEMA_SQL =
"-- C-Relay-PG PostgreSQL Schema\n"
@@ -24,15 +24,7 @@ static const char* const EMBEDDED_PG_SCHEMA_SQL =
" content TEXT NOT NULL,\n"
" sig TEXT NOT NULL,\n"
" tags JSONB NOT NULL DEFAULT '[]'::jsonb,\n"
" d_tag_value TEXT GENERATED ALWAYS AS (\n"
" COALESCE(\n"
" NULLIF(\n"
" jsonb_path_query_first(tags, '$[*] ? (@[0] == \"d\")[1]') #>> '{}',\n"
" ''\n"
" ),\n"
" ''\n"
" )\n"
" ) STORED,\n"
" d_tag_value TEXT NOT NULL DEFAULT '',\n"
" expires_at BIGINT,\n"
" event_json TEXT NOT NULL,\n"
" first_seen BIGINT NOT NULL DEFAULT EXTRACT(EPOCH FROM NOW())::BIGINT\n"
@@ -43,23 +35,42 @@ static const char* const EMBEDDED_PG_SCHEMA_SQL =
"\n"
"DO $$\n"
"BEGIN\n"
" IF NOT EXISTS (\n"
" IF EXISTS (\n"
" SELECT 1\n"
" FROM information_schema.columns\n"
" WHERE table_schema = current_schema()\n"
" AND table_name = 'events'\n"
" AND column_name = 'd_tag_value'\n"
" AND is_generated = 'ALWAYS'\n"
" ) THEN\n"
" DROP INDEX IF EXISTS uq_events_addressable_pubkey_kind_dtag;\n"
" DROP INDEX IF EXISTS idx_events_d_tag_value;\n"
" ALTER TABLE events DROP COLUMN d_tag_value;\n"
" ALTER TABLE events ADD COLUMN d_tag_value TEXT NOT NULL DEFAULT '';\n"
" ELSIF NOT EXISTS (\n"
" SELECT 1\n"
" FROM information_schema.columns\n"
" WHERE table_schema = current_schema()\n"
" AND table_name = 'events'\n"
" AND column_name = 'd_tag_value'\n"
" ) THEN\n"
" ALTER TABLE events\n"
" ADD COLUMN d_tag_value TEXT GENERATED ALWAYS AS (\n"
" COALESCE(\n"
" NULLIF(\n"
" jsonb_path_query_first(tags, '$[*] ? (@[0] == \"d\")[1]') #>> '{}',\n"
" ''\n"
" ),\n"
" ''\n"
" )\n"
" ) STORED;\n"
" ALTER TABLE events ADD COLUMN d_tag_value TEXT NOT NULL DEFAULT '';\n"
" END IF;\n"
"\n"
" -- One-time backfill of d_tag_value from tags JSONB.\n"
" -- Guarded by schema_info version check so it only runs once on upgrade\n"
" -- from v3, NOT on every startup (avoids full-table scan + write on boot).\n"
" IF COALESCE((SELECT value FROM schema_info WHERE key = 'version'), '0') < '4' THEN\n"
" UPDATE events e\n"
" SET d_tag_value = COALESCE((\n"
" SELECT tag->>1\n"
" FROM jsonb_array_elements(e.tags) AS tag\n"
" WHERE jsonb_typeof(tag) = 'array'\n"
" AND jsonb_array_length(tag) >= 2\n"
" AND tag->>0 = 'd'\n"
" LIMIT 1\n"
" ), '')\n"
" WHERE COALESCE(e.d_tag_value, '') = '';\n"
" END IF;\n"
"END\n"
"$$;\n"
@@ -98,8 +109,10 @@ static const char* const EMBEDDED_PG_SCHEMA_SQL =
"RETURNS TRIGGER AS $$\n"
"DECLARE\n"
" expiration_value TEXT;\n"
" d_value TEXT;\n"
"BEGIN\n"
" NEW.expires_at := NULL;\n"
" NEW.d_tag_value := '';\n"
"\n"
" SELECT tag->>1\n"
" INTO expiration_value\n"
@@ -113,6 +126,19 @@ static const char* const EMBEDDED_PG_SCHEMA_SQL =
" NEW.expires_at := expiration_value::BIGINT;\n"
" END IF;\n"
"\n"
"\n"
" SELECT tag->>1\n"
" INTO d_value\n"
" FROM jsonb_array_elements(NEW.tags) AS tag\n"
" WHERE jsonb_typeof(tag) = 'array'\n"
" AND jsonb_array_length(tag) >= 2\n"
" AND tag->>0 = 'd'\n"
" LIMIT 1;\n"
"\n"
" IF d_value IS NOT NULL THEN\n"
" NEW.d_tag_value := d_value;\n"
" END IF;\n"
"\n"
" RETURN NEW;\n"
"END;\n"
"$$ LANGUAGE plpgsql;\n"
@@ -237,12 +263,268 @@ static const char* const EMBEDDED_PG_SCHEMA_SQL =
" idle_first_failure BIGINT NOT NULL DEFAULT 0\n"
");\n"
"\n"
"-- Helper views for common queries\n"
"CREATE OR REPLACE VIEW recent_events AS\n"
"SELECT id, pubkey, created_at, kind, event_type, content\n"
"FROM events\n"
"WHERE event_type <> 'ephemeral'\n"
"ORDER BY created_at DESC\n"
"LIMIT 1000;\n"
"\n"
"CREATE OR REPLACE VIEW event_stats AS\n"
"SELECT\n"
" event_type,\n"
" COUNT(*) AS count,\n"
" AVG(char_length(content)) AS avg_content_length,\n"
" MIN(created_at) AS earliest,\n"
" MAX(created_at) AS latest\n"
"FROM events\n"
"GROUP BY event_type;\n"
"\n"
"-- Configuration events view (kind 33334)\n"
"CREATE OR REPLACE VIEW configuration_events AS\n"
"SELECT\n"
" id,\n"
" pubkey AS admin_pubkey,\n"
" created_at,\n"
" content,\n"
" tags,\n"
" sig\n"
"FROM events\n"
"WHERE kind = 33334\n"
"ORDER BY created_at DESC;\n"
"\n"
"-- View for subscription analytics\n"
"CREATE OR REPLACE VIEW subscription_analytics AS\n"
"SELECT\n"
" to_timestamp(created_at)::date AS date,\n"
" COUNT(*) AS subscriptions_created,\n"
" COUNT(CASE WHEN ended_at IS NOT NULL THEN 1 END) AS subscriptions_ended,\n"
" AVG(CASE WHEN duration IS NOT NULL THEN duration END) AS avg_duration_seconds,\n"
" MAX(events_sent) AS max_events_sent,\n"
" AVG(events_sent) AS avg_events_sent,\n"
" COUNT(DISTINCT client_ip) AS unique_clients\n"
"FROM subscriptions\n"
"GROUP BY to_timestamp(created_at)::date\n"
"ORDER BY date DESC;\n"
"\n"
"-- View for current active subscriptions (from log perspective)\n"
"CREATE OR REPLACE VIEW active_subscriptions_log AS\n"
"SELECT\n"
" subscription_id,\n"
" client_ip,\n"
" filter_json,\n"
" events_sent,\n"
" created_at,\n"
" (EXTRACT(EPOCH FROM NOW())::BIGINT - created_at) AS duration_seconds,\n"
" wsi_pointer\n"
"FROM subscriptions\n"
"WHERE event_type = 'created'\n"
" AND ended_at IS NULL;\n"
"\n"
"-- Event kinds distribution view\n"
"CREATE OR REPLACE VIEW event_kinds_view AS\n"
"SELECT\n"
" kind,\n"
" COUNT(*) AS count,\n"
" ROUND((COUNT(*) * 100.0 / NULLIF((SELECT COUNT(*) FROM events), 0))::numeric, 2) AS percentage\n"
"FROM events\n"
"GROUP BY kind\n"
"ORDER BY count DESC;\n"
"\n"
"-- Top pubkeys by event count view\n"
"CREATE OR REPLACE VIEW top_pubkeys_view AS\n"
"SELECT\n"
" pubkey,\n"
" COUNT(*) AS event_count,\n"
" ROUND((COUNT(*) * 100.0 / NULLIF((SELECT COUNT(*) FROM events), 0))::numeric, 2) AS percentage\n"
"FROM events\n"
"GROUP BY pubkey\n"
"ORDER BY event_count DESC;\n"
"\n"
"-- Time-based statistics view\n"
"CREATE OR REPLACE VIEW time_stats_view AS\n"
"SELECT\n"
" 'total' AS period,\n"
" COUNT(*) AS total_events,\n"
" COUNT(DISTINCT pubkey) AS unique_pubkeys,\n"
" MIN(created_at) AS oldest_event,\n"
" MAX(created_at) AS newest_event\n"
"FROM events\n"
"UNION ALL\n"
"SELECT\n"
" '24h' AS period,\n"
" COUNT(*) AS total_events,\n"
" COUNT(DISTINCT pubkey) AS unique_pubkeys,\n"
" MIN(created_at) AS oldest_event,\n"
" MAX(created_at) AS newest_event\n"
"FROM events\n"
"WHERE created_at >= (EXTRACT(EPOCH FROM NOW())::BIGINT - 86400)\n"
"UNION ALL\n"
"SELECT\n"
" '7d' AS period,\n"
" COUNT(*) AS total_events,\n"
" COUNT(DISTINCT pubkey) AS unique_pubkeys,\n"
" MIN(created_at) AS oldest_event,\n"
" MAX(created_at) AS newest_event\n"
"FROM events\n"
"WHERE created_at >= (EXTRACT(EPOCH FROM NOW())::BIGINT - 604800)\n"
"UNION ALL\n"
"SELECT\n"
" '30d' AS period,\n"
" COUNT(*) AS total_events,\n"
" COUNT(DISTINCT pubkey) AS unique_pubkeys,\n"
" MIN(created_at) AS oldest_event,\n"
" MAX(created_at) AS newest_event\n"
"FROM events\n"
"WHERE created_at >= (EXTRACT(EPOCH FROM NOW())::BIGINT - 2592000);\n"
"\n"
"INSERT INTO schema_info(key, value, updated_at)\n"
"VALUES ('version', '2', EXTRACT(EPOCH FROM NOW())::BIGINT)\n"
"VALUES ('version', '5', EXTRACT(EPOCH FROM NOW())::BIGINT)\n"
"ON CONFLICT (key) DO UPDATE SET\n"
" value = EXCLUDED.value,\n"
" updated_at = EXCLUDED.updated_at;\n"
"\n"
"-- =====================================================================\n"
"-- Caching relay integration tables\n"
"-- These tables are written by an external caching application and\n"
"-- consumed by c-relay-pg. They are schema-only here; the database\n"
"-- abstraction functions are added in a later phase.\n"
"-- =====================================================================\n"
"\n"
"-- Queue table where the external caching application inserts raw event\n"
"-- JSON. c-relay-pg destructively dequeues from it.\n"
"CREATE TABLE IF NOT EXISTS caching_event_inbox (\n"
" queue_id BIGSERIAL PRIMARY KEY,\n"
" event_id TEXT NOT NULL UNIQUE,\n"
" event_json JSONB NOT NULL,\n"
" source_relay TEXT,\n"
" source_class TEXT NOT NULL DEFAULT 'backfill',\n"
" priority SMALLINT NOT NULL DEFAULT 1,\n"
" received_at BIGINT NOT NULL DEFAULT EXTRACT(EPOCH FROM NOW())::BIGINT,\n"
"\n"
" CHECK (jsonb_typeof(event_json) = 'object'),\n"
" CHECK (jsonb_typeof(event_json->'id') = 'string'),\n"
" CHECK (length(event_json->>'id') = 64),\n"
" CHECK (event_id = event_json->>'id'),\n"
" CHECK (jsonb_typeof(event_json->'pubkey') = 'string'),\n"
" CHECK (length(event_json->>'pubkey') = 64),\n"
" CHECK (jsonb_typeof(event_json->'sig') = 'string'),\n"
" CHECK (length(event_json->>'sig') = 128),\n"
" CHECK (jsonb_typeof(event_json->'created_at') = 'number'),\n"
" CHECK (jsonb_typeof(event_json->'kind') = 'number'),\n"
" CHECK (jsonb_typeof(event_json->'tags') = 'array'),\n"
" CHECK (jsonb_typeof(event_json->'content') = 'string'),\n"
" CHECK (source_class IN ('live', 'discovery', 'backfill')),\n"
" CHECK (priority IN (0, 1))\n"
");\n"
"\n"
"CREATE INDEX IF NOT EXISTS idx_caching_inbox_dequeue\n"
" ON caching_event_inbox(priority, received_at, queue_id);\n"
"\n"
"-- Singleton status row for the external caching application. The caching\n"
"-- process overwrites this periodically.\n"
"CREATE TABLE IF NOT EXISTS caching_service_state (\n"
" id SMALLINT PRIMARY KEY DEFAULT 1,\n"
" service_version TEXT,\n"
" service_state TEXT NOT NULL DEFAULT 'stopped',\n"
" config_generation BIGINT NOT NULL DEFAULT 0,\n"
" heartbeat_at BIGINT NOT NULL DEFAULT 0,\n"
" followed_author_count INTEGER NOT NULL DEFAULT 0,\n"
" selected_relay_count INTEGER NOT NULL DEFAULT 0,\n"
" connected_relay_count INTEGER NOT NULL DEFAULT 0,\n"
" current_window_index INTEGER NOT NULL DEFAULT 0,\n"
" backfill_cursor INTEGER NOT NULL DEFAULT 0,\n"
" events_fetched BIGINT NOT NULL DEFAULT 0,\n"
" inbox_inserts BIGINT NOT NULL DEFAULT 0,\n"
" last_error TEXT,\n"
" last_error_at BIGINT,\n"
" updated_at BIGINT NOT NULL DEFAULT EXTRACT(EPOCH FROM NOW())::BIGINT,\n"
"\n"
" CHECK (id = 1),\n"
" CHECK (service_state IN ('stopped', 'starting', 'running', 'degraded'))\n"
");\n"
"\n"
"-- Per-author backfill progress for the external caching application.\n"
"CREATE TABLE IF NOT EXISTS caching_backfill_progress (\n"
" author_pubkey TEXT NOT NULL,\n"
" window_index INTEGER NOT NULL,\n"
" window_anchor BIGINT NOT NULL,\n"
" until_cursor BIGINT NOT NULL DEFAULT 0,\n"
" complete BOOLEAN NOT NULL DEFAULT FALSE,\n"
" updated_at BIGINT NOT NULL DEFAULT EXTRACT(EPOCH FROM NOW())::BIGINT,\n"
"\n"
" PRIMARY KEY (author_pubkey, window_index)\n"
");\n"
"\n"
"CREATE INDEX IF NOT EXISTS idx_caching_backfill_progress_window\n"
" ON caching_backfill_progress(window_index)\n"
" WHERE complete = FALSE;\n"
"\n"
"-- Durable followed-pubkey list with per-author backfill cursor state.\n"
"-- Replaces the window-coupled caching_backfill_progress table for the\n"
"-- per-author until-cursor drain backfill model.\n"
"CREATE TABLE IF NOT EXISTS caching_followed_pubkeys (\n"
" pubkey TEXT PRIMARY KEY,\n"
" is_root BOOLEAN NOT NULL DEFAULT FALSE,\n"
" until_cursor BIGINT NOT NULL DEFAULT 0,\n"
" backfill_complete BOOLEAN NOT NULL DEFAULT FALSE,\n"
" events_fetched INTEGER NOT NULL DEFAULT 0,\n"
" first_seen BIGINT NOT NULL DEFAULT EXTRACT(EPOCH FROM NOW())::BIGINT,\n"
" last_seen BIGINT NOT NULL DEFAULT EXTRACT(EPOCH FROM NOW())::BIGINT,\n"
" updated_at BIGINT NOT NULL DEFAULT EXTRACT(EPOCH FROM NOW())::BIGINT\n"
");\n"
"\n"
"CREATE INDEX IF NOT EXISTS idx_caching_followed_incomplete\n"
" ON caching_followed_pubkeys(backfill_complete, last_seen)\n"
" WHERE backfill_complete = FALSE;\n"
"\n"
"-- Per-relay backfill cursor tracking for the relay-by-relay drain model.\n"
"-- Each (author, relay) pair has its own until_cursor and completion flag so\n"
"-- slow relays do not cause premature completion marking for the author.\n"
"CREATE TABLE IF NOT EXISTS caching_backfill_relay_progress (\n"
" author_pubkey TEXT NOT NULL,\n"
" relay_url TEXT NOT NULL,\n"
" until_cursor BIGINT NOT NULL DEFAULT 0,\n"
" complete BOOLEAN NOT NULL DEFAULT FALSE,\n"
" events_fetched INTEGER NOT NULL DEFAULT 0,\n"
" updated_at BIGINT NOT NULL DEFAULT EXTRACT(EPOCH FROM NOW())::BIGINT,\n"
" PRIMARY KEY (author_pubkey, relay_url)\n"
");\n"
"\n"
"CREATE INDEX IF NOT EXISTS idx_caching_relay_progress_incomplete\n"
" ON caching_backfill_relay_progress(author_pubkey)\n"
" WHERE complete = FALSE;\n"
"\n"
"-- Replace window-based progress columns with author-based progress.\n"
"ALTER TABLE caching_service_state\n"
" DROP COLUMN IF EXISTS current_window_index,\n"
" DROP COLUMN IF EXISTS backfill_cursor,\n"
" ADD COLUMN IF NOT EXISTS backfill_authors_complete INTEGER NOT NULL DEFAULT 0,\n"
" ADD COLUMN IF NOT EXISTS backfill_authors_total INTEGER NOT NULL DEFAULT 0;\n"
"\n"
"-- =====================================================================\n"
"-- LISTEN/NOTIFY support for api-worker monitoring (Phase 5)\n"
"-- Fires a notification on the 'event_stored' channel whenever a new event\n"
"-- is inserted, so the api-worker thread can wake reactively instead of\n"
"-- polling on a timer. The payload is a small JSON object with kind and a\n"
"-- truncated pubkey prefix (kept tiny to minimize per-insert overhead).\n"
"-- =====================================================================\n"
"CREATE OR REPLACE FUNCTION notify_event_stored() RETURNS trigger AS $$\n"
"BEGIN\n"
" PERFORM pg_notify('event_stored', json_build_object(\n"
" 'kind', NEW.kind,\n"
" 'pubkey', substring(NEW.pubkey, 1, 8)\n"
" )::text);\n"
" RETURN NEW;\n"
"END;\n"
"$$ LANGUAGE plpgsql;\n"
"\n"
"DROP TRIGGER IF EXISTS trg_notify_event_stored ON events;\n"
"CREATE TRIGGER trg_notify_event_stored\n"
" AFTER INSERT ON events\n"
" FOR EACH ROW EXECUTE FUNCTION notify_event_stored();\n"
"\n"
"COMMIT;\n"
;
+186 -21
View File
@@ -18,15 +18,7 @@ CREATE TABLE IF NOT EXISTS events (
content TEXT NOT NULL,
sig TEXT NOT NULL,
tags JSONB NOT NULL DEFAULT '[]'::jsonb,
d_tag_value TEXT GENERATED ALWAYS AS (
COALESCE(
NULLIF(
jsonb_path_query_first(tags, '$[*] ? (@[0] == "d")[1]') #>> '{}',
''
),
''
)
) STORED,
d_tag_value TEXT NOT NULL DEFAULT '',
expires_at BIGINT,
event_json TEXT NOT NULL,
first_seen BIGINT NOT NULL DEFAULT EXTRACT(EPOCH FROM NOW())::BIGINT
@@ -37,23 +29,42 @@ ALTER TABLE events
DO $$
BEGIN
IF NOT EXISTS (
IF EXISTS (
SELECT 1
FROM information_schema.columns
WHERE table_schema = current_schema()
AND table_name = 'events'
AND column_name = 'd_tag_value'
AND is_generated = 'ALWAYS'
) THEN
DROP INDEX IF EXISTS uq_events_addressable_pubkey_kind_dtag;
DROP INDEX IF EXISTS idx_events_d_tag_value;
ALTER TABLE events DROP COLUMN d_tag_value;
ALTER TABLE events ADD COLUMN d_tag_value TEXT NOT NULL DEFAULT '';
ELSIF NOT EXISTS (
SELECT 1
FROM information_schema.columns
WHERE table_schema = current_schema()
AND table_name = 'events'
AND column_name = 'd_tag_value'
) THEN
ALTER TABLE events
ADD COLUMN d_tag_value TEXT GENERATED ALWAYS AS (
COALESCE(
NULLIF(
jsonb_path_query_first(tags, '$[*] ? (@[0] == "d")[1]') #>> '{}',
''
),
''
)
) STORED;
ALTER TABLE events ADD COLUMN d_tag_value TEXT NOT NULL DEFAULT '';
END IF;
-- One-time backfill of d_tag_value from tags JSONB.
-- Guarded by schema_info version check so it only runs once on upgrade
-- from v3, NOT on every startup (avoids full-table scan + write on boot).
IF COALESCE((SELECT value FROM schema_info WHERE key = 'version'), '0') < '4' THEN
UPDATE events e
SET d_tag_value = COALESCE((
SELECT tag->>1
FROM jsonb_array_elements(e.tags) AS tag
WHERE jsonb_typeof(tag) = 'array'
AND jsonb_array_length(tag) >= 2
AND tag->>0 = 'd'
LIMIT 1
), '')
WHERE COALESCE(e.d_tag_value, '') = '';
END IF;
END
$$;
@@ -92,8 +103,10 @@ CREATE OR REPLACE FUNCTION set_event_derived_fields()
RETURNS TRIGGER AS $$
DECLARE
expiration_value TEXT;
d_value TEXT;
BEGIN
NEW.expires_at := NULL;
NEW.d_tag_value := '';
SELECT tag->>1
INTO expiration_value
@@ -107,6 +120,18 @@ BEGIN
NEW.expires_at := expiration_value::BIGINT;
END IF;
SELECT tag->>1
INTO d_value
FROM jsonb_array_elements(NEW.tags) AS tag
WHERE jsonb_typeof(tag) = 'array'
AND jsonb_array_length(tag) >= 2
AND tag->>0 = 'd'
LIMIT 1;
IF d_value IS NOT NULL THEN
NEW.d_tag_value := d_value;
END IF;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
@@ -232,9 +257,149 @@ CREATE TABLE IF NOT EXISTS ip_bans (
);
INSERT INTO schema_info(key, value, updated_at)
VALUES ('version', '2', EXTRACT(EPOCH FROM NOW())::BIGINT)
VALUES ('version', '5', EXTRACT(EPOCH FROM NOW())::BIGINT)
ON CONFLICT (key) DO UPDATE SET
value = EXCLUDED.value,
updated_at = EXCLUDED.updated_at;
-- =====================================================================
-- LISTEN/NOTIFY support for api-worker monitoring (Phase 5)
-- Fires a notification on the 'event_stored' channel whenever a new event
-- is inserted, so the api-worker thread can wake reactively instead of
-- polling on a timer. The payload is a small JSON object with kind and a
-- truncated pubkey prefix (kept tiny to minimize per-insert overhead).
-- =====================================================================
CREATE OR REPLACE FUNCTION notify_event_stored() RETURNS trigger AS $$
BEGIN
PERFORM pg_notify('event_stored', json_build_object(
'kind', NEW.kind,
'pubkey', substring(NEW.pubkey, 1, 8)
)::text);
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
DROP TRIGGER IF EXISTS trg_notify_event_stored ON events;
CREATE TRIGGER trg_notify_event_stored
AFTER INSERT ON events
FOR EACH ROW EXECUTE FUNCTION notify_event_stored();
-- =====================================================================
-- Caching relay integration tables
-- These tables are written by an external caching application and
-- consumed by c-relay-pg. They are schema-only here; the database
-- abstraction functions are added in a later phase.
-- =====================================================================
-- Queue table where the external caching application inserts raw event
-- JSON. c-relay-pg destructively dequeues from it.
CREATE TABLE IF NOT EXISTS caching_event_inbox (
queue_id BIGSERIAL PRIMARY KEY,
event_id TEXT NOT NULL UNIQUE,
event_json JSONB NOT NULL,
source_relay TEXT,
source_class TEXT NOT NULL DEFAULT 'backfill',
priority SMALLINT NOT NULL DEFAULT 1,
received_at BIGINT NOT NULL DEFAULT EXTRACT(EPOCH FROM NOW())::BIGINT,
CHECK (jsonb_typeof(event_json) = 'object'),
CHECK (jsonb_typeof(event_json->'id') = 'string'),
CHECK (length(event_json->>'id') = 64),
CHECK (event_id = event_json->>'id'),
CHECK (jsonb_typeof(event_json->'pubkey') = 'string'),
CHECK (length(event_json->>'pubkey') = 64),
CHECK (jsonb_typeof(event_json->'sig') = 'string'),
CHECK (length(event_json->>'sig') = 128),
CHECK (jsonb_typeof(event_json->'created_at') = 'number'),
CHECK (jsonb_typeof(event_json->'kind') = 'number'),
CHECK (jsonb_typeof(event_json->'tags') = 'array'),
CHECK (jsonb_typeof(event_json->'content') = 'string'),
CHECK (source_class IN ('live', 'discovery', 'backfill')),
CHECK (priority IN (0, 1))
);
CREATE INDEX IF NOT EXISTS idx_caching_inbox_dequeue
ON caching_event_inbox(priority, received_at, queue_id);
-- Singleton status row for the external caching application. The caching
-- process overwrites this periodically.
CREATE TABLE IF NOT EXISTS caching_service_state (
id SMALLINT PRIMARY KEY DEFAULT 1,
service_version TEXT,
service_state TEXT NOT NULL DEFAULT 'stopped',
config_generation BIGINT NOT NULL DEFAULT 0,
heartbeat_at BIGINT NOT NULL DEFAULT 0,
followed_author_count INTEGER NOT NULL DEFAULT 0,
selected_relay_count INTEGER NOT NULL DEFAULT 0,
connected_relay_count INTEGER NOT NULL DEFAULT 0,
current_window_index INTEGER NOT NULL DEFAULT 0,
backfill_cursor INTEGER NOT NULL DEFAULT 0,
events_fetched BIGINT NOT NULL DEFAULT 0,
inbox_inserts BIGINT NOT NULL DEFAULT 0,
last_error TEXT,
last_error_at BIGINT,
updated_at BIGINT NOT NULL DEFAULT EXTRACT(EPOCH FROM NOW())::BIGINT,
CHECK (id = 1),
CHECK (service_state IN ('stopped', 'starting', 'running', 'degraded'))
);
-- Per-author backfill progress for the external caching application.
CREATE TABLE IF NOT EXISTS caching_backfill_progress (
author_pubkey TEXT NOT NULL,
window_index INTEGER NOT NULL,
window_anchor BIGINT NOT NULL,
until_cursor BIGINT NOT NULL DEFAULT 0,
complete BOOLEAN NOT NULL DEFAULT FALSE,
updated_at BIGINT NOT NULL DEFAULT EXTRACT(EPOCH FROM NOW())::BIGINT,
PRIMARY KEY (author_pubkey, window_index)
);
CREATE INDEX IF NOT EXISTS idx_caching_backfill_progress_window
ON caching_backfill_progress(window_index)
WHERE complete = FALSE;
-- Durable followed-pubkey list with per-author backfill cursor state.
-- Replaces the window-coupled caching_backfill_progress table for the
-- per-author until-cursor drain backfill model.
CREATE TABLE IF NOT EXISTS caching_followed_pubkeys (
pubkey TEXT PRIMARY KEY,
is_root BOOLEAN NOT NULL DEFAULT FALSE,
until_cursor BIGINT NOT NULL DEFAULT 0,
backfill_complete BOOLEAN NOT NULL DEFAULT FALSE,
events_fetched INTEGER NOT NULL DEFAULT 0,
first_seen BIGINT NOT NULL DEFAULT EXTRACT(EPOCH FROM NOW())::BIGINT,
last_seen BIGINT NOT NULL DEFAULT EXTRACT(EPOCH FROM NOW())::BIGINT,
updated_at BIGINT NOT NULL DEFAULT EXTRACT(EPOCH FROM NOW())::BIGINT
);
CREATE INDEX IF NOT EXISTS idx_caching_followed_incomplete
ON caching_followed_pubkeys(backfill_complete, last_seen)
WHERE backfill_complete = FALSE;
-- Per-relay backfill cursor tracking for the relay-by-relay drain model.
-- Each (author, relay) pair has its own until_cursor and completion flag so
-- slow relays do not cause premature completion marking for the author.
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;
-- Replace window-based progress columns with author-based progress.
ALTER TABLE caching_service_state
DROP COLUMN IF EXISTS current_window_index,
DROP COLUMN IF EXISTS backfill_cursor,
ADD COLUMN IF NOT EXISTS backfill_authors_complete INTEGER NOT NULL DEFAULT 0,
ADD COLUMN IF NOT EXISTS backfill_authors_total INTEGER NOT NULL DEFAULT 0;
COMMIT;
+1 -4
View File
@@ -305,14 +305,11 @@ int nostr_validate_unified_request(const char* json_string, size_t json_length)
// 8. Check if this is a kind 23456 admin event from authorized admin
// This must happen AFTER signature validation but BEFORE auth rules
if (event_kind == 23456) {
const char* admin_pubkey = get_config_value("admin_pubkey");
if (admin_pubkey && strcmp(event_pubkey, admin_pubkey) == 0) {
if (is_admin_pubkey(event_pubkey)) {
// Valid admin event - bypass remaining validation
free((char*)admin_pubkey);
cJSON_Delete(event);
return NOSTR_SUCCESS;
}
if (admin_pubkey) free((char*)admin_pubkey);
// Not from admin - continue with normal validation
}
+4
View File
@@ -17,6 +17,9 @@ void sqlite_db_clear_thread_connection(void);
int sqlite_db_open_worker_connection(const char* db_path, void** out_connection);
void sqlite_db_close_worker_connection(void* connection);
int sqlite_db_worker_listen(void* connection, const char* channel);
int sqlite_db_worker_poll_notify(void* connection, int timeout_ms);
int sqlite_db_prepare(const char* sql, sqlite_db_stmt_t** out_stmt);
int sqlite_db_bind_text_param(sqlite_db_stmt_t* stmt, int index, const char* value);
int sqlite_db_bind_int_param(sqlite_db_stmt_t* stmt, int index, int value);
@@ -53,6 +56,7 @@ char* sqlite_db_execute_readonly_query_json(const char* query, const char* reque
int sqlite_db_get_total_event_count_ll(long long* out_count);
int sqlite_db_get_event_count_since(time_t cutoff, long long* out_count);
int sqlite_db_get_storage_size_bytes(long long* out_size);
cJSON* sqlite_db_get_event_kind_distribution_rows(long long* out_total_events);
cJSON* sqlite_db_get_top_pubkeys_rows(int limit);
cJSON* sqlite_db_get_subscription_details_rows(void);
+33 -9
View File
@@ -54,9 +54,6 @@ int validate_timestamp_range(long since, long until, char* error_message, size_t
int validate_numeric_limits(int limit, char* error_message, size_t error_size);
int validate_search_term(const char* search_term, char* error_message, size_t error_size);
// Forward declaration for monitoring function
void monitoring_on_subscription_change(void);
// Configuration functions from config.c
extern int get_config_bool(const char* key, int default_value);
@@ -479,9 +476,6 @@ int add_subscription_to_manager(subscription_t* sub) {
// Log subscription creation to database (INSERT OR REPLACE handles duplicates)
log_subscription_created(sub);
// Trigger monitoring update for subscription changes
monitoring_on_subscription_change();
return 0;
}
@@ -532,9 +526,6 @@ int remove_subscription_from_manager(const char* sub_id, struct lws* wsi) {
// Update events sent counter before freeing
update_subscription_events_sent(sub_id_copy, events_sent_copy);
// Trigger monitoring update for subscription changes
monitoring_on_subscription_change();
free_subscription(sub);
return 0;
}
@@ -1008,6 +999,39 @@ int has_subscriptions_for_kind(int event_kind) {
return 0; // No matching subscriptions
}
// Check if any active subscription would receive a specific event kind.
// Unlike has_subscriptions_for_kind(), this also returns 1 when there are
// active subscriptions with NO kind filter (they match every kind), which
// is required for the api-worker's "is anyone listening to kind 24567" gate.
int has_any_subscription_for_kind(int event_kind) {
pthread_mutex_lock(&g_subscription_manager.subscriptions_lock);
// 1) Subscriptions with an explicit kind filter matching event_kind.
if (event_kind >= 0 && event_kind <= 65535) {
kind_subscription_node_t* node = g_subscription_manager.kind_index[event_kind];
while (node) {
if (node->subscription && node->subscription->active) {
pthread_mutex_unlock(&g_subscription_manager.subscriptions_lock);
return 1;
}
node = node->next;
}
}
// 2) Subscriptions with no kind filter (match all kinds).
no_kind_filter_node_t* nk = g_subscription_manager.no_kind_filter_subs;
while (nk) {
if (nk->subscription && nk->subscription->active) {
pthread_mutex_unlock(&g_subscription_manager.subscriptions_lock);
return 1;
}
nk = nk->next;
}
pthread_mutex_unlock(&g_subscription_manager.subscriptions_lock);
return 0;
}
/////////////////////////////////////////////////////////////////////////////////////////
/////////////////////////////////////////////////////////////////////////////////////////
+1
View File
@@ -140,6 +140,7 @@ void update_subscription_events_sent(const char* sub_id, int events_sent);
// Subscription query functions
int has_subscriptions_for_kind(int event_kind);
int has_any_subscription_for_kind(int event_kind);
// Startup cleanup function
void cleanup_all_subscriptions_on_startup(void);
+46 -13
View File
@@ -32,7 +32,8 @@ typedef struct {
int reader_count;
pthread_t* readers;
pthread_t writer;
int writer_count;
pthread_t* writers;
thread_pool_queue_t read_q;
thread_pool_queue_t write_q;
@@ -441,6 +442,10 @@ static void execute_ip_ban_save_job(thread_pool_job_node_t* node) {
}
static void execute_wal_checkpoint_job(thread_pool_job_node_t* node) {
#ifdef DB_BACKEND_POSTGRES
complete_job_with_result(node, THREAD_POOL_STATUS_OK,
"WAL_CHECKPOINT skipped for PostgreSQL", NULL, 0);
#else
if (db_wal_checkpoint_passive() != 0) {
complete_job_with_result(node, THREAD_POOL_STATUS_INTERNAL_ERROR,
"WAL_CHECKPOINT failed", NULL, 0);
@@ -449,6 +454,7 @@ static void execute_wal_checkpoint_job(thread_pool_job_node_t* node) {
complete_job_with_result(node, THREAD_POOL_STATUS_OK,
"WAL_CHECKPOINT completed", NULL, 0);
#endif
}
static void execute_read_job(thread_pool_job_node_t* node) {
@@ -505,7 +511,7 @@ static void* reader_worker_main(void* arg) {
void* worker_db = open_worker_connection();
if (!worker_db) {
DEBUG_ERROR("Reader worker failed to open SQLite connection");
DEBUG_ERROR("Reader worker failed to open database connection");
return NULL;
}
@@ -521,13 +527,15 @@ static void* reader_worker_main(void* arg) {
}
static void* writer_worker_main(void* arg) {
(void)arg;
int writer_index = (int)(intptr_t)arg;
pthread_setname_np(pthread_self(), "db-write");
char thread_name[16];
snprintf(thread_name, sizeof(thread_name), "db-write-%d", writer_index);
pthread_setname_np(pthread_self(), thread_name);
void* worker_db = open_worker_connection();
if (!worker_db) {
DEBUG_ERROR("Writer worker failed to open SQLite connection");
DEBUG_ERROR("Writer worker failed to open database connection");
return NULL;
}
@@ -538,9 +546,11 @@ static void* writer_worker_main(void* arg) {
execute_write_job(node);
}
#ifndef DB_BACKEND_POSTGRES
if (db_wal_checkpoint_truncate() != 0) {
DEBUG_WARN("Writer shutdown TRUNCATE checkpoint failed");
}
#endif
db_close_worker_connection(worker_db);
return NULL;
@@ -556,12 +566,22 @@ int thread_pool_init(const thread_pool_config_t* config) {
}
g_pool.reader_count = (config->reader_threads > 0) ? config->reader_threads : 4;
g_pool.writer_count = (config->writer_threads > 0) ? config->writer_threads : 2;
g_pool.readers = calloc((size_t)g_pool.reader_count, sizeof(pthread_t));
if (!g_pool.readers) {
pthread_mutex_unlock(&g_pool.state_mutex);
return -1;
}
g_pool.writers = calloc((size_t)g_pool.writer_count, sizeof(pthread_t));
if (!g_pool.writers) {
free(g_pool.readers);
g_pool.readers = NULL;
pthread_mutex_unlock(&g_pool.state_mutex);
return -1;
}
queue_init(&g_pool.read_q, config->max_queue_depth);
queue_init(&g_pool.write_q, config->max_queue_depth);
@@ -572,7 +592,9 @@ int thread_pool_init(const thread_pool_config_t* config) {
const char* db_path = (config->db_path && config->db_path[0] != '\0') ? config->db_path : db_get_database_path();
if (!db_path || db_path[0] == '\0') {
free(g_pool.readers);
free(g_pool.writers);
g_pool.readers = NULL;
g_pool.writers = NULL;
pthread_mutex_unlock(&g_pool.state_mutex);
return -1;
}
@@ -591,16 +613,18 @@ int thread_pool_init(const thread_pool_config_t* config) {
}
}
if (pthread_create(&g_pool.writer, NULL, writer_worker_main, NULL) != 0) {
g_pool.running = 0;
pthread_cond_broadcast(&g_pool.read_q.cond);
pthread_cond_broadcast(&g_pool.write_q.cond);
pthread_mutex_unlock(&g_pool.state_mutex);
return -1;
for (int i = 0; i < g_pool.writer_count; i++) {
if (pthread_create(&g_pool.writers[i], NULL, writer_worker_main, (void*)(intptr_t)(i + 1)) != 0) {
g_pool.running = 0;
pthread_cond_broadcast(&g_pool.read_q.cond);
pthread_cond_broadcast(&g_pool.write_q.cond);
pthread_mutex_unlock(&g_pool.state_mutex);
return -1;
}
}
pthread_mutex_unlock(&g_pool.state_mutex);
DEBUG_LOG("Thread pool initialized: %d readers + 1 writer", g_pool.reader_count);
DEBUG_LOG("Thread pool initialized: %d readers + %d writers", g_pool.reader_count, g_pool.writer_count);
return 0;
}
@@ -619,11 +643,16 @@ void thread_pool_shutdown(void) {
for (int i = 0; i < g_pool.reader_count; i++) {
pthread_join(g_pool.readers[i], NULL);
}
pthread_join(g_pool.writer, NULL);
for (int i = 0; i < g_pool.writer_count; i++) {
pthread_join(g_pool.writers[i], NULL);
}
free(g_pool.readers);
free(g_pool.writers);
g_pool.readers = NULL;
g_pool.writers = NULL;
g_pool.reader_count = 0;
g_pool.writer_count = 0;
queue_destroy(&g_pool.read_q);
queue_destroy(&g_pool.write_q);
@@ -670,6 +699,9 @@ thread_pool_status_t thread_pool_submit_write(const thread_pool_job_t* job, uint
}
int thread_pool_submit_wal_checkpoint(void) {
#ifdef DB_BACKEND_POSTGRES
return 0;
#else
if (!thread_pool_is_running()) {
return -1;
}
@@ -680,6 +712,7 @@ int thread_pool_submit_wal_checkpoint(void) {
thread_pool_status_t rc = thread_pool_submit_write(&job, NULL);
return (rc == THREAD_POOL_STATUS_OK) ? 0 : -1;
#endif
}
int thread_pool_execute_count_sync(const char* sql, const char** bind_params, int bind_param_count, int* out_count) {
+1
View File
@@ -56,6 +56,7 @@ typedef struct {
typedef struct {
int reader_threads;
int writer_threads;
int max_queue_depth;
const char* db_path;
thread_pool_wake_loop_cb wake_loop_cb;
+23 -9
View File
@@ -34,6 +34,7 @@
#include "db_ops.h" // DB abstraction wrappers
#include "main.h" // Async REQ completion integration
#include "caching_inbox_poller.h" // Caching inbox poller (PostgreSQL-only)
// Forward declarations for logging functions
@@ -2322,13 +2323,11 @@ static int nostr_relay_callback(struct lws *wsi, enum lws_callback_reasons reaso
// Special case: allow kind 23456 admin events from authorized admin to bypass auth
if (event_kind == 23456 && event_pubkey) {
const char* admin_pubkey = get_config_value("admin_pubkey");
if (admin_pubkey && strcmp(event_pubkey, admin_pubkey) == 0) {
if (is_admin_pubkey(event_pubkey)) {
bypass_auth = 1;
} else {
DEBUG_INFO("DEBUG: Kind 23456 event but pubkey mismatch or no admin pubkey");
}
if (admin_pubkey) free((char*)admin_pubkey);
}
if (pss && auth_required && !pss->authenticated && !bypass_auth) {
@@ -3423,6 +3422,10 @@ int start_websocket_relay(int port_override, int strict_port) {
DEBUG_WARN("Async event worker failed to start; EVENT path will remain synchronous");
}
if (start_api_worker() != 0) {
DEBUG_WARN("API worker failed to start; monitoring/status work will run on lws-main");
}
pthread_setname_np(pthread_self(), "lws-main");
// Main event loop with proper signal handling
@@ -3440,9 +3443,21 @@ int start_websocket_relay(int port_override, int strict_port) {
// Drain completed async EVENT jobs and emit OK/broadcast on service thread.
process_async_event_completions();
// Drain completed caching-inbox ingestion jobs and run post-actions/broadcast
// on the service thread (PostgreSQL-only; no-op otherwise).
process_inbox_event_completions();
// Poll the caching_event_inbox table and feed dequeued events through
// ingest_event() with EVENT_SOURCE_CACHING_INBOX (PostgreSQL-only; no-op
// otherwise). Runs once per lws service loop iteration on the main thread.
caching_inbox_poller_tick();
// Drain completed async COUNT jobs and emit COUNT/NOTICE on service thread.
process_count_async_completions();
// Drain completed api-worker tasks.
api_worker_process_completions();
// Check if it's time to post status update
time_t current_time = time(NULL);
int status_post_hours = hot_cfg_kind_1_status_posts_hours();
@@ -3452,7 +3467,9 @@ int start_websocket_relay(int port_override, int strict_port) {
if (current_time - last_status_post_time >= seconds_interval) {
last_status_post_time = current_time;
generate_and_post_status_event();
if (api_worker_enqueue_status_post() != 0) {
generate_and_post_status_event();
}
}
}
@@ -3486,6 +3503,7 @@ int start_websocket_relay(int port_override, int strict_port) {
}
}
stop_api_worker();
stop_async_event_worker();
lws_context_destroy(ws_context);
@@ -3548,14 +3566,10 @@ int process_dm_stats_command(cJSON* dm_event, char* error_message, size_t error_
const char* sender_pubkey = cJSON_GetStringValue(pubkey_obj);
// Check if sender is admin
const char* admin_pubkey = get_config_value("admin_pubkey");
if (!admin_pubkey || strlen(admin_pubkey) == 0 ||
strcmp(sender_pubkey, admin_pubkey) != 0) {
if (admin_pubkey) free((char*)admin_pubkey);
if (!is_admin_pubkey(sender_pubkey)) {
strncpy(error_message, "Unauthorized: not admin", error_size - 1);
return -1;
}
free((char*)admin_pubkey);
// Get relay private key for decryption
char* relay_privkey_hex = get_relay_private_key();
+4 -1
View File
@@ -22,7 +22,10 @@
// Maximum number of messages allowed in a per-client write queue.
// When exceeded, new messages are dropped to prevent unbounded memory growth
// under high-traffic or slow-client conditions.
#define MAX_MESSAGE_QUEUE_SIZE 500
// Must be larger than max_limit (5000) so a full page of EVENT messages
// plus the trailing EOSE can always be queued — otherwise the EOSE gets
// dropped when a query returns exactly `limit` events.
#define MAX_MESSAGE_QUEUE_SIZE 5100
// Filter validation constants
#define MAX_FILTERS_PER_REQUEST 10

Some files were not shown because too many files have changed in this diff Show More