mirror of
https://github.com/PR0M3TH3AN/TORCH.git
synced 2026-11-10 06:42:21 +00:00
Harden lock backend config validation and fallback behavior
This commit is contained in:
1 parent
9a64554d87
commit
84d3e7eded
7 files changed
+467
-293
No files matched your search
@@ -8,5 +8,7 @@ export const DEFAULT_RELAYS = [
|
||||
export const DEFAULT_TTL = 7200;
|
||||
export const DEFAULT_NAMESPACE = 'torch';
|
||||
export const DEFAULT_QUERY_TIMEOUT_MS = 15_000;
|
||||
export const DEFAULT_PUBLISH_TIMEOUT_MS = 10_000;
|
||||
export const DEFAULT_MIN_SUCCESSFUL_PUBLISHES = 1;
|
||||
export const VALID_CADENCES = new Set(['daily', 'weekly']);
|
||||
export const KIND_APP_DATA = 30078;
|
||||
+24
-1
@@ -32,6 +32,9 @@ node bin/torch-lock.mjs list
|
||||
- `NOSTR_LOCK_RELAYS`
|
||||
- `NOSTR_LOCK_TTL`
|
||||
- `NOSTR_LOCK_QUERY_TIMEOUT_MS`
|
||||
- `NOSTR_LOCK_PUBLISH_TIMEOUT_MS`
|
||||
- `NOSTR_LOCK_MIN_SUCCESSFUL_PUBLISHES`
|
||||
- `NOSTR_LOCK_RELAY_FALLBACKS`
|
||||
- `NOSTR_LOCK_DAILY_ROSTER`
|
||||
- `NOSTR_LOCK_WEEKLY_ROSTER`
|
||||
- `TORCH_CONFIG_PATH`
|
||||
@@ -50,7 +53,10 @@ Common settings:
|
||||
- `nostrLock.namespace` — namespace prefix used in d-tags and hashtags.
|
||||
- `nostrLock.relays` — relay list for check/lock/list operations.
|
||||
- `nostrLock.ttlSeconds` — default lock TTL.
|
||||
- `nostrLock.queryTimeoutMs` — relay query timeout.
|
||||
- `nostrLock.queryTimeoutMs` — relay query timeout (ms, valid range: 100..120000).
|
||||
- `nostrLock.publishTimeoutMs` — per-relay publish timeout (ms, valid range: 100..120000).
|
||||
- `nostrLock.minSuccessfulRelayPublishes` — minimum successful publishes required before lock acquisition continues (default: `1`).
|
||||
- `nostrLock.relayFallbacks` — optional fallback relay URLs used when primary query/publish attempts fail quorum.
|
||||
- `nostrLock.dailyRoster` / `nostrLock.weeklyRoster` — optional per-project roster overrides.
|
||||
- `dashboard.defaultCadenceView` — default dashboard view (`daily`, `weekly`, `all`).
|
||||
- `dashboard.defaultStatusView` — default dashboard status filter (`active`, `all`).
|
||||
@@ -64,6 +70,23 @@ For weekly repository-fit maintenance, TORCH also includes `src/prompts/weekly/r
|
||||
|
||||
Operational note: scheduler handoff commands are treated as required execution steps. A non-zero exit code (or missing command) is a hard failure: the scheduler writes a `_failed.md` task log, exits immediately, and does not publish `lock:complete` for that run.
|
||||
|
||||
|
||||
## Lock backend production defaults
|
||||
|
||||
Recommended baseline for production scheduler runs:
|
||||
|
||||
- `nostrLock.relays`: 3+ geographically-diverse primary relays.
|
||||
- `nostrLock.relayFallbacks`: 2 additional relays not present in primary list.
|
||||
- `nostrLock.queryTimeoutMs`: `10000`
|
||||
- `nostrLock.publishTimeoutMs`: `8000`
|
||||
- `nostrLock.minSuccessfulRelayPublishes`: `2`
|
||||
|
||||
Validation behavior:
|
||||
|
||||
- Relay URLs must be absolute `ws://` or `wss://` URLs.
|
||||
- Invalid relay URLs or invalid timeout/count ranges are fatal startup errors.
|
||||
- Lock backend errors include phase (`query:primary`, `query:fallback`, `publish:primary`, `publish:fallback`), relay endpoint, and timeout value used.
|
||||
|
||||
## Scheduler lock reliability reporting
|
||||
|
||||
Run the lock reliability summary to aggregate recent scheduler outcomes by platform, cadence, backend error category, and relay endpoint:
|
||||
|
||||
+128
-38
@@ -1,5 +1,10 @@
|
||||
import { SimplePool } from 'nostr-tools/pool';
|
||||
import { getQueryTimeoutMs } from './torch-config.mjs';
|
||||
import {
|
||||
getQueryTimeoutMs,
|
||||
getPublishTimeoutMs,
|
||||
getMinSuccessfulRelayPublishes,
|
||||
getRelayFallbacks,
|
||||
} from './torch-config.mjs';
|
||||
import { KIND_APP_DATA } from './constants.mjs';
|
||||
|
||||
function nowUnix() {
|
||||
@@ -42,56 +47,141 @@ function filterActiveLocks(locks) {
|
||||
return locks.filter((lock) => !lock.expiresAt || lock.expiresAt > now);
|
||||
}
|
||||
|
||||
export async function queryLocks(relays, cadence, dateStr, namespace) {
|
||||
const pool = new SimplePool();
|
||||
const tagFilter = `${namespace}-lock-${cadence}-${dateStr}`;
|
||||
const queryTimeoutMs = getQueryTimeoutMs();
|
||||
|
||||
function withTimeout(promise, timeoutMs, timeoutMessage) {
|
||||
let timeoutHandle;
|
||||
const timeoutPromise = new Promise((_, reject) => {
|
||||
timeoutHandle = setTimeout(() => reject(new Error(timeoutMessage)), timeoutMs);
|
||||
});
|
||||
return Promise.race([promise, timeoutPromise]).finally(() => {
|
||||
if (timeoutHandle) clearTimeout(timeoutHandle);
|
||||
});
|
||||
}
|
||||
|
||||
function relayListLabel(relays) {
|
||||
return relays.join(', ');
|
||||
}
|
||||
|
||||
export async function queryLocks(relays, cadence, dateStr, namespace, deps = {}) {
|
||||
const {
|
||||
poolFactory = () => new SimplePool(),
|
||||
getQueryTimeoutMsFn = getQueryTimeoutMs,
|
||||
getRelayFallbacksFn = getRelayFallbacks,
|
||||
errorLogger = console.error,
|
||||
} = deps;
|
||||
|
||||
const pool = poolFactory();
|
||||
const tagFilter = `${namespace}-lock-${cadence}-${dateStr}`;
|
||||
const queryTimeoutMs = getQueryTimeoutMsFn();
|
||||
const fallbackRelays = getRelayFallbacksFn();
|
||||
|
||||
const runQuery = async (relaySet, phase) => {
|
||||
try {
|
||||
const events = await withTimeout(
|
||||
pool.querySync(relaySet, {
|
||||
kinds: [KIND_APP_DATA],
|
||||
'#t': [tagFilter],
|
||||
}),
|
||||
queryTimeoutMs,
|
||||
`[${phase}] Relay query timed out after ${queryTimeoutMs}ms (relays: ${relayListLabel(relaySet)})`,
|
||||
);
|
||||
return filterActiveLocks(events.map(parseLockEvent));
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
throw new Error(
|
||||
`[${phase}] Relay query failed (timeout=${queryTimeoutMs}ms, relays=${relayListLabel(relaySet)}): ${message}`,
|
||||
{ cause: err },
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
try {
|
||||
const events = await Promise.race([
|
||||
pool.querySync(relays, {
|
||||
kinds: [KIND_APP_DATA],
|
||||
'#t': [tagFilter],
|
||||
}),
|
||||
new Promise((_, reject) => {
|
||||
timeoutHandle = setTimeout(() => reject(new Error('Relay query timed out')), queryTimeoutMs);
|
||||
}),
|
||||
]);
|
||||
|
||||
return filterActiveLocks(events.map(parseLockEvent));
|
||||
} finally {
|
||||
if (timeoutHandle) {
|
||||
clearTimeout(timeoutHandle);
|
||||
try {
|
||||
return await runQuery(relays, 'query:primary');
|
||||
} catch (primaryErr) {
|
||||
if (!fallbackRelays.length) {
|
||||
throw primaryErr;
|
||||
}
|
||||
errorLogger(`WARN: ${primaryErr.message}`);
|
||||
errorLogger(`WARN: retrying query with fallback relays (${relayListLabel(fallbackRelays)})`);
|
||||
return await runQuery(fallbackRelays, 'query:fallback');
|
||||
}
|
||||
pool.close(relays);
|
||||
} finally {
|
||||
const allRelays = [...new Set([...relays, ...fallbackRelays])];
|
||||
pool.close(allRelays);
|
||||
}
|
||||
}
|
||||
|
||||
export async function publishLock(relays, event) {
|
||||
const pool = new SimplePool();
|
||||
async function publishToRelays(pool, relays, event, publishTimeoutMs, phase) {
|
||||
const publishPromises = pool.publish(relays, event);
|
||||
const settled = await Promise.allSettled(
|
||||
publishPromises.map((publishPromise, index) => withTimeout(
|
||||
publishPromise,
|
||||
publishTimeoutMs,
|
||||
`[${phase}] Publish timed out after ${publishTimeoutMs}ms (relay=${relays[index]})`,
|
||||
)),
|
||||
);
|
||||
|
||||
return settled.map((result, index) => {
|
||||
if (result.status === 'fulfilled') {
|
||||
return { relay: relays[index], success: true, phase };
|
||||
}
|
||||
const reason = result.reason;
|
||||
const message = reason instanceof Error ? reason.message : String(reason ?? 'unknown');
|
||||
return { relay: relays[index], success: false, phase, message };
|
||||
});
|
||||
}
|
||||
|
||||
export async function publishLock(relays, event, deps = {}) {
|
||||
const {
|
||||
poolFactory = () => new SimplePool(),
|
||||
getPublishTimeoutMsFn = getPublishTimeoutMs,
|
||||
getMinSuccessfulRelayPublishesFn = getMinSuccessfulRelayPublishes,
|
||||
getRelayFallbacksFn = getRelayFallbacks,
|
||||
} = deps;
|
||||
|
||||
const pool = poolFactory();
|
||||
const publishTimeoutMs = getPublishTimeoutMsFn();
|
||||
const minSuccesses = getMinSuccessfulRelayPublishesFn();
|
||||
const fallbackRelays = getRelayFallbacksFn().filter((relay) => !relays.includes(relay));
|
||||
|
||||
const attempted = new Set();
|
||||
const publishResults = [];
|
||||
|
||||
const attemptPhase = async (phaseRelays, phaseName) => {
|
||||
if (!phaseRelays.length) return;
|
||||
for (const relay of phaseRelays) {
|
||||
attempted.add(relay);
|
||||
}
|
||||
const phaseResults = await publishToRelays(pool, phaseRelays, event, publishTimeoutMs, phaseName);
|
||||
publishResults.push(...phaseResults);
|
||||
};
|
||||
|
||||
try {
|
||||
const publishPromises = pool.publish(relays, event);
|
||||
const results = await Promise.allSettled(publishPromises);
|
||||
const successes = results.filter((r) => r.status === 'fulfilled');
|
||||
await attemptPhase(relays, 'publish:primary');
|
||||
|
||||
if (successes.length === 0) {
|
||||
const errors = results.map((r, i) => {
|
||||
if (r.status === 'rejected') {
|
||||
const reason = r.reason;
|
||||
const message = reason instanceof Error ? reason.message : String(reason ?? 'unknown');
|
||||
return `${relays[i]}: ${message}`;
|
||||
}
|
||||
return `${relays[i]}: unknown`;
|
||||
});
|
||||
throw new Error(`Failed to publish to any relay:\n ${errors.join('\n ')}`);
|
||||
let successCount = publishResults.filter((result) => result.success).length;
|
||||
if (successCount < minSuccesses && fallbackRelays.length > 0) {
|
||||
await attemptPhase(fallbackRelays, 'publish:fallback');
|
||||
successCount = publishResults.filter((result) => result.success).length;
|
||||
}
|
||||
|
||||
console.error(` Published to ${successes.length}/${relays.length} relays`);
|
||||
if (successCount < minSuccesses) {
|
||||
const failureLines = publishResults
|
||||
.filter((result) => !result.success)
|
||||
.map((result) => `${result.relay} (${result.phase}): ${result.message}`);
|
||||
|
||||
throw new Error(
|
||||
`Failed relay publish quorum in publish phase: ${successCount}/${attempted.size} successful ` +
|
||||
`(required=${minSuccesses}, timeout=${publishTimeoutMs}ms)\n ${failureLines.join('\n ')}`,
|
||||
);
|
||||
}
|
||||
|
||||
console.error(
|
||||
` Published to ${successCount}/${attempted.size} relays ` +
|
||||
`(required=${minSuccesses}, timeout=${publishTimeoutMs}ms)`,
|
||||
);
|
||||
return event;
|
||||
} finally {
|
||||
pool.close(relays);
|
||||
pool.close([...attempted]);
|
||||
}
|
||||
}
|
||||
+129
-7
@@ -5,11 +5,52 @@ import {
|
||||
DEFAULT_TTL,
|
||||
DEFAULT_NAMESPACE,
|
||||
DEFAULT_QUERY_TIMEOUT_MS,
|
||||
DEFAULT_PUBLISH_TIMEOUT_MS,
|
||||
DEFAULT_MIN_SUCCESSFUL_PUBLISHES,
|
||||
} from './constants.mjs';
|
||||
|
||||
const DEFAULT_CONFIG_PATH = 'torch-config.json';
|
||||
|
||||
let cachedConfig = null;
|
||||
const MIN_TIMEOUT_MS = 100;
|
||||
const MAX_TIMEOUT_MS = 120_000;
|
||||
|
||||
function parsePositiveInteger(value) {
|
||||
if (!Number.isFinite(value) || value <= 0) return null;
|
||||
return Math.floor(value);
|
||||
}
|
||||
|
||||
function parseRelayList(value) {
|
||||
if (!Array.isArray(value)) return null;
|
||||
const parsed = value.map((relay) => String(relay).trim()).filter(Boolean);
|
||||
return parsed.length > 0 ? parsed : null;
|
||||
}
|
||||
|
||||
function assertValidRelayUrl(relay, sourceLabel) {
|
||||
let parsed;
|
||||
try {
|
||||
parsed = new URL(relay);
|
||||
} catch {
|
||||
throw new Error(`Invalid relay URL in ${sourceLabel}: "${relay}" (must be an absolute ws:// or wss:// URL)`);
|
||||
}
|
||||
if (parsed.protocol !== 'ws:' && parsed.protocol !== 'wss:') {
|
||||
throw new Error(`Invalid relay URL in ${sourceLabel}: "${relay}" (protocol must be ws:// or wss://)`);
|
||||
}
|
||||
}
|
||||
|
||||
function assertTimeoutInRange(value, sourceLabel) {
|
||||
if (!Number.isInteger(value) || value < MIN_TIMEOUT_MS || value > MAX_TIMEOUT_MS) {
|
||||
throw new Error(
|
||||
`Invalid ${sourceLabel}: ${value} (must be an integer between ${MIN_TIMEOUT_MS} and ${MAX_TIMEOUT_MS} ms)`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function assertPositiveCount(value, sourceLabel) {
|
||||
if (!Number.isInteger(value) || value <= 0) {
|
||||
throw new Error(`Invalid ${sourceLabel}: ${value} (must be a positive integer)`);
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeCadence(value, fallback) {
|
||||
const normalized = String(value || '').trim().toLowerCase();
|
||||
@@ -48,12 +89,13 @@ export function parseTorchConfig(raw, configPath = null) {
|
||||
relays: Array.isArray(nostrLock.relays)
|
||||
? nostrLock.relays.map((relay) => String(relay).trim()).filter(Boolean)
|
||||
: null,
|
||||
relayFallbacks: parseRelayList(nostrLock.relayFallbacks),
|
||||
ttlSeconds: Number.isFinite(nostrLock.ttlSeconds) && nostrLock.ttlSeconds > 0
|
||||
? Math.floor(nostrLock.ttlSeconds)
|
||||
: null,
|
||||
queryTimeoutMs: Number.isFinite(nostrLock.queryTimeoutMs) && nostrLock.queryTimeoutMs > 0
|
||||
? Math.floor(nostrLock.queryTimeoutMs)
|
||||
: null,
|
||||
queryTimeoutMs: parsePositiveInteger(nostrLock.queryTimeoutMs),
|
||||
publishTimeoutMs: parsePositiveInteger(nostrLock.publishTimeoutMs),
|
||||
minSuccessfulRelayPublishes: parsePositiveInteger(nostrLock.minSuccessfulRelayPublishes),
|
||||
dailyRoster: parseRoster(nostrLock.dailyRoster),
|
||||
weeklyRoster: parseRoster(nostrLock.weeklyRoster),
|
||||
},
|
||||
@@ -99,21 +141,72 @@ export function loadTorchConfig(fileSystem = fs) {
|
||||
}
|
||||
|
||||
cachedConfig = parseTorchConfig(raw, configPath);
|
||||
validateLockBackendConfig(cachedConfig);
|
||||
return cachedConfig;
|
||||
}
|
||||
|
||||
function validateLockBackendConfig(config) {
|
||||
const relays = config.nostrLock.relays || [];
|
||||
const relayFallbacks = config.nostrLock.relayFallbacks || [];
|
||||
|
||||
for (const relay of relays) {
|
||||
assertValidRelayUrl(relay, 'nostrLock.relays');
|
||||
}
|
||||
for (const relay of relayFallbacks) {
|
||||
assertValidRelayUrl(relay, 'nostrLock.relayFallbacks');
|
||||
}
|
||||
|
||||
if (config.nostrLock.queryTimeoutMs !== null) {
|
||||
assertTimeoutInRange(config.nostrLock.queryTimeoutMs, 'nostrLock.queryTimeoutMs');
|
||||
}
|
||||
if (config.nostrLock.publishTimeoutMs !== null) {
|
||||
assertTimeoutInRange(config.nostrLock.publishTimeoutMs, 'nostrLock.publishTimeoutMs');
|
||||
}
|
||||
if (config.nostrLock.minSuccessfulRelayPublishes !== null) {
|
||||
assertPositiveCount(config.nostrLock.minSuccessfulRelayPublishes, 'nostrLock.minSuccessfulRelayPublishes');
|
||||
}
|
||||
}
|
||||
|
||||
function parseEnvRelayList(envValue, envName) {
|
||||
const relays = envValue.split(',').map((r) => r.trim()).filter(Boolean);
|
||||
for (const relay of relays) {
|
||||
assertValidRelayUrl(relay, envName);
|
||||
}
|
||||
return relays;
|
||||
}
|
||||
|
||||
function parseEnvInteger(envValue, envName) {
|
||||
const parsed = parseInt(envValue, 10);
|
||||
if (Number.isNaN(parsed)) {
|
||||
throw new Error(`Invalid ${envName}: "${envValue}" (must be an integer)`);
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
export function getRelays() {
|
||||
const config = loadTorchConfig();
|
||||
const envRelays = process.env.NOSTR_LOCK_RELAYS;
|
||||
if (envRelays) {
|
||||
return envRelays.split(',').map((r) => r.trim()).filter(Boolean);
|
||||
return parseEnvRelayList(envRelays, 'NOSTR_LOCK_RELAYS');
|
||||
}
|
||||
if (config.nostrLock.relays?.length) {
|
||||
return config.nostrLock.relays;
|
||||
}
|
||||
for (const relay of DEFAULT_RELAYS) {
|
||||
assertValidRelayUrl(relay, 'DEFAULT_RELAYS');
|
||||
}
|
||||
return DEFAULT_RELAYS;
|
||||
}
|
||||
|
||||
export function getRelayFallbacks() {
|
||||
const config = loadTorchConfig();
|
||||
const envRelays = process.env.NOSTR_LOCK_RELAY_FALLBACKS;
|
||||
if (envRelays) {
|
||||
return parseEnvRelayList(envRelays, 'NOSTR_LOCK_RELAY_FALLBACKS');
|
||||
}
|
||||
return config.nostrLock.relayFallbacks || [];
|
||||
}
|
||||
|
||||
export function getNamespace() {
|
||||
const config = loadTorchConfig();
|
||||
const namespace = (process.env.NOSTR_LOCK_NAMESPACE || config.nostrLock.namespace || DEFAULT_NAMESPACE).trim();
|
||||
@@ -137,8 +230,37 @@ export function getQueryTimeoutMs() {
|
||||
const config = loadTorchConfig();
|
||||
const envValue = process.env.NOSTR_LOCK_QUERY_TIMEOUT_MS;
|
||||
if (envValue) {
|
||||
const parsed = parseInt(envValue, 10);
|
||||
if (!Number.isNaN(parsed) && parsed > 0) return parsed;
|
||||
const parsed = parseEnvInteger(envValue, 'NOSTR_LOCK_QUERY_TIMEOUT_MS');
|
||||
assertTimeoutInRange(parsed, 'NOSTR_LOCK_QUERY_TIMEOUT_MS');
|
||||
return parsed;
|
||||
}
|
||||
return config.nostrLock.queryTimeoutMs || DEFAULT_QUERY_TIMEOUT_MS;
|
||||
const value = config.nostrLock.queryTimeoutMs || DEFAULT_QUERY_TIMEOUT_MS;
|
||||
assertTimeoutInRange(value, 'effective query timeout');
|
||||
return value;
|
||||
}
|
||||
|
||||
export function getPublishTimeoutMs() {
|
||||
const config = loadTorchConfig();
|
||||
const envValue = process.env.NOSTR_LOCK_PUBLISH_TIMEOUT_MS;
|
||||
if (envValue) {
|
||||
const parsed = parseEnvInteger(envValue, 'NOSTR_LOCK_PUBLISH_TIMEOUT_MS');
|
||||
assertTimeoutInRange(parsed, 'NOSTR_LOCK_PUBLISH_TIMEOUT_MS');
|
||||
return parsed;
|
||||
}
|
||||
const value = config.nostrLock.publishTimeoutMs || DEFAULT_PUBLISH_TIMEOUT_MS;
|
||||
assertTimeoutInRange(value, 'effective publish timeout');
|
||||
return value;
|
||||
}
|
||||
|
||||
export function getMinSuccessfulRelayPublishes() {
|
||||
const config = loadTorchConfig();
|
||||
const envValue = process.env.NOSTR_LOCK_MIN_SUCCESSFUL_PUBLISHES;
|
||||
if (envValue) {
|
||||
const parsed = parseEnvInteger(envValue, 'NOSTR_LOCK_MIN_SUCCESSFUL_PUBLISHES');
|
||||
assertPositiveCount(parsed, 'NOSTR_LOCK_MIN_SUCCESSFUL_PUBLISHES');
|
||||
return parsed;
|
||||
}
|
||||
const value = config.nostrLock.minSuccessfulRelayPublishes || DEFAULT_MIN_SUCCESSFUL_PUBLISHES;
|
||||
assertPositiveCount(value, 'effective min successful relay publishes');
|
||||
return value;
|
||||
}
|
||||
Reference in new issue
Block a user