forked from coracle/caravel
Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 45ffee16ef | |||
| 48f20dc1a5 |
@@ -15,12 +15,15 @@ Members:
|
||||
## `pub async fn start(self)`
|
||||
|
||||
- Subscribes to `command.notify`
|
||||
- On startup, schedules delayed sync retries for relays whose `sync_error` is non-empty.
|
||||
- Loops on `rx.recv()`, calling `handle_activity` for each received `Activity`.
|
||||
|
||||
## `async fn handle_activity(&self, activity: &Activity)`
|
||||
|
||||
- For `create_relay`, `update_relay`, `activate_relay`, or `deactivate_relay` activity, calls `sync_and_report`.
|
||||
- All other activity types are ignored (e.g. `fail_relay_sync`, `complete_relay_sync`).
|
||||
- For `create_relay`, `update_relay`, `activate_relay`, or `deactivate_relay` activity, calls `sync_and_report` immediately.
|
||||
- For `fail_relay_sync`, schedules a delayed retry using exponential backoff based on consecutive failures for the relay.
|
||||
- Retry scheduling stops after the configured max attempts to avoid infinite retry loops.
|
||||
- Other activity types are ignored (e.g. `complete_relay_sync`).
|
||||
|
||||
## `async fn sync_and_report(&self, relay: &Relay, is_new: bool)`
|
||||
|
||||
|
||||
+41
-11
@@ -17,6 +17,9 @@ type HmacSha256 = Hmac<Sha256>;
|
||||
const STRIPE_API: &str = "https://api.stripe.com/v1";
|
||||
const COINBASE_SPOT_API: &str = "https://api.coinbase.com/v2/prices";
|
||||
const WEBHOOK_TOLERANCE_SECS: i64 = 300;
|
||||
const MANUAL_LIGHTNING_PAYMENT_DM: &str = "Payment is due for your relay subscription. Please visit the application to complete a manual Lightning payment.";
|
||||
const NWC_ERROR_DM_PREFIX: &str = "NWC auto-payment failed:";
|
||||
const NWC_ERROR_DM_MAX_CHARS: usize = 240;
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum InvoiceLookupError {
|
||||
@@ -80,7 +83,6 @@ pub struct Billing {
|
||||
nwc_url: String,
|
||||
stripe_secret_key: String,
|
||||
stripe_webhook_secret: String,
|
||||
btc_quote_api_base: String,
|
||||
http: reqwest::Client,
|
||||
query: Query,
|
||||
command: Command,
|
||||
@@ -98,13 +100,10 @@ impl Billing {
|
||||
if stripe_webhook_secret.trim().is_empty() {
|
||||
panic!("missing STRIPE_WEBHOOK_SECRET environment variable");
|
||||
}
|
||||
let btc_quote_api_base =
|
||||
std::env::var("BTC_PRICE_API_BASE").unwrap_or_else(|_| COINBASE_SPOT_API.to_string());
|
||||
Self {
|
||||
nwc_url,
|
||||
stripe_secret_key,
|
||||
stripe_webhook_secret,
|
||||
btc_quote_api_base,
|
||||
http: reqwest::Client::new(),
|
||||
query,
|
||||
command,
|
||||
@@ -360,6 +359,8 @@ impl Billing {
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
let mut nwc_error_for_dm: Option<String> = None;
|
||||
|
||||
// 1. NWC auto-pay: if the tenant has a nwc_url
|
||||
if !tenant.nwc_url.is_empty() {
|
||||
match self
|
||||
@@ -376,6 +377,14 @@ impl Billing {
|
||||
self.command
|
||||
.set_tenant_nwc_error(&tenant.pubkey, &error_msg)
|
||||
.await?;
|
||||
tracing::warn!(
|
||||
error = %e,
|
||||
tenant_pubkey = %tenant.pubkey,
|
||||
stripe_customer_id,
|
||||
invoice_id,
|
||||
"nwc auto-payment failed for invoice.created"
|
||||
);
|
||||
nwc_error_for_dm = summarize_nwc_error_for_dm(&error_msg);
|
||||
// Fall through to next option
|
||||
}
|
||||
}
|
||||
@@ -390,12 +399,8 @@ impl Billing {
|
||||
}
|
||||
|
||||
// 3. Manual payment: send a DM
|
||||
self.robot
|
||||
.send_dm(
|
||||
&tenant.pubkey,
|
||||
"Payment is due for your relay subscription. Please visit the application to complete a manual Lightning payment.",
|
||||
)
|
||||
.await?;
|
||||
let dm_message = manual_lightning_payment_dm(nwc_error_for_dm.as_deref());
|
||||
self.robot.send_dm(&tenant.pubkey, &dm_message).await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -1057,7 +1062,7 @@ impl Billing {
|
||||
}
|
||||
|
||||
async fn fetch_btc_spot_price(&self, currency: &str) -> Result<f64> {
|
||||
fetch_btc_spot_price_from_base(&self.http, &self.btc_quote_api_base, currency).await
|
||||
fetch_btc_spot_price_from_base(&self.http, COINBASE_SPOT_API, currency).await
|
||||
}
|
||||
|
||||
fn currency_minor_exponent(currency: &str) -> Result<u8> {
|
||||
@@ -1101,6 +1106,31 @@ pub async fn fetch_btc_spot_price_from_base(
|
||||
Ok(amount)
|
||||
}
|
||||
|
||||
fn summarize_nwc_error_for_dm(error: &str) -> Option<String> {
|
||||
let normalized = error.split_whitespace().collect::<Vec<_>>().join(" ");
|
||||
if normalized.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
if normalized.chars().count() <= NWC_ERROR_DM_MAX_CHARS {
|
||||
return Some(normalized);
|
||||
}
|
||||
|
||||
let prefix_len = NWC_ERROR_DM_MAX_CHARS.saturating_sub(3);
|
||||
let mut truncated = normalized.chars().take(prefix_len).collect::<String>();
|
||||
truncated.push_str("...");
|
||||
Some(truncated)
|
||||
}
|
||||
|
||||
fn manual_lightning_payment_dm(nwc_error: Option<&str>) -> String {
|
||||
match nwc_error {
|
||||
Some(error) if !error.is_empty() => {
|
||||
format!("{MANUAL_LIGHTNING_PAYMENT_DM}\n\n{NWC_ERROR_DM_PREFIX} {error}")
|
||||
}
|
||||
_ => MANUAL_LIGHTNING_PAYMENT_DM.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn fiat_minor_to_msats_from_quote(
|
||||
amount_due_minor: i64,
|
||||
currency: &str,
|
||||
|
||||
+108
-8
@@ -1,10 +1,15 @@
|
||||
use anyhow::Result;
|
||||
use nostr_sdk::prelude::*;
|
||||
use std::time::Duration;
|
||||
|
||||
use crate::command::Command;
|
||||
use crate::models::{Activity, RELAY_STATUS_DELINQUENT, RELAY_STATUS_INACTIVE, Relay};
|
||||
use crate::query::Query;
|
||||
|
||||
const RELAY_SYNC_RETRY_BASE_DELAY_SECS: u64 = 30;
|
||||
const RELAY_SYNC_RETRY_MAX_DELAY_SECS: u64 = 15 * 60;
|
||||
const RELAY_SYNC_RETRY_MAX_ATTEMPTS: usize = 6;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct Infra {
|
||||
api_url: String,
|
||||
@@ -48,6 +53,10 @@ impl Infra {
|
||||
pub async fn start(self) {
|
||||
let mut rx = self.command.notify.subscribe();
|
||||
|
||||
if let Err(e) = self.schedule_startup_retries().await {
|
||||
tracing::error!(error = %e, "failed to schedule relay sync retries on startup");
|
||||
}
|
||||
|
||||
loop {
|
||||
match rx.recv().await {
|
||||
Ok(activity) => {
|
||||
@@ -66,15 +75,84 @@ impl Infra {
|
||||
async fn handle_activity(&self, activity: &Activity) -> Result<()> {
|
||||
let needs_sync = should_sync_relay_activity(activity.activity_type.as_str());
|
||||
|
||||
if needs_sync {
|
||||
let Some(relay) = self.query.get_relay(&activity.resource_id).await? else {
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
let is_new = relay.synced == 0;
|
||||
self.sync_and_report(&relay, is_new).await;
|
||||
if !needs_sync || activity.resource_type != "relay" {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
if activity.activity_type == "fail_relay_sync" {
|
||||
self.schedule_relay_sync_retry(&activity.resource_id, "activity")
|
||||
.await?;
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let Some(relay) = self.query.get_relay(&activity.resource_id).await? else {
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
let is_new = relay.synced == 0;
|
||||
self.sync_and_report(&relay, is_new).await;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn schedule_startup_retries(&self) -> Result<()> {
|
||||
let relays = self.query.list_relays_with_sync_error().await?;
|
||||
|
||||
for relay in relays {
|
||||
self.schedule_relay_sync_retry(&relay.id, "startup").await?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn schedule_relay_sync_retry(&self, relay_id: &str, source: &str) -> Result<()> {
|
||||
let activities = self.query.list_activity_for_relay(relay_id).await?;
|
||||
let consecutive_failures = consecutive_sync_failures(&activities);
|
||||
|
||||
let Some(delay) = relay_sync_retry_delay(consecutive_failures) else {
|
||||
tracing::warn!(
|
||||
relay = relay_id,
|
||||
consecutive_failures,
|
||||
max_attempts = RELAY_SYNC_RETRY_MAX_ATTEMPTS,
|
||||
"relay sync retries exhausted; awaiting manual intervention"
|
||||
);
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
tracing::info!(
|
||||
relay = relay_id,
|
||||
source,
|
||||
consecutive_failures,
|
||||
delay_secs = delay.as_secs(),
|
||||
"scheduled relay sync retry"
|
||||
);
|
||||
|
||||
let relay_id = relay_id.to_string();
|
||||
let infra = self.clone();
|
||||
|
||||
tokio::spawn(async move {
|
||||
tokio::time::sleep(delay).await;
|
||||
|
||||
if let Err(e) = infra.retry_relay_sync(&relay_id).await {
|
||||
tracing::error!(relay = %relay_id, error = %e, "relay sync retry task failed");
|
||||
}
|
||||
});
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn retry_relay_sync(&self, relay_id: &str) -> Result<()> {
|
||||
let Some(relay) = self.query.get_relay(relay_id).await? else {
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
if relay.sync_error.trim().is_empty() {
|
||||
tracing::debug!(relay = %relay.id, "skip relay sync retry; relay has no sync_error");
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let is_new = relay.synced == 0;
|
||||
self.sync_and_report(&relay, is_new).await;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -258,6 +336,28 @@ fn relay_sync_body(
|
||||
fn should_sync_relay_activity(activity_type: &str) -> bool {
|
||||
matches!(
|
||||
activity_type,
|
||||
"create_relay" | "update_relay" | "activate_relay" | "deactivate_relay"
|
||||
"create_relay" | "update_relay" | "activate_relay" | "deactivate_relay" | "fail_relay_sync"
|
||||
)
|
||||
}
|
||||
|
||||
fn consecutive_sync_failures(activities: &[Activity]) -> usize {
|
||||
activities
|
||||
.iter()
|
||||
.take_while(|activity| activity.activity_type == "fail_relay_sync")
|
||||
.count()
|
||||
}
|
||||
|
||||
fn relay_sync_retry_delay(consecutive_failures: usize) -> Option<Duration> {
|
||||
let retry_attempt = consecutive_failures.max(1);
|
||||
if retry_attempt > RELAY_SYNC_RETRY_MAX_ATTEMPTS {
|
||||
return None;
|
||||
}
|
||||
|
||||
let exponent = (retry_attempt - 1).min(31);
|
||||
let multiplier = 1u64 << exponent;
|
||||
let delay_secs = RELAY_SYNC_RETRY_BASE_DELAY_SECS
|
||||
.saturating_mul(multiplier)
|
||||
.min(RELAY_SYNC_RETRY_MAX_DELAY_SECS);
|
||||
|
||||
Some(Duration::from_secs(delay_secs))
|
||||
}
|
||||
|
||||
@@ -94,6 +94,23 @@ impl Query {
|
||||
Ok(rows)
|
||||
}
|
||||
|
||||
pub async fn list_relays_with_sync_error(&self) -> Result<Vec<Relay>> {
|
||||
let rows = sqlx::query_as::<_, Relay>(
|
||||
"SELECT id, tenant, schema, subdomain, plan, stripe_subscription_item_id,
|
||||
status, sync_error,
|
||||
info_name, info_icon, info_description,
|
||||
policy_public_join, policy_strip_signatures,
|
||||
groups_enabled, management_enabled, blossom_enabled,
|
||||
livekit_enabled, push_enabled, synced
|
||||
FROM relay
|
||||
WHERE TRIM(sync_error) != ''
|
||||
ORDER BY id",
|
||||
)
|
||||
.fetch_all(&self.pool)
|
||||
.await?;
|
||||
Ok(rows)
|
||||
}
|
||||
|
||||
pub async fn list_relays_for_tenant(&self, tenant_id: &str) -> Result<Vec<Relay>> {
|
||||
let rows = sqlx::query_as::<_, Relay>(
|
||||
"SELECT id, tenant, schema, subdomain, plan, stripe_subscription_item_id,
|
||||
|
||||
Reference in New Issue
Block a user