taler-rust

GNU Taler code in Rust. Largely core banking integrations.
Log | Files | Refs | Submodules | README | LICENSE

commit 43a28f56d4dfd8237c6c29c19de14ea526ec5245
parent 16b9ff784bda6409ded13a8cfb3d0ce1b0c759b0
Author: Antoine A <>
Date:   Wed, 14 Oct 2026 13:16:19 +0200

wise: improve worker to handle inconsistent statement pagination

Diffstat:
MCargo.lock | 25++++++++++++-------------
Madapters/taler-cyclos/src/setup.rs | 2+-
Madapters/taler-wise/Cargo.toml | 2--
Madapters/taler-wise/db/wise-0001.sql | 11++++++++---
Madapters/taler-wise/src/config.rs | 67++++++++++++++++++++++++++++++++++++++-----------------------------
Madapters/taler-wise/src/constants.rs | 5+++++
Madapters/taler-wise/src/db.rs | 103+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--
Madapters/taler-wise/src/main.rs | 13+++++++++----
Madapters/taler-wise/src/setup.rs | 110+++++++++++++++++++++++++++++++++++++++++++++++--------------------------------
Madapters/taler-wise/src/wise_api/client.rs | 39+++++++++++++++++++++++++++------------
Madapters/taler-wise/src/wise_api/types.rs | 15++++++++++-----
Madapters/taler-wise/src/worker.rs | 431++++++++++++++++++++++++++++++++++++++++---------------------------------------
Mcommon/taler-common/src/config.rs | 53+++++++++++++++++++++++++++++++++--------------------
Mtaler-apns-relay/src/apns.rs | 2+-
14 files changed, 531 insertions(+), 347 deletions(-)

diff --git a/Cargo.lock b/Cargo.lock @@ -741,9 +741,9 @@ dependencies = [ [[package]] name = "equivalent" -version = "1.0.2" +version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" +checksum = "00d174d5400e5e8fd687ad1049e2f578285fa914201b1af7e8b112a4546bd826" [[package]] name = "errno" @@ -1950,9 +1950,9 @@ dependencies = [ [[package]] name = "rustls" -version = "0.23.45" +version = "0.23.46" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0d41d731c7d2f962d1ccc364cec258de3c0e93b38c2fb3ba97ac74513048d634" +checksum = "48e13bd8c0e9365c43cfa5c9e8f9ad49d3c8444926c9aac819e0e4dc503c8fdf" dependencies = [ "aws-lc-rs", "log", @@ -1965,9 +1965,9 @@ dependencies = [ [[package]] name = "rustls-native-certs" -version = "0.8.4" +version = "0.8.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dab5152771c58876a2146916e53e35057e1a4dfa2b9df0f0305b07f611fdea4d" +checksum = "4a073f5dc7469f984c52ad2752b63b0807745133b6de880b7b64c1ac4c48aec4" dependencies = [ "openssl-probe", "rustls-pki-types", @@ -2716,7 +2716,6 @@ dependencies = [ "aws-lc-rs", "clap", "compact_str", - "futures-util", "http-client", "hyper", "jiff", @@ -2809,9 +2808,9 @@ checksum = "fd3ca314f692efd6c868f8408f53fe444634a845f96c028b97d35f6a1f79f0ee" [[package]] name = "tokio" -version = "1.53.2" +version = "1.53.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e95f91fcc7a621e8b030f6aa23c71fe9838ae2fb4d8118b75602a328f5144044" +checksum = "ce3335fa71841cda333a58d7615b03901380ecf09d59b3296d21f8bbac0dde4e" dependencies = [ "bytes", "libc", @@ -3493,18 +3492,18 @@ dependencies = [ [[package]] name = "zerocopy" -version = "0.8.62" +version = "0.8.63" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "86502bf56ac7c77571a32e2647bb2a15894565e981fb2a48d7bde2d91c965a9d" +checksum = "e5fe1f8f1b06191a00962174c61aa5005e0bb391a6d80d07e24d115c01a92ed8" dependencies = [ "zerocopy-derive", ] [[package]] name = "zerocopy-derive" -version = "0.8.62" +version = "0.8.63" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5457206954b06561e2608c7e19cf58b1926586d999c246eebe4502f7e2039d1a" +checksum = "863ad3ac83293fb4d740aedbfdc9240dd8d1a50c1099acd76ce80ce7c7230c7f" dependencies = [ "proc-macro2", "quote", diff --git a/adapters/taler-cyclos/src/setup.rs b/adapters/taler-cyclos/src/setup.rs @@ -180,6 +180,6 @@ pub async fn setup(cfg: &Config, _reset: bool, client: &http_client::Client) -> Ok(()) } else { error!(target: "setup", "Setup failed, config need editing"); - std::process::exit(0); + std::process::exit(6); } } diff --git a/adapters/taler-wise/Cargo.toml b/adapters/taler-wise/Cargo.toml @@ -34,7 +34,5 @@ regex.workspace = true prometheus-client.workspace = true uuid = { version = "1.24.0", features = ["serde"] } -futures-util = { version = "0.3", default-features = false, features = ["alloc"] } - [dev-dependencies] tokio = { workspace = true, features = ["test-util"] } diff --git a/adapters/taler-wise/db/wise-0001.sql b/adapters/taler-wise/db/wise-0001.sql @@ -23,10 +23,15 @@ COMMENT ON TYPE taler_amount IS 'Stores an amount, fraction is in units of 1/100 CREATE TABLE balances ( balance_id INT8 PRIMARY KEY, - last_sync_at INT8 NOT NULL, - last_sync_success_at INT8 NOT NULL + created_at INT8 NOT NULL, + early_cursor INT8 NOT NULL, + sync_cursor INT8 NOT NULL, + checkpoint_cursor INT8 NOT NULL, + early_success_at INT8, + sync_success_at INT8, + checkpoint_success_at INT8 ); -COMMENT ON TABLE balances IS 'Synchronized balances accounts'; +COMMENT ON TABLE balances IS 'Balance creation time, transaction cursors and successful scan times (UTC microseconds)'; CREATE TABLE tx_in ( tx_in_id INT8 PRIMARY KEY GENERATED ALWAYS AS IDENTITY, diff --git a/adapters/taler-wise/src/config.rs b/adapters/taler-wise/src/config.rs @@ -102,36 +102,37 @@ pub struct WiseBalance { } fn balances(cfg: &Config) -> Result<Vec<WiseBalance>, CfgErr> { - cfg.sections() - .filter_map(|s| { - (|| { - Ok(if s.name.starts_with("wise-balance-") { - Some(WiseBalance { - id: s.number("id").require()?, - currency: s.currency("currency").require()?, - payto: map_config!(s, "payto type", "PAYTO_TYPE", - "iban" => { - WiseAccount::IBAN(BankID { - iban: s.parse("IBAN", "iban").require()?, - bic: s.parse("BIC", "bic").opt()? - }) - }, - "ach" => { - WiseAccount::ACH(ACH { - routing_number: s.parse("ACH routing number", "routing_number").require()?, - account_number: s.parse("ACH account_number", "account_number").require()? - }) - }, - ) - .require()?, + let mut balances = Vec::new(); + let mut ids = std::collections::BTreeSet::new(); + for s in cfg + .sections() + .filter(|s| s.name.starts_with("wise-balance-")) + { + let id = s.number("ID").require()?; + if !ids.insert(id) { + return Err(s.invalid("ID", format!("duplicate balance ID {id}"))); + } + balances.push(WiseBalance { + id, + currency: s.currency("CURRENCY").require()?, + payto: map_config!(s, "payto type", "PAYTO_TYPE", + "iban" => { + WiseAccount::IBAN(BankID { + iban: s.parse("IBAN", "iban").require()?, + bic: s.parse("BIC", "bic").opt()? }) - } else { - None - }) - })() - .transpose() - }) - .collect() + }, + "ach" => { + WiseAccount::ACH(ACH { + routing_number: s.parse("ACH routing number", "routing_number").require()?, + account_number: s.parse("ACH account_number", "account_number").require()? + }) + }, + ) + .require()?, + }); + } + Ok(balances) } /// taler-wise setup config @@ -203,6 +204,14 @@ impl WorkerCfg { if cfg.balances.is_empty() { config_bail!("Missing configured balance in a section [wise-balance-*]"); } + if cfg.frequency.is_zero() { + return Err(s.invalid("FREQUENCY", "must be positive")); + } + if cfg.token.is_empty() + || hyper::header::HeaderValue::from_str(&format!("Bearer {}", cfg.token)).is_err() + { + return Err(s.invalid("TOKEN", "must be a nonempty valid HTTP header value")); + } Ok(cfg) } } diff --git a/adapters/taler-wise/src/constants.rs b/adapters/taler-wise/src/constants.rs @@ -14,6 +14,11 @@ TALER; see the file COPYING. If not, see <http://www.gnu.org/licenses/> */ +use std::time::Duration; + use taler_common::config::parser::ConfigSource; pub const CONFIG_SOURCE: ConfigSource = ConfigSource::new("taler-wise", "wise", "taler-wise"); + +pub(crate) static HOUR: Duration = Duration::from_secs(60 * 60); +pub(crate) static DAY: Duration = Duration::from_secs(24 * 60 * 60); diff --git a/adapters/taler-wise/src/db.rs b/adapters/taler-wise/src/db.rs @@ -61,6 +61,105 @@ pub async fn dbinit(cfg: &Config, reset: bool) -> anyhow::Result<PgPool> { Ok(pool) } +/// Durable transaction high-water marks and completed scan schedules. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct SyncState { + pub early: Timestamp, + pub sync: Timestamp, + pub checkpoint: Timestamp, + pub early_success: Option<Timestamp>, + pub sync_success: Option<Timestamp>, + pub checkpoint_success: Option<Timestamp>, +} + +pub(crate) async fn load_sync_state( + db: &PgPool, + balance_id: u32, +) -> sqlx::Result<Option<SyncState>> { + sqlx::query( + "SELECT early_cursor, sync_cursor, checkpoint_cursor, + early_success_at, sync_success_at, checkpoint_success_at + FROM balances WHERE balance_id = $1", + ) + .bind(i64::from(balance_id)) + .try_map(|r: PgRow| { + Ok(SyncState { + early: r.try_get_timestamp(0)?, + sync: r.try_get_timestamp(1)?, + checkpoint: r.try_get_timestamp(2)?, + early_success: r.try_get_opt_timestamp(3)?, + sync_success: r.try_get_opt_timestamp(4)?, + checkpoint_success: r.try_get_opt_timestamp(5)?, + }) + }) + .fetch_optional(db) + .await +} + +pub(crate) async fn initialize_sync_state( + db: &PgPool, + balance_id: u32, + created_at: Timestamp, +) -> sqlx::Result<()> { + serialized!( + sqlx::query( + "INSERT INTO balances (balance_id, created_at, early_cursor, sync_cursor, checkpoint_cursor) + VALUES ($1, $2, $2, $2, $2) + ON CONFLICT (balance_id) DO NOTHING", + ) + .bind(i64::from(balance_id)) + .bind_timestamp(&created_at) + .execute(db) + )?; + Ok(()) +} + +pub(crate) async fn reset_sync_state(db: &PgPool) -> sqlx::Result<()> { + serialized!( + sqlx::query( + "UPDATE balances SET + early_cursor = created_at, + sync_cursor = created_at, + checkpoint_cursor = created_at, + early_success_at = NULL, + sync_success_at = NULL, + checkpoint_success_at = NULL", + ) + .execute(db) + )?; + Ok(()) +} + +/// Called after all registrations in the chunk have committed. A failed write +/// leaves the old cursor available for an idempotent replay on restart. +pub(crate) async fn save_sync_state( + db: &PgPool, + balance_id: u32, + state: &SyncState, +) -> sqlx::Result<()> { + serialized!( + sqlx::query( + "UPDATE balances SET + early_cursor = GREATEST(early_cursor, $2), + sync_cursor = GREATEST(sync_cursor, $3), + checkpoint_cursor = GREATEST(checkpoint_cursor, $4), + early_success_at = GREATEST(early_success_at, $5), + sync_success_at = GREATEST(sync_success_at, $6), + checkpoint_success_at = GREATEST(checkpoint_success_at, $7) + WHERE balance_id = $1", + ) + .bind(i64::from(balance_id)) + .bind_timestamp(&state.early) + .bind_timestamp(&state.sync) + .bind_timestamp(&state.checkpoint) + .bind(state.early_success.map(|t| t.as_microsecond())) + .bind(state.sync_success.map(|t| t.as_microsecond())) + .bind(state.checkpoint_success.map(|t| t.as_microsecond())) + .execute(db) + )?; + Ok(()) +} + pub async fn notification_listener( pool: PgPool, in_channels: Arc<BTreeMap<u32, Sender<i64>>>, @@ -330,7 +429,6 @@ pub async fn transfer_unregister(db: &PgPool, req: &Unregistration) -> sqlx::Res mod test { use compact_str::{CompactString, format_compact}; - use jiff::Span; use sqlx::{PgConnection, PgPool, postgres::PgRow}; use taler_api::{db::TypeHelper, notification::dummy_listen, subject::IncomingKey}; use taler_common::{ @@ -345,6 +443,7 @@ mod test { use taler_macros::db_test; use crate::{ + constants::HOUR, db::{AddIncomingResult, TxIn, incoming_history, register_tx_in, revenue_history}, payto::WiseAccount, }; @@ -361,7 +460,7 @@ mod test { .await .unwrap(); let now = now_sql_stable_ts(); - let later = now + Span::new().hours(1); + let later = now + HOUR; let tx = TxIn { balance_id: 42, name: CompactString::const_new("Name"), diff --git a/adapters/taler-wise/src/main.rs b/adapters/taler-wise/src/main.rs @@ -45,8 +45,12 @@ enum Command { #[arg(short, long)] reset: bool, }, - /// Check taler-wise config - Setup, + /// Check configuration and initialize balance state + Setup { + /// Reset all synchronization cursors and schedules to rescan from balance creation + #[arg(long)] + reset: bool, + }, /// Run taler-wise worker Worker { /// Execute once and return @@ -69,9 +73,10 @@ async fn run(cmd: Command, cfg: &Config) -> anyhow::Result<()> { Command::Dbinit { reset } => { dbinit(cfg, reset).await?; } - Command::Setup => { + Command::Setup { reset } => { + let pool = pool(cfg).await?; let client = http_client::client(); - setup::setup(cfg, &client).await? + setup::setup(cfg, &pool, &client, reset).await? } Command::Serve { check } => { if check { diff --git a/adapters/taler-wise/src/setup.rs b/adapters/taler-wise/src/setup.rs @@ -14,15 +14,23 @@ TALER; see the file COPYING. If not, see <http://www.gnu.org/licenses/> */ +use anyhow::Context; +use sqlx::PgPool; use taler_common::config::Config; use tracing::{debug, error, info, warn}; use crate::{ config::{SetupCfg, WiseBalance}, + db::{initialize_sync_state, reset_sync_state}, wise_api::client::Client, }; -pub async fn setup(cfg: &Config, client: &http_client::Client) -> anyhow::Result<()> { +pub async fn setup( + cfg: &Config, + pool: &PgPool, + client: &http_client::Client, + reset: bool, +) -> anyhow::Result<()> { let cfg = SetupCfg::parse(cfg)?; let client = Client::new(client, cfg.api, &cfg.token); @@ -40,7 +48,7 @@ pub async fn setup(cfg: &Config, client: &http_client::Client) -> anyhow::Result if let Some(id) = cfg.profile_id { if let Some(profile) = profiles.iter().find(|it| it.id == id) { debug!(target: "setup", "PROFILE_ID {id} in config is one of:{profiles_fmt}"); - match cfg.name { + match &cfg.name { Some(name) => { if name != profile.full_name { warn!( @@ -72,18 +80,12 @@ pub async fn setup(cfg: &Config, client: &http_client::Client) -> anyhow::Result let account = accounts .iter() .find(|it| it.profile_id == profile_id) - .unwrap(); - - let mapped: Vec<_> = account - .balances - .iter() - .map(|it| (it.id, it.currency, it.bank_details.account())) - .collect(); + .context("Missing Wise account for configured profile")?; let balances_fmt = std::fmt::from_fn(|w| { - for (id, currency, account) in &mapped { - if let Some(account) = account { - write!(w, "\n{id} {currency} ({account})")?; + for balance in &account.balances { + if let Some(payto) = balance.bank_details.account() { + write!(w, "\n{} {} ({payto})", balance.id, balance.currency)?; } } Ok(()) @@ -92,44 +94,62 @@ pub async fn setup(cfg: &Config, client: &http_client::Client) -> anyhow::Result if cfg.balances.is_empty() { error!(target: "setup", "No balances are configured"); - ready = false - } else { - for WiseBalance { - id, - currency, - payto, - } in &cfg.balances - { - match mapped.iter().find(|it| it.0 == *id as u64) { - Some((_, curr, acc)) => { - if curr != currency { - error!(target: "setup", "Balance {id} in config use currency {currency} expected {curr}"); - ready = false; - } - if let Some(acc) = acc { - if acc != payto { - error!(target: "setup", "Balance {id} in config use payto ({payto}) expected ({acc})"); - ready = false - } - } else { - error!(target: "setup", "Balance {id} in config use payto ({payto}) but it's not supported"); - ready = false - } - } - None => { - error!(target: "setup", "Unknown balance {id} {currency}"); - ready = false - } + ready = false; + } + for WiseBalance { + id, + currency, + payto, + } in &cfg.balances + { + let Some(balance) = account.balances.iter().find(|b| b.id == u64::from(*id)) else { + error!(target: "setup", "Unknown balance {id} {currency}"); + ready = false; + continue; + }; + if balance.currency != *currency { + error!(target: "setup", "Balance {id} in config use currency {currency} expected {}", balance.currency); + ready = false; + } + match balance.bank_details.account() { + Some(account) if account == *payto => {} + Some(account) => { + error!(target: "setup", "Balance {id} in config use payto ({payto}) expected ({account})"); + ready = false; + } + None => { + error!(target: "setup", "Balance {id} in config use payto ({payto}) but it's not supported"); + ready = false; } } } } - if ready { - info!(target: "setup", "Setup finished"); - Ok(()) - } else { + if !ready { error!(target: "setup", "Setup failed, config need editing"); - std::process::exit(0); + std::process::exit(6); + } + + let profile_id = cfg.profile_id.context("Missing PROFILE_ID")?; + let balances = client.standard_balances(profile_id).await?; + for configured in &cfg.balances { + let remote = balances + .iter() + .find(|remote| { + remote.id == u64::from(configured.id) && remote.currency == configured.currency + }) + .with_context(|| { + format!( + "Missing Wise balance {} {}", + configured.id, configured.currency + ) + })?; + initialize_sync_state(pool, configured.id, remote.creation_time).await?; + } + if reset { + reset_sync_state(pool).await?; + info!(target: "setup", "Synchronization state reset to balance creation"); } + info!(target: "setup", "Setup finished"); + Ok(()) } diff --git a/adapters/taler-wise/src/wise_api/client.rs b/adapters/taler-wise/src/wise_api/client.rs @@ -14,14 +14,14 @@ TALER; see the file COPYING. If not, see <http://www.gnu.org/licenses/> */ -use std::borrow::Cow; +use std::{borrow::Cow, time::Duration}; use http_client::{ApiErr, ClientErr, builder::Req, headers::HeaderParser as _}; use hyper::{ Method, StatusCode, header::{AUTHORIZATION, HeaderValue, RETRY_AFTER}, }; -use jiff::Timestamp; +use jiff::{SignedDuration, Timestamp, fmt::rfc2822::DateTimeParser}; use serde::de::DeserializeOwned; use taler_common::types::amount::Currency; use thiserror::Error; @@ -37,35 +37,49 @@ pub enum WiseErr { Status(StatusCode), #[error("status {0} '{1}'")] StatusCause(StatusCode, String), - #[error("Rate limited for {0}")] - TooManyRequest(String), + #[error("rate limited (retry after {0:?})")] + TooManyRequest(Option<Duration>), #[error(transparent)] Client(#[from] ClientErr), } pub type ApiResult<R> = std::result::Result<R, ApiErr<WiseErr>>; +/// Parse both forms of Retry-After. Past dates are already satisfied. +fn retry_after(value: &str) -> Option<Duration> { + let value = value.trim(); + let delay = if let Ok(seconds) = value.parse() { + Duration::from_secs(seconds) + } else { + let date = DateTimeParser::new().parse_timestamp(value).ok()?; + date.duration_since(Timestamp::now()) + .max(SignedDuration::ZERO) + .unsigned_abs() + }; + std::time::Instant::now().checked_add(delay).map(|_| delay) +} + pub struct Client<'a> { - env: ApiEnv, + base_url: &'a url::Url, client: &'a http_client::Client, token: HeaderValue, } impl<'a> Client<'a> { - pub fn new(client: &'a http_client::Client, env: ApiEnv, token: &'a str) -> Self { + pub fn new(client: &'a http_client::Client, env: ApiEnv, token: &str) -> Self { Self { client, - env, + base_url: env.api(), token: HeaderValue::from_str(&format!("Bearer {token}")).unwrap(), } } fn request(&self, method: Method, path: impl Into<Cow<'static, str>>) -> Req { - Req::new(self.client, method, self.env.api(), path) + Req::new(self.client, method, self.base_url, path) } async fn send<T: DeserializeOwned>(&self, req: Req) -> ApiResult<T> { let (ctx, res) = req - .header(AUTHORIZATION, self.token.clone()) + .sensitive_header(AUTHORIZATION, self.token.clone()) .send() .await .map_err(|(ctx, e)| ctx.wrap(e.into()))?; @@ -74,9 +88,9 @@ impl<'a> Client<'a> { // Parse json res.json().await.map_err(|e| ctx.wrap(e.into())) } - StatusCode::TOO_MANY_REQUESTS => Err(ctx.wrap(WiseErr::TooManyRequest( - res.str_header(RETRY_AFTER).unwrap_or_default(), - ))), + StatusCode::TOO_MANY_REQUESTS => Err(ctx.wrap(WiseErr::TooManyRequest(retry_after( + &res.str_header(RETRY_AFTER).unwrap_or_default(), + )))), other => Err(ctx.wrap(WiseErr::Status(other))), } } @@ -124,6 +138,7 @@ impl<'a> Client<'a> { Method::GET, format!("/v1/profiles/{profile_id}/balance-statements/{balance_id}/statement.json"), ) + .query("type", "COMPACT") .query("currency", currency) .query("intervalStart", start) .query("intervalEnd", end), diff --git a/adapters/taler-wise/src/wise_api/types.rs b/adapters/taler-wise/src/wise_api/types.rs @@ -168,12 +168,14 @@ pub struct WiseAmount { pub currency: Currency, } -impl From<WiseAmount> for Amount { - fn from(val: WiseAmount) -> Self { - Amount::new_decimal( +impl TryFrom<WiseAmount> for Amount { + type Error = taler_common::types::amount::ParseDecimalErr; + + fn try_from(val: WiseAmount) -> Result<Self, Self::Error> { + Ok(Amount::new_decimal( &val.currency, - Decimal::from_str(&val.value.to_compact_string()).unwrap(), - ) + Decimal::from_str(&val.value.to_compact_string())?, + )) } } @@ -195,7 +197,10 @@ pub struct Details { #[serde(rename = "type")] pub ty: CompactString, pub description: CompactString, + #[serde(default)] pub sender_account: CompactString, + #[serde(default)] pub sender_name: CompactString, + #[serde(default)] pub payment_reference: CompactString, } diff --git a/adapters/taler-wise/src/worker.rs b/adapters/taler-wise/src/worker.rs @@ -16,22 +16,27 @@ use std::{sync::LazyLock, time::Duration}; -use futures_util::future::{join_all, try_join_all}; use http_client::ApiErr; -use jiff::Timestamp; +use jiff::{SignedDuration, Span, Timestamp, tz::TimeZone}; use regex::Regex; use sqlx::PgPool; use taler_api::subject::{IncomingSubject, parse_incoming_unstructured}; -use taler_common::{ExpoBackoffDecorr, config::Config, types::payto::BankID}; -use tracing::{error, info, trace, warn}; +use taler_common::{ + ExpoBackoffDecorr, + config::Config, + types::{payto::BankID, utils::now_sql_stable_ts}, +}; +use tokio::time::Instant; +use tracing::{debug, error, info, trace, warn}; use crate::{ config::{WiseBalance, WorkerCfg}, - db::{AddIncomingResult, TxIn, register_tx_in}, + constants::{DAY, HOUR}, + db::{AddIncomingResult, SyncState, TxIn, load_sync_state, register_tx_in, save_sync_state}, payto::WiseAccount, wise_api::{ client::{Client, WiseErr}, - types::Direction, + types::{Direction, Tx}, }, }; @@ -41,57 +46,69 @@ pub enum WorkerError { Db(#[from] sqlx::Error), #[error(transparent)] Api(#[from] ApiErr<WiseErr>), + #[error("invalid Wise statement: {0}")] + InvalidStatement(String), + #[error("balance {0} has no synchronization state; run taler-wise setup")] + UninitializedBalance(u32), } -pub type WorkerResult = Result<(), WorkerError>; +pub type WorkerResult<T = ()> = Result<T, WorkerError>; -fn parse_account(account_str: &str) -> Option<WiseAccount> { - static IBAN_BIC_PATTERN: LazyLock<Regex> = - LazyLock::new(|| Regex::new(r"^\(([A-Z0-9]{8,11})\) ([A-Z0-9]+)$").unwrap()); +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] +enum Tier { + Early, + Sync, + Checkpoint, +} - if let Some(caps) = IBAN_BIC_PATTERN.captures(account_str) { - let bic = caps[1].parse().ok()?; - let iban = caps[2].parse().ok()?; +fn remaining(last: Option<Timestamp>, period: Duration, now: Timestamp) -> Duration { + last.map_or(Duration::ZERO, |last| { + let deadline = last + period; + now.duration_until(deadline) + .max(SignedDuration::ZERO) + .unsigned_abs() + }) +} - return Some(WiseAccount::IBAN(BankID { - iban, - bic: Some(bic), - })); +impl SyncState { + fn advance(&mut self, tier: Tier, latest: Timestamp) { + self.early = self.early.max(latest); + if tier >= Tier::Sync { + self.sync = self.sync.max(latest - 2 * HOUR); + } + if tier >= Tier::Checkpoint { + self.checkpoint = self.checkpoint.max(latest - 2 * DAY); + } } - None -} - -/// Each balance owns its retry schedule. A slow or failing balance must not -/// delay other balances, and success elsewhere must not reset its backoff. -async fn poll_balance( - balance_id: u32, - frequency: Duration, - transient: bool, - mut sync: impl AsyncFnMut() -> WorkerResult, -) -> WorkerResult { - let mut jitter = ExpoBackoffDecorr::default(); - loop { - let result = sync().await; - if transient { - return result; + fn complete(&mut self, tier: Tier, now: Timestamp) { + self.early_success = Some(now); + if tier >= Tier::Sync { + self.sync_success = Some(now); } - let delay = match result { - Ok(()) => { - jitter.reset(); - frequency - } - Err(err @ WorkerError::Db(sqlx::Error::PoolClosed)) => return Err(err), - Err(err) => { - let delay = jitter.backoff(); - error!(target: "worker", balance_id, ?delay, "balance synchronization failed: {err}"); - delay - } - }; - tokio::time::sleep(delay).await; + if tier >= Tier::Checkpoint { + self.checkpoint_success = Some(now); + } + } + + fn delay(&self, now: Timestamp, frequency: Duration) -> Duration { + remaining(self.early_success, frequency, now) + .min(remaining(self.sync_success, HOUR, now)) + .min(remaining(self.checkpoint_success, DAY, now)) } } +fn parse_account(account_str: &str) -> Option<WiseAccount> { + static IBAN_BIC_PATTERN: LazyLock<Regex> = + LazyLock::new(|| Regex::new(r"^\(([A-Z0-9]{8,11})\) ([A-Z0-9]+)$").unwrap()); + + let caps = IBAN_BIC_PATTERN.captures(account_str)?; + Some(WiseAccount::IBAN(BankID { + iban: caps[2].parse().ok()?, + bic: Some(caps[1].parse().ok()?), + })) +} + pub async fn run_worker( cfg: &Config, pool: &PgPool, @@ -100,24 +117,47 @@ pub async fn run_worker( ) -> anyhow::Result<()> { let cfg = WorkerCfg::parse(cfg)?; let client = Client::new(client, cfg.api, &cfg.token); - let cfg = &cfg; - let client = &client; - let workers = cfg.balances.iter().map(|balance| { - poll_balance(balance.id, cfg.frequency, transient, async move || { - sync_balance(cfg, balance, pool, client).await - }) - }); - if transient { - // Attempt every balance once, even if another balance fails. - for result in join_all(workers).await { - result?; + work_balances(&cfg, pool, &client, transient).await?; + Ok(()) +} + +async fn work_balances( + cfg: &WorkerCfg, + pool: &PgPool, + client: &Client<'_>, + transient: bool, +) -> WorkerResult { + let mut next = Instant::now(); + let mut jitter = ExpoBackoffDecorr::default(); + loop { + let mut delay = Duration::MAX; + let mut first_error = None; + for balance in &cfg.balances { + tokio::time::sleep_until(next).await; + match sync_balance(cfg, balance, pool, client, transient).await { + Ok(wait) => delay = delay.min(wait), + Err(err @ WorkerError::Db(sqlx::Error::PoolClosed)) => return Err(err), + Err(err) => { + let mut wait = jitter.backoff(); + if let WorkerError::Api(api) = &err + && let WiseErr::TooManyRequest(retry_after) = api.err.as_ref() + { + wait = wait.max(retry_after.unwrap_or_default()); + } + next = Instant::now() + wait; + error!(target: "worker", "synchronization pass for {} failed: {err}", balance.id); + first_error.get_or_insert(err); + } + } + } + if transient { + return first_error.map_or(Ok(()), Err); + } + if first_error.is_none() { + jitter.reset(); + next = Instant::now() + delay; } - } else { - // These are operation loops, not detached tasks: panics and terminal - // failures still reach the process, cancelling the remaining loops. - try_join_all(workers).await?; } - Ok(()) } async fn sync_balance( @@ -125,163 +165,134 @@ async fn sync_balance( balance: &WiseBalance, pool: &PgPool, client: &Client<'_>, -) -> WorkerResult { - let stmt = client - .balance_statement( - cfg.profile_id, - balance.id, - &balance.currency, - "2026-07-16T00:00:00.000Z".parse().unwrap(), - Timestamp::now(), + transient: bool, +) -> WorkerResult<Duration> { + let mut state = load_sync_state(pool, balance.id) + .await? + .ok_or(WorkerError::UninitializedBalance(balance.id))?; + let now = now_sql_stable_ts(); + if !transient { + let delay = state.delay(now, cfg.frequency); + if !delay.is_zero() { + return Ok(delay); + } + } + // Statements can miss transactions: early tracks the latest seen, hourly/daily + // scans replay with 2h/2d lag. The broadest due scan advances newer tiers only + // after inserts succeed. Idempotency makes replay safe. + // We currently warn on recovered payments to see if this mechanism is usefull. + let (tier, cursor, last, overlap) = if remaining(state.checkpoint_success, DAY, now).is_zero() { + ( + Tier::Checkpoint, + state.checkpoint, + state.checkpoint_success, + 2 * DAY, + ) + } else if remaining(state.sync_success, HOUR, now).is_zero() { + (Tier::Sync, state.sync, state.sync_success, 2 * HOUR) + } else { + ( + Tier::Early, + state.early, + state.early_success, + Duration::ZERO, ) - .await?; - let now = Timestamp::now(); - for tx in stmt.transactions { - match tx.direction { - Direction::Debit => { - // TODO support outgoing transaction + }; + let mut start = cursor.max(last.map_or(cursor, |last| last - overlap)); + let previous_early = state.early_success.map(|last| state.early.max(last)); + while start < now { + // Fetch at most one month at a time + let end = (start.to_zoned(TimeZone::UTC) + Span::new().months(1)) + .timestamp() + .min(now); + debug!(target: "worker", "fetching statement for {} {tier:?} from {start} to {end}", balance.id); + let stmt = client + .balance_statement(cfg.profile_id, balance.id, &balance.currency, start, end) + .await?; + let latest = stmt.transactions.iter().map(|tx| tx.date).max(); + for tx in stmt.transactions { + if tx.reference_number.trim().is_empty() { + return Err(WorkerError::InvalidStatement( + "transaction has an empty Wise reference".into(), + )); + } else if tx.amount.currency != balance.currency { + return Err(WorkerError::InvalidStatement(format!( + "transaction {} has currency {}, expected {}", + tx.reference_number, tx.amount.currency, balance.currency, + ))); } - Direction::Credit => { - let subject = parse_incoming_unstructured(&tx.details.payment_reference); - // Parse sender account - let payto = parse_account(&tx.details.sender_account); - // - let t = TxIn { - balance_id: balance.id, - wise_ref: Some(tx.reference_number), - amount: tx.amount.into(), - subject: tx.details.payment_reference, - name: tx.details.sender_name, - debtor: payto, - value_at: tx.date, - }; - let subject = &match subject { - Ok(IncomingSubject::Key(key)) => Some(key), - Ok(IncomingSubject::AdminBalanceAdjust) | Err(_) => None, - }; - let failure = match register_tx_in(pool, &t, subject, &now).await? { - AddIncomingResult::Success { new, .. } => { - if new { - info!(target: "worker", "in {t}"); - if t.debtor.is_none() { - warn!(target: "worker", "Couldn't parse creditor account from '{}'", tx.details.sender_account) - } - } else { - trace!(target: "worker", "in {t} already seen"); - } - continue; - } - AddIncomingResult::ReservePubReuse => "reserve pub reuse", - AddIncomingResult::UnknownMapping => "unknown mapping", - AddIncomingResult::MappingReuse => "mapping reuse", - }; - - match register_tx_in(pool, &t, &None, &now).await? { - AddIncomingResult::Success { new, .. } => { - if new { - info!(target: "worker", "in {t}: {failure}"); - if t.debtor.is_none() { - warn!(target: "worker", "Couldn't parse creditor account from '{}'", tx.details.sender_account) - } - } else { - trace!(target: "worker", "in {t} already seen: {failure}"); - } - continue; - } - AddIncomingResult::ReservePubReuse - | AddIncomingResult::UnknownMapping - | AddIncomingResult::MappingReuse => unreachable!(), - }; + let Some(tx) = register_transaction(pool, balance, tx, now).await? else { + continue; + }; + if tier >= Tier::Sync + && let Some(early) = previous_early + && tx.value_at <= early + { + warn!(target: "worker", "in (recovered) {tx}"); } } + if let Some(latest) = latest { + state.advance(tier, latest); + save_sync_state(pool, balance.id, &state).await?; + trace!(target: "worker", "saved statement cursors for {}", balance.id); + } + start = end; } - Ok(()) + state.complete(tier, now); + save_sync_state(pool, balance.id, &state).await?; + Ok(state.delay(Timestamp::now(), cfg.frequency)) } -#[cfg(test)] -mod tests { - use std::cell::Cell; - - use futures_util::FutureExt; - use tokio::time::Instant; - - use super::*; - - fn unavailable() -> WorkerError { - sqlx::Error::Io(std::io::ErrorKind::ConnectionRefused.into()).into() +async fn register_transaction( + pool: &PgPool, + balance: &WiseBalance, + tx: Tx, + now: Timestamp, +) -> WorkerResult<Option<TxIn>> { + if tx.direction == Direction::Debit { + // Its timestamp advances the cursors, but outgoing registration is not supported. + return Ok(None); } - - #[tokio::test(start_paused = true)] - async fn failed_balance_retries_without_stalling_other_balances() { - let attempts = Cell::new(0); - let healthy = Cell::new(0); - let start = Instant::now(); - let bad = poll_balance(1, Duration::from_secs(60), false, async || { - attempts.set(attempts.get() + 1); - if attempts.get() == 3 { - Err(sqlx::Error::PoolClosed.into()) - } else { - Err(unavailable()) - } - }); - let good = poll_balance(2, Duration::from_millis(100), false, async || { - healthy.set(healthy.get() + 1); - Ok(()) - }); - let result = try_join_all([bad.boxed_local(), good.boxed_local()]).await; - assert!(matches!( - result, - Err(WorkerError::Db(sqlx::Error::PoolClosed)) - )); - assert_eq!(attempts.get(), 3); - assert!(healthy.get() > attempts.get()); - assert!(start.elapsed() >= Duration::from_millis(800)); + let subject = match parse_incoming_unstructured(&tx.details.payment_reference) { + Ok(IncomingSubject::Key(key)) => Some(key), + Ok(IncomingSubject::AdminBalanceAdjust) | Err(_) => None, + }; + let t = TxIn { + balance_id: balance.id, + wise_ref: Some(tx.reference_number), + amount: tx + .amount + .try_into() + .map_err(|err| WorkerError::InvalidStatement(format!("invalid amount: {err}")))?, + subject: tx.details.payment_reference, + name: tx.details.sender_name, + debtor: parse_account(&tx.details.sender_account), + value_at: tx.date, + }; + let mut result = register_tx_in(pool, &t, &subject, &now).await?; + let failure = match result { + AddIncomingResult::Success { .. } => None, + AddIncomingResult::ReservePubReuse => Some("reserve pub reuse"), + AddIncomingResult::UnknownMapping => Some("unknown mapping"), + AddIncomingResult::MappingReuse => Some("mapping reuse"), + }; + if failure.is_some() { + result = register_tx_in(pool, &t, &None, &now).await?; } - - #[tokio::test(start_paused = true)] - async fn success_resets_only_this_balances_backoff() { - let attempts = Cell::new(0); - let failed_at = Cell::new(Instant::now()); - let result = poll_balance(1, Duration::from_millis(10), false, async || { - attempts.set(attempts.get() + 1); - match attempts.get() { - 11 => Ok(()), - 12 => { - failed_at.set(Instant::now()); - Err(unavailable()) - } - 13 => Err(sqlx::Error::PoolClosed.into()), - _ => Err(unavailable()), - } - }) - .await; - assert!(result.is_err()); - let delay = failed_at.get().elapsed(); - assert!(delay >= Duration::from_millis(400)); - assert!(delay < Duration::from_secs(1)); + let AddIncomingResult::Success { new, .. } = result else { + unreachable!("registration without a Taler subject cannot fail subject checks"); + }; + if !new { + trace!(target: "worker", "in {t} already seen"); + return Ok(None); } - - #[tokio::test(start_paused = true)] - async fn transient_attempts_each_balance_once_and_reports_failure() { - let attempts = Cell::new(0); - let results = join_all([ - poll_balance( - 1, - Duration::from_secs(60), - true, - async || Err(unavailable()), - ) - .boxed_local(), - poll_balance(2, Duration::from_secs(60), true, async || { - tokio::time::sleep(Duration::from_millis(100)).await; - attempts.set(attempts.get() + 1); - Ok(()) - }) - .boxed_local(), - ]) - .await; - assert!(results[0].is_err()); - assert!(results[1].is_ok()); - assert_eq!(attempts.get(), 1); + if let Some(reason) = failure { + info!(target: "worker", "in {t} malformed: {reason}"); + } else { + info!(target: "worker", "in {t}"); + } + if t.debtor.is_none() { + warn!(target: "worker", "in {t} cannot parse debtor account '{}'", tx.details.sender_account); } + Ok(Some(t)) } diff --git a/common/taler-common/src/config.rs b/common/taler-common/src/config.rs @@ -19,7 +19,7 @@ use std::{ sync::Arc, time::Duration, }; -use compact_str::CompactString; +use compact_str::{CompactString, ToCompactString}; use indexmap::IndexMap; use jiff::{SignedDuration, Span}; use url::Url; @@ -477,14 +477,14 @@ pub enum CfgErr { IO(Cow<'static, str>, PathBuf, std::io::Error), Line(Cow<'static, str>, PathBuf, usize, Option<String>), Missing { - ty: String, - section: String, - option: String, + ty: CompactString, + section: CompactString, + option: CompactString, }, Invalid { - ty: String, - section: String, - option: String, + ty: Option<CompactString>, + section: CompactString, + option: CompactString, err: String, }, Custom(String), @@ -518,10 +518,13 @@ impl std::fmt::Display for CfgErr { section, option, err, - } => write!( - f, - "invalid {ty} option {option} in section [{section}]: {err}" - ), + } => { + f.write_str("invalid ")?; + if let Some(ty) = ty { + write!(f, "{ty} ")?; + } + write!(f, "option {option} in section [{section}]: {err}") + } CfgErr::Custom(e) => e.fmt(f), } } @@ -787,6 +790,16 @@ pub enum MapErr { } impl<'cfg, 'arg> Section<'cfg, 'arg> { + /// Report a logical validation error for an option in this section. + pub fn invalid(&self, option: &str, err: impl std::fmt::Display) -> CfgErr { + CfgErr::Invalid { + ty: None, + section: self.name.to_compact_string().to_lowercase(), + option: option.to_compact_string().to_uppercase(), + err: err.to_string(), + } + } + #[doc(hidden)] fn inner<T>( &self, @@ -836,9 +849,9 @@ impl<'cfg, 'arg> Section<'cfg, 'arg> { buf.push_str(v); buf.push('\''); CfgErr::Invalid { - ty: ty.to_owned(), - section: self.name.to_lowercase(), - option: option.to_uppercase(), + ty: Some(ty.to_compact_string()), + section: self.name.to_compact_string().to_lowercase(), + option: option.to_compact_string().to_uppercase(), err: buf, } } @@ -856,9 +869,9 @@ impl<'cfg, 'arg> Section<'cfg, 'arg> { ) -> Value<'arg, T> { self.inner(ty, option, |v| { transform(v).map_err(|e| CfgErr::Invalid { - ty: ty.to_owned(), - section: self.name.to_lowercase(), - option: option.to_uppercase(), + ty: Some(ty.to_compact_string()), + section: self.name.to_compact_string().to_lowercase(), + option: option.to_compact_string().to_uppercase(), err: e.to_string(), }) }) @@ -1042,9 +1055,9 @@ impl<T> Value<'_, T> { /** Converted value or throw if missing */ pub fn require(self) -> Result<T, CfgErr> { self.value?.ok_or_else(|| CfgErr::Missing { - ty: self.ty.to_owned(), - section: self.section.to_lowercase(), - option: self.option.to_uppercase(), + ty: self.ty.to_compact_string(), + section: self.section.to_compact_string().to_lowercase(), + option: self.option.to_compact_string().to_uppercase(), }) } } diff --git a/taler-apns-relay/src/apns.rs b/taler-apns-relay/src/apns.rs @@ -193,7 +193,7 @@ impl Client { .expect("failed to install the default TLS provider"); let invalid_key = |message: String| taler_common::config::CfgErr::Invalid { - ty: "PKCS#8 private key file".into(), + ty: Some("PKCS#8 private key file".into()), section: "apns-relay-worker".into(), option: "KEY_FILE".into(), err: message,