forked from coracle/caravel
Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 45ffee16ef | |||
| 48f20dc1a5 |
@@ -15,12 +15,15 @@ Members:
|
|||||||
## `pub async fn start(self)`
|
## `pub async fn start(self)`
|
||||||
|
|
||||||
- Subscribes to `command.notify`
|
- 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`.
|
- Loops on `rx.recv()`, calling `handle_activity` for each received `Activity`.
|
||||||
|
|
||||||
## `async fn handle_activity(&self, activity: &Activity)`
|
## `async fn handle_activity(&self, activity: &Activity)`
|
||||||
|
|
||||||
- For `create_relay`, `update_relay`, `activate_relay`, or `deactivate_relay` activity, calls `sync_and_report`.
|
- For `create_relay`, `update_relay`, `activate_relay`, or `deactivate_relay` activity, calls `sync_and_report` immediately.
|
||||||
- All other activity types are ignored (e.g. `fail_relay_sync`, `complete_relay_sync`).
|
- 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)`
|
## `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 STRIPE_API: &str = "https://api.stripe.com/v1";
|
||||||
const COINBASE_SPOT_API: &str = "https://api.coinbase.com/v2/prices";
|
const COINBASE_SPOT_API: &str = "https://api.coinbase.com/v2/prices";
|
||||||
const WEBHOOK_TOLERANCE_SECS: i64 = 300;
|
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)]
|
#[derive(Debug)]
|
||||||
pub enum InvoiceLookupError {
|
pub enum InvoiceLookupError {
|
||||||
@@ -80,7 +83,6 @@ pub struct Billing {
|
|||||||
nwc_url: String,
|
nwc_url: String,
|
||||||
stripe_secret_key: String,
|
stripe_secret_key: String,
|
||||||
stripe_webhook_secret: String,
|
stripe_webhook_secret: String,
|
||||||
btc_quote_api_base: String,
|
|
||||||
http: reqwest::Client,
|
http: reqwest::Client,
|
||||||
query: Query,
|
query: Query,
|
||||||
command: Command,
|
command: Command,
|
||||||
@@ -98,13 +100,10 @@ impl Billing {
|
|||||||
if stripe_webhook_secret.trim().is_empty() {
|
if stripe_webhook_secret.trim().is_empty() {
|
||||||
panic!("missing STRIPE_WEBHOOK_SECRET environment variable");
|
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 {
|
Self {
|
||||||
nwc_url,
|
nwc_url,
|
||||||
stripe_secret_key,
|
stripe_secret_key,
|
||||||
stripe_webhook_secret,
|
stripe_webhook_secret,
|
||||||
btc_quote_api_base,
|
|
||||||
http: reqwest::Client::new(),
|
http: reqwest::Client::new(),
|
||||||
query,
|
query,
|
||||||
command,
|
command,
|
||||||
@@ -360,6 +359,8 @@ impl Billing {
|
|||||||
return Ok(());
|
return Ok(());
|
||||||
};
|
};
|
||||||
|
|
||||||
|
let mut nwc_error_for_dm: Option<String> = None;
|
||||||
|
|
||||||
// 1. NWC auto-pay: if the tenant has a nwc_url
|
// 1. NWC auto-pay: if the tenant has a nwc_url
|
||||||
if !tenant.nwc_url.is_empty() {
|
if !tenant.nwc_url.is_empty() {
|
||||||
match self
|
match self
|
||||||
@@ -376,6 +377,14 @@ impl Billing {
|
|||||||
self.command
|
self.command
|
||||||
.set_tenant_nwc_error(&tenant.pubkey, &error_msg)
|
.set_tenant_nwc_error(&tenant.pubkey, &error_msg)
|
||||||
.await?;
|
.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
|
// Fall through to next option
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -390,12 +399,8 @@ impl Billing {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 3. Manual payment: send a DM
|
// 3. Manual payment: send a DM
|
||||||
self.robot
|
let dm_message = manual_lightning_payment_dm(nwc_error_for_dm.as_deref());
|
||||||
.send_dm(
|
self.robot.send_dm(&tenant.pubkey, &dm_message).await?;
|
||||||
&tenant.pubkey,
|
|
||||||
"Payment is due for your relay subscription. Please visit the application to complete a manual Lightning payment.",
|
|
||||||
)
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -1057,7 +1062,7 @@ impl Billing {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async fn fetch_btc_spot_price(&self, currency: &str) -> Result<f64> {
|
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> {
|
fn currency_minor_exponent(currency: &str) -> Result<u8> {
|
||||||
@@ -1101,6 +1106,31 @@ pub async fn fetch_btc_spot_price_from_base(
|
|||||||
Ok(amount)
|
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(
|
pub fn fiat_minor_to_msats_from_quote(
|
||||||
amount_due_minor: i64,
|
amount_due_minor: i64,
|
||||||
currency: &str,
|
currency: &str,
|
||||||
|
|||||||
+108
-8
@@ -1,10 +1,15 @@
|
|||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
use nostr_sdk::prelude::*;
|
use nostr_sdk::prelude::*;
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
use crate::command::Command;
|
use crate::command::Command;
|
||||||
use crate::models::{Activity, RELAY_STATUS_DELINQUENT, RELAY_STATUS_INACTIVE, Relay};
|
use crate::models::{Activity, RELAY_STATUS_DELINQUENT, RELAY_STATUS_INACTIVE, Relay};
|
||||||
use crate::query::Query;
|
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)]
|
#[derive(Clone)]
|
||||||
pub struct Infra {
|
pub struct Infra {
|
||||||
api_url: String,
|
api_url: String,
|
||||||
@@ -48,6 +53,10 @@ impl Infra {
|
|||||||
pub async fn start(self) {
|
pub async fn start(self) {
|
||||||
let mut rx = self.command.notify.subscribe();
|
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 {
|
loop {
|
||||||
match rx.recv().await {
|
match rx.recv().await {
|
||||||
Ok(activity) => {
|
Ok(activity) => {
|
||||||
@@ -66,15 +75,84 @@ impl Infra {
|
|||||||
async fn handle_activity(&self, activity: &Activity) -> Result<()> {
|
async fn handle_activity(&self, activity: &Activity) -> Result<()> {
|
||||||
let needs_sync = should_sync_relay_activity(activity.activity_type.as_str());
|
let needs_sync = should_sync_relay_activity(activity.activity_type.as_str());
|
||||||
|
|
||||||
if needs_sync {
|
if !needs_sync || activity.resource_type != "relay" {
|
||||||
let Some(relay) = self.query.get_relay(&activity.resource_id).await? else {
|
return Ok(());
|
||||||
return Ok(());
|
|
||||||
};
|
|
||||||
|
|
||||||
let is_new = relay.synced == 0;
|
|
||||||
self.sync_and_report(&relay, is_new).await;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -258,6 +336,28 @@ fn relay_sync_body(
|
|||||||
fn should_sync_relay_activity(activity_type: &str) -> bool {
|
fn should_sync_relay_activity(activity_type: &str) -> bool {
|
||||||
matches!(
|
matches!(
|
||||||
activity_type,
|
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)
|
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>> {
|
pub async fn list_relays_for_tenant(&self, tenant_id: &str) -> Result<Vec<Relay>> {
|
||||||
let rows = sqlx::query_as::<_, Relay>(
|
let rows = sqlx::query_as::<_, Relay>(
|
||||||
"SELECT id, tenant, schema, subdomain, plan, stripe_subscription_item_id,
|
"SELECT id, tenant, schema, subdomain, plan, stripe_subscription_item_id,
|
||||||
|
|||||||
Reference in New Issue
Block a user