taler-rust

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

worker.rs (23549B)


      1 /*
      2   This file is part of TALER
      3   Copyright (C) 2025, 2026 Taler Systems SA
      4 
      5   TALER is free software; you can redistribute it and/or modify it under the
      6   terms of the GNU Affero General Public License as published by the Free Software
      7   Foundation; either version 3, or (at your option) any later version.
      8 
      9   TALER is distributed in the hope that it will be useful, but WITHOUT ANY
     10   WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
     11   A PARTICULAR PURPOSE.  See the GNU Affero General Public License for more details.
     12 
     13   You should have received a copy of the GNU Affero General Public License along with
     14   TALER; see the file COPYING.  If not, see <http://www.gnu.org/licenses/>
     15 */
     16 
     17 use std::time::Duration;
     18 
     19 use failure_injection::{InjectedErr, fail_point};
     20 use http_client::ApiErr;
     21 use jiff::Timestamp;
     22 use sqlx::{Acquire as _, PgConnection, PgPool, postgres::PgListener};
     23 use taler_api::subject::{self, IncomingSubject, parse_incoming_unstructured};
     24 use taler_common::{
     25     ExpoBackoffDecorr,
     26     config::Config,
     27     types::amount::{self, Currency},
     28 };
     29 use tokio::sync::Notify;
     30 use tracing::{debug, error, info, trace, warn};
     31 
     32 use crate::{
     33     config::{AccountType, WorkerCfg},
     34     constants::SYNC_CURSOR_KEY,
     35     cyclos_api::{
     36         api::{CyclosAuth, CyclosErr},
     37         client::Client,
     38         types::{AccountKind, HistoryItem, InputError, NotFoundError, OrderBy},
     39     },
     40     db::{
     41         self, AddIncomingResult, ChargebackFailureResult, RegisterResult, TxIn, TxOut, TxOutKind,
     42         kv_get, kv_set,
     43     },
     44     notification::watch_notification,
     45 };
     46 
     47 #[derive(Debug, thiserror::Error)]
     48 pub enum WorkerError {
     49     #[error(transparent)]
     50     Db(#[from] sqlx::Error),
     51     #[error(transparent)]
     52     Api(#[from] ApiErr<CyclosErr>),
     53     #[error("Another worker is running concurrently")]
     54     Concurrency,
     55     #[error(transparent)]
     56     Injected(#[from] InjectedErr),
     57 }
     58 
     59 pub type WorkerResult = Result<(), WorkerError>;
     60 
     61 /// Retry an operation, not a terminated task. A closed pool cannot recover,
     62 /// and injected interruptions must remain visible to the caller/supervisor.
     63 fn retry_delay(
     64     err: &WorkerError,
     65     jitter: &mut ExpoBackoffDecorr,
     66     frequency: Duration,
     67 ) -> Option<Duration> {
     68     match err {
     69         WorkerError::Db(sqlx::Error::PoolClosed) | WorkerError::Injected(_) => None,
     70         WorkerError::Concurrency => Some(jitter.backoff().max(Duration::from_secs(15))),
     71         WorkerError::Api(e)
     72             if matches!(*e.err, CyclosErr::Input(InputError::Validation { .. })) =>
     73         {
     74             // Invalid input must not be resubmitted at notification speed.
     75             Some(jitter.backoff().max(frequency))
     76         }
     77         _ => Some(jitter.backoff()),
     78     }
     79 }
     80 
     81 pub async fn run_worker(
     82     cfg: &Config,
     83     pool: &PgPool,
     84     client: &http_client::Client,
     85     transient: bool,
     86 ) -> anyhow::Result<()> {
     87     let cfg = WorkerCfg::parse(cfg)?;
     88     let client = Client {
     89         client,
     90         api_url: &cfg.host.api_url,
     91         auth: &CyclosAuth::Basic {
     92             username: cfg.host.username,
     93             password: cfg.host.password,
     94         },
     95     };
     96     if transient {
     97         let mut conn = pool.acquire().await?;
     98         Worker {
     99             client: &client,
    100             db: &mut conn,
    101             account_type_id: *cfg.account_type_id,
    102             payment_type_id: *cfg.payment_type_id,
    103             account_type: cfg.account_type,
    104             currency: cfg.currency,
    105         }
    106         .run()
    107         .await?;
    108         return Ok(());
    109     }
    110 
    111     let notification = Notify::new();
    112 
    113     let watcher = async {
    114         watch_notification(&client, &notification).await;
    115     };
    116     let worker = async {
    117         let mut jitter = ExpoBackoffDecorr::default();
    118         loop {
    119             let res: WorkerResult = async {
    120                 let db = &mut PgListener::connect_with(pool).await?;
    121                 db.listen_all(["transfer"]).await?;
    122                 info!(target: "worker", "database listener connected");
    123                 loop {
    124                     let result = Worker {
    125                         client: &client,
    126                         db: db.acquire().await?,
    127                         account_type_id: *cfg.account_type_id,
    128                         payment_type_id: *cfg.payment_type_id,
    129                         account_type: cfg.account_type,
    130                         currency: cfg.currency,
    131                     }
    132                     .run()
    133                     .await;
    134                     match result {
    135                         Ok(()) => jitter.reset(),
    136                         Err(err @ WorkerError::Db(_)) => return Err(err),
    137                         Err(err) => {
    138                             let Some(delay) = retry_delay(&err, &mut jitter, cfg.frequency) else {
    139                                 return Err(err);
    140                             };
    141                             error!(target: "worker", ?delay, "synchronization pass failed: {err}");
    142                             tokio::time::sleep(delay).await;
    143                             continue;
    144                         }
    145                     }
    146                     tokio::select! {
    147                         _ = tokio::time::sleep(cfg.frequency) => {
    148                             info!(target: "worker", "running at frequency");
    149                         }
    150                         res = db.try_recv() => {
    151                             let mut ntf = res?;
    152                             while let Some(n) = ntf {
    153                                 debug!(target: "worker", "notification from {}", n.channel());
    154                                 ntf = db.next_buffered();
    155                             }
    156                         }
    157                         _ = notification.notified() => {
    158                             info!(target: "worker", "running at notification trigger");
    159                         }
    160                     }
    161                 }
    162             }
    163             .await;
    164             let err = res.unwrap_err();
    165             let Some(delay) = retry_delay(&err, &mut jitter, cfg.frequency) else {
    166                 break Err(err);
    167             };
    168             error!(target: "worker", ?delay, "database session failed: {err}");
    169             tokio::time::sleep(delay).await;
    170         }
    171     };
    172     // The optional notification listener reconnects independently. Polling
    173     // continues during its outages; a terminal worker result is not swallowed.
    174     tokio::select! {
    175         result = worker => result?,
    176         _ = watcher => unreachable!("notification listener does not return"),
    177     }
    178 }
    179 
    180 pub struct Worker<'a> {
    181     pub client: &'a Client<'a>,
    182     pub db: &'a mut PgConnection,
    183     pub currency: Currency,
    184     pub account_type_id: i64,
    185     pub payment_type_id: i64,
    186     pub account_type: AccountType,
    187 }
    188 
    189 impl Worker<'_> {
    190     /// Run a single worker pass
    191     pub async fn run(&mut self) -> WorkerResult {
    192         // Some worker operations are not idempotent, therefore it's not safe to have multiple worker
    193         // running concurrently. We use a global Postgres advisory lock to prevent it.
    194         if !db::worker_lock(self.db).await? {
    195             return Err(WorkerError::Concurrency);
    196         }
    197 
    198         db::task_start(&mut *self.db, "sync").await?;
    199 
    200         // Sync transactions
    201         let mut cursor: Timestamp = kv_get(&mut *self.db, SYNC_CURSOR_KEY)
    202             .await?
    203             .unwrap_or_default();
    204 
    205         loop {
    206             let page = self
    207                 .client
    208                 .history(self.account_type_id, OrderBy::DateAsc, 0, Some(cursor))
    209                 .await?;
    210             for transfer in page.page {
    211                 if transfer.date > cursor {
    212                     cursor = transfer.date;
    213                 }
    214                 let tx = extract_tx_info(transfer);
    215                 match tx {
    216                     Tx::In(tx_in) => self.ingest_in(tx_in).await?,
    217                     Tx::Out(tx_out) => self.ingest_out(tx_out).await?,
    218                 }
    219             }
    220 
    221             kv_set(&mut *self.db, SYNC_CURSOR_KEY, &cursor).await?;
    222 
    223             if !page.has_next_page {
    224                 break;
    225             }
    226         }
    227 
    228         db::task_success(&mut *self.db, "sync").await?;
    229         db::task_start(&mut *self.db, "submit").await?;
    230 
    231         // Send transactions
    232         let start = Timestamp::now();
    233         loop {
    234             let batch = db::pending_batch(&mut *self.db, &start).await?;
    235             if batch.is_empty() {
    236                 break;
    237             }
    238             for initiated in batch {
    239                 debug!(target: "worker", "send tx {initiated}");
    240                 let res = self
    241                     .client
    242                     .direct_payment(
    243                         initiated.creditor_id,
    244                         self.payment_type_id,
    245                         initiated.amount,
    246                         &initiated.subject,
    247                     )
    248                     .await;
    249                 fail_point("direct-payment")?;
    250                 match res {
    251                     Ok(tx) => {
    252                         // Update transaction status, on failure the initiated transaction will be orphan
    253                         db::initiated_submit_success(
    254                             &mut *self.db,
    255                             initiated.id,
    256                             &tx.date,
    257                             tx.id.0,
    258                         )
    259                         .await?;
    260                         trace!(target: "worker", "init tx {}", tx.id);
    261                     }
    262                     Err(e) => {
    263                         let msg = match &*e.err {
    264                             CyclosErr::Unknown(NotFoundError { entity_type, key }) => {
    265                                 format!("unknown {entity_type} {key}")
    266                             }
    267                             CyclosErr::Forbidden(err) => err.to_string(),
    268                             _ => return Err(e.into()),
    269                         };
    270                         // TODO is permission should be considered are hard or soft failure ?
    271                         db::initiated_submit_permanent_failure(&mut *self.db, initiated.id, &msg)
    272                             .await?;
    273                         error!(target: "worker", "initiated failure {initiated}: {msg}");
    274                     }
    275                 }
    276             }
    277         }
    278         db::task_success(&mut *self.db, "submit").await?;
    279         Ok(())
    280     }
    281 
    282     /// Ingest an incoming transaction
    283     async fn ingest_in(&mut self, tx: TxIn) -> WorkerResult {
    284         match self.account_type {
    285             AccountType::Exchange => {
    286                 let transfer = self.client.transfer(tx.transfer_id).await?;
    287                 let bounce = async |db: &mut PgConnection,
    288                                     reason: &str|
    289                        -> Result<(), WorkerError> {
    290                     // Fetch existing transaction
    291                     if let Some(chargeback) = transfer.charged_back_by {
    292                         let res = db::register_bounced_tx_in(
    293                             db,
    294                             &tx,
    295                             *chargeback.id,
    296                             reason,
    297                             &Timestamp::now(),
    298                         )
    299                         .await?;
    300                         if res.tx_new {
    301                             info!(target: "worker",
    302                                 "in {tx} bounced (recovered) in {}: {reason}", chargeback.id
    303                             );
    304                         } else {
    305                             trace!(target: "worker",
    306                                 "in {tx} already seen and bounced in {}: {reason}",chargeback.id
    307                             );
    308                         }
    309                     } else if !transfer.can_chargeback {
    310                         match db::register_tx_in(db, &tx, &None, &Timestamp::now()).await? {
    311                             AddIncomingResult::Success { new, .. } => {
    312                                 if new {
    313                                     warn!(target: "worker", "in {tx} cannot bounce: {reason}");
    314                                 } else {
    315                                     trace!(target: "worker", "in {tx} already seen and cannot bounce ");
    316                                 }
    317                             }
    318                             AddIncomingResult::ReservePubReuse
    319                             | AddIncomingResult::UnknownMapping
    320                             | AddIncomingResult::MappingReuse => unreachable!(),
    321                         }
    322                     } else {
    323                         let chargeback_id = self.client.chargeback(*transfer.id).await?;
    324                         fail_point("chargeback")?;
    325                         let res = db::register_bounced_tx_in(
    326                             db,
    327                             &tx,
    328                             chargeback_id,
    329                             reason,
    330                             &Timestamp::now(),
    331                         )
    332                         .await?;
    333                         if res.tx_new {
    334                             info!(target: "worker", "in {tx} bounced in {chargeback_id}: {reason}");
    335                         } else {
    336                             trace!(target: "worker", "in {tx} already seen and bounced in {chargeback_id}: {reason}");
    337                         }
    338                     }
    339                     Ok(())
    340                 };
    341                 if let Some(chargeback) = transfer.chargeback_of {
    342                     // This a chargeback of one of our transaction, if we bounce we might enter a loop
    343                     match db::initiated_chargeback_failure(&mut *self.db, *chargeback.id).await? {
    344                         ChargebackFailureResult::Unknown => {
    345                             trace!(target: "worker", "initiated failure unknown: charged back")
    346                         }
    347                         ChargebackFailureResult::Known(initiated) => {
    348                             error!(target: "worker", "initiated failure {initiated}: charged back")
    349                         }
    350                         ChargebackFailureResult::Idempotent(initiated) => {
    351                             trace!(target: "worker", "initiated failure {initiated} already seen: charged back")
    352                         }
    353                     }
    354                     // Sill register the incoming transaction as an incoming one
    355                     match db::register_tx_in(self.db, &tx, &None, &Timestamp::now()).await? {
    356                         AddIncomingResult::Success { new, .. } => {
    357                             if new {
    358                                 info!(target: "worker", "in {tx} chargeback");
    359                             } else {
    360                                 trace!(target: "worker", "in {tx} chargeback already seen");
    361                             }
    362                         }
    363                         AddIncomingResult::ReservePubReuse
    364                         | AddIncomingResult::UnknownMapping
    365                         | AddIncomingResult::MappingReuse => unreachable!(),
    366                     }
    367 
    368                     return Ok(());
    369                 }
    370                 match parse_incoming_unstructured(&tx.subject) {
    371                     Ok(subject) => {
    372                         match subject {
    373                             IncomingSubject::Key(subject) => {
    374                                 match db::register_tx_in(
    375                                     self.db,
    376                                     &tx,
    377                                     &Some(subject),
    378                                     &Timestamp::now(),
    379                                 )
    380                                 .await?
    381                                 {
    382                                     AddIncomingResult::Success { new, .. } => {
    383                                         if new {
    384                                             info!(target: "worker", "in {tx}");
    385                                         } else {
    386                                             trace!(target: "worker", "in {tx} already seen");
    387                                         }
    388                                     }
    389                                     AddIncomingResult::ReservePubReuse => {
    390                                         bounce(self.db, "reserve pub reuse").await?
    391                                     }
    392                                     AddIncomingResult::UnknownMapping => {
    393                                         bounce(self.db, "unknown mapping").await?
    394                                     }
    395                                     AddIncomingResult::MappingReuse => {
    396                                         bounce(self.db, "mapping reuse").await?
    397                                     }
    398                                 }
    399                             }
    400                             IncomingSubject::AdminBalanceAdjust => {
    401                                 // TODO bounce or skip ?
    402                             }
    403                         }
    404                     }
    405                     Err(e) => bounce(self.db, &e.to_string()).await?,
    406                 }
    407             }
    408             AccountType::Normal => {
    409                 match db::register_tx_in(self.db, &tx, &None, &Timestamp::now()).await? {
    410                     AddIncomingResult::Success { new, .. } => {
    411                         if new {
    412                             info!(target: "worker", "in {tx}");
    413                         } else {
    414                             trace!(target: "worker", "in {tx} already seen");
    415                         }
    416                     }
    417                     AddIncomingResult::ReservePubReuse
    418                     | AddIncomingResult::UnknownMapping
    419                     | AddIncomingResult::MappingReuse => unreachable!(),
    420                 }
    421             }
    422         }
    423         Ok(())
    424     }
    425 
    426     async fn ingest_out(&mut self, tx: TxOut) -> WorkerResult {
    427         match self.account_type {
    428             AccountType::Exchange => {
    429                 let transfer = self.client.transfer(tx.transfer_id).await?;
    430 
    431                 if transfer.charged_back_by.is_some() {
    432                     match db::initiated_chargeback_failure(&mut *self.db, *transfer.id).await? {
    433                         ChargebackFailureResult::Unknown => {
    434                             trace!(target: "worker", "initiated failure unknown: charged back")
    435                         }
    436                         ChargebackFailureResult::Known(initiated) => {
    437                             error!(target: "worker", "initiated failure {initiated}: charged back")
    438                         }
    439                         ChargebackFailureResult::Idempotent(initiated) => {
    440                             trace!(target: "worker", "initiated failure {initiated} already seen: charged back")
    441                         }
    442                     }
    443                 }
    444 
    445                 let kind = if let Ok(subject) = subject::parse_outgoing(&tx.subject) {
    446                     TxOutKind::Talerable(subject)
    447                 } else if let Some(chargeback) = &transfer.chargeback_of {
    448                     TxOutKind::Bounce(*chargeback.id)
    449                 } else {
    450                     TxOutKind::Simple
    451                 };
    452 
    453                 let res = db::register_tx_out(self.db, &tx, &kind, &Timestamp::now()).await?;
    454                 match res.result {
    455                     RegisterResult::idempotent => match kind {
    456                         TxOutKind::Simple => {
    457                             trace!(target: "worker", "out malformed {tx} already seen")
    458                         }
    459                         TxOutKind::Bounce(_) => {
    460                             trace!(target: "worker", "out bounce {tx} already seen")
    461                         }
    462                         TxOutKind::Talerable(_) => {
    463                             trace!(target: "worker", "out {tx} already seen")
    464                         }
    465                     },
    466                     RegisterResult::known => match kind {
    467                         TxOutKind::Simple => {
    468                             warn!(target: "worker", "out malformed {tx}")
    469                         }
    470                         TxOutKind::Bounce(_) => {
    471                             info!(target: "worker", "out bounce {tx}")
    472                         }
    473                         TxOutKind::Talerable(_) => {
    474                             info!(target: "worker", "out {tx}")
    475                         }
    476                     },
    477                     RegisterResult::recovered => match kind {
    478                         TxOutKind::Simple => {
    479                             warn!(target: "worker", "out malformed (recovered) {tx}")
    480                         }
    481                         TxOutKind::Bounce(_) => {
    482                             warn!(target: "worker", "out bounce (recovered) {tx}")
    483                         }
    484                         TxOutKind::Talerable(_) => {
    485                             warn!(target: "worker", "out (recovered) {tx}")
    486                         }
    487                     },
    488                 }
    489             }
    490             AccountType::Normal => {
    491                 let res = db::register_tx_out(self.db, &tx, &TxOutKind::Simple, &Timestamp::now())
    492                     .await?;
    493                 match res.result {
    494                     RegisterResult::idempotent => {
    495                         trace!(target: "worker", "out {tx} already seen");
    496                     }
    497                     RegisterResult::known => {
    498                         info!(target: "worker", "out {tx}");
    499                     }
    500                     RegisterResult::recovered => {
    501                         warn!(target: "worker", "out (recovered) {tx}");
    502                     }
    503                 }
    504             }
    505         }
    506         Ok(())
    507     }
    508 }
    509 
    510 pub enum Tx {
    511     In(TxIn),
    512     Out(TxOut),
    513 }
    514 
    515 pub fn extract_tx_info(tx: HistoryItem) -> Tx {
    516     let amount = amount::decimal(tx.amount.trim_start_matches('-'));
    517     let (id, name) = match tx.related_account.kind {
    518         AccountKind::System => (tx.related_account.ty.id, tx.related_account.ty.name),
    519         AccountKind::User { user } => (user.id, user.display),
    520     };
    521     if tx.amount.starts_with('-') {
    522         Tx::Out(TxOut {
    523             transfer_id: *tx.id,
    524             tx_id: tx.transaction.map(|it| *it.id),
    525             amount,
    526             subject: tx.description.unwrap_or_default(),
    527             creditor_id: *id,
    528             creditor_name: name,
    529             valued_at: tx.date,
    530         })
    531     } else {
    532         Tx::In(TxIn {
    533             transfer_id: *tx.id,
    534             tx_id: tx.transaction.map(|it| *it.id),
    535             amount,
    536             subject: tx.description.unwrap_or_default(),
    537             debtor_id: *id,
    538             debtor_name: name,
    539             valued_at: tx.date,
    540         })
    541     }
    542 }
    543 
    544 #[cfg(test)]
    545 mod retry_tests {
    546     use super::*;
    547 
    548     #[test]
    549     fn database_outages_retry_but_terminal_interruptions_propagate() {
    550         let mut jitter = ExpoBackoffDecorr::default();
    551         let err = WorkerError::Db(sqlx::Error::Io(
    552             std::io::ErrorKind::ConnectionRefused.into(),
    553         ));
    554         let delay = retry_delay(&err, &mut jitter, Duration::from_secs(60)).unwrap();
    555         assert!(delay >= Duration::from_millis(400));
    556         assert!(delay <= Duration::from_secs(30));
    557         assert!(
    558             retry_delay(
    559                 &WorkerError::Db(sqlx::Error::PoolClosed),
    560                 &mut jitter,
    561                 Duration::ZERO
    562             )
    563             .is_none()
    564         );
    565         assert!(
    566             retry_delay(
    567                 &WorkerError::Injected(InjectedErr("worker interrupted")),
    568                 &mut jitter,
    569                 Duration::ZERO
    570             )
    571             .is_none()
    572         );
    573         assert!(
    574             retry_delay(&WorkerError::Concurrency, &mut jitter, Duration::ZERO).unwrap()
    575                 >= Duration::from_secs(15)
    576         );
    577     }
    578 }