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, ¬ification).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 }