1use std::collections::HashMap;
12use std::sync::Arc;
13use std::time::Duration;
14
15use async_trait::async_trait;
16use gcloud_googleapis::pubsub::v1::PubsubMessage;
17use gcloud_pubsub::client::{Client, ClientConfig};
18use gcloud_pubsub::publisher::Publisher;
19use parking_lot::RwLock;
20use rustls::crypto::{aws_lc_rs, CryptoProvider};
21use serde::Serialize;
22use token_source::TokenSource;
23use tokio::sync::watch;
24use tracing::{debug, error, info, warn};
25
26use crate::{
27 config::ServerConfig,
28 jobs::{
29 Job, NotificationSend, RelayerHealthCheck, TokenSwapRequest, TransactionRequest,
30 TransactionSend, TransactionStatusCheck,
31 },
32 models::{DefaultAppState, NetworkType},
33 queues::QueueBackendType,
34 utils::RedisConnections,
35};
36use actix_web::web::ThinData;
37
38use super::schedule::{self, ScheduledJob};
39use super::{monitoring, QueueBackend, QueueBackendError, QueueHealth, QueueType, WorkerHandle};
40
41const DEPTH_REFRESH_INTERVAL_SECS: u64 = 60;
43
44#[derive(Clone, Default)]
47struct DepthSnapshot {
48 available: bool,
49 depths: HashMap<QueueType, u64>,
50}
51
52#[derive(Clone)]
54struct MonitoringReader {
55 http: reqwest::Client,
56 token_source: Arc<dyn TokenSource>,
57 subscription_to_queue: HashMap<String, QueueType>,
59}
60
61fn depth_for(snapshot: &DepthSnapshot, queue_type: QueueType) -> Option<u64> {
67 if snapshot.available {
68 Some(snapshot.depths.get(&queue_type).copied().unwrap_or(0))
69 } else {
70 None
71 }
72}
73
74pub(crate) const PUBSUB_MAX_MESSAGE_SIZE_BYTES: usize = 10 * 1024 * 1024;
76
77pub(crate) const RETRY_ATTEMPT_ATTR: &str = "retry_attempt";
80
81pub(crate) const ALL_QUEUE_TYPES: [QueueType; 8] = [
83 QueueType::TransactionRequest,
84 QueueType::TransactionSubmission,
85 QueueType::StatusCheck,
86 QueueType::StatusCheckEvm,
87 QueueType::StatusCheckStellar,
88 QueueType::Notification,
89 QueueType::TokenSwapRequest,
90 QueueType::RelayerHealthCheck,
91];
92
93pub(crate) fn check_message_size(len: usize) -> Result<(), QueueBackendError> {
97 if len > PUBSUB_MAX_MESSAGE_SIZE_BYTES {
98 return Err(QueueBackendError::SerializationError(format!(
99 "Message body size ({len} bytes) exceeds Pub/Sub limit ({PUBSUB_MAX_MESSAGE_SIZE_BYTES} bytes)"
100 )));
101 }
102 Ok(())
103}
104
105pub(crate) fn message_from_body(data: Vec<u8>, retry_attempt: usize) -> PubsubMessage {
110 let mut attributes = HashMap::new();
111 attributes.insert(RETRY_ATTEMPT_ATTR.to_string(), retry_attempt.to_string());
112 PubsubMessage {
113 data,
114 attributes,
115 ordering_key: String::new(),
116 ..Default::default()
117 }
118}
119
120pub(crate) fn job_to_message<T: Serialize>(
126 job: &Job<T>,
127 retry_attempt: usize,
128) -> Result<PubsubMessage, QueueBackendError> {
129 let data = serde_json::to_vec(job).map_err(|e| {
130 error!(error = %e, "Failed to serialize job to Pub/Sub message");
131 QueueBackendError::SerializationError(e.to_string())
132 })?;
133 check_message_size(data.len())?;
134 Ok(message_from_body(data, retry_attempt))
135}
136
137pub(crate) fn retry_attempt_from_attrs(attributes: &HashMap<String, String>) -> usize {
144 attributes
145 .get(RETRY_ATTEMPT_ATTR)
146 .and_then(|v| v.parse::<usize>().ok())
147 .unwrap_or(0)
148}
149
150pub(crate) fn topic_name(prefix: &str, queue_type: QueueType) -> String {
154 format!("{prefix}-{}", queue_type.queue_name())
155}
156
157pub(crate) fn subscription_name(prefix: &str, queue_type: QueueType) -> String {
159 format!("{prefix}-{}-sub", queue_type.queue_name())
160}
161
162fn missing_resources_error(missing: &[String]) -> QueueBackendError {
165 QueueBackendError::ConfigError(format!(
166 "Pub/Sub backend initialization failed. Missing/inaccessible resources: {}",
167 missing.join("; ")
168 ))
169}
170
171pub(crate) fn status_check_queue_type(network_type: Option<&NetworkType>) -> QueueType {
177 match network_type {
178 Some(NetworkType::Evm) => QueueType::StatusCheckEvm,
179 Some(NetworkType::Stellar) => QueueType::StatusCheckStellar,
180 _ => QueueType::StatusCheck,
181 }
182}
183
184#[derive(Clone)]
192pub struct PubSubBackend {
193 client: Client,
195 project_id: String,
197 topic_names: HashMap<QueueType, String>,
199 subscription_names: HashMap<QueueType, String>,
201 publishers: HashMap<QueueType, Publisher>,
203 redis_connections: Arc<RedisConnections>,
205 key_prefix: String,
207 emulator: bool,
209 depth_snapshot: Arc<RwLock<DepthSnapshot>>,
212 monitoring: Option<MonitoringReader>,
215 shutdown_tx: Arc<watch::Sender<bool>>,
217}
218
219impl std::fmt::Debug for PubSubBackend {
220 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
221 f.debug_struct("PubSubBackend")
222 .field("backend_type", &"pubsub")
223 .field("project_id", &self.project_id)
224 .field("queue_count", &self.topic_names.len())
225 .field("emulator", &self.emulator)
226 .finish()
227 }
228}
229
230impl PubSubBackend {
231 pub async fn new(redis_connections: Arc<RedisConnections>) -> Result<Self, QueueBackendError> {
243 info!("Initializing Pub/Sub queue backend");
244
245 if CryptoProvider::get_default().is_none() {
253 let _ = aws_lc_rs::default_provider().install_default();
254 }
255
256 let project_id =
257 ServerConfig::get_pubsub_project_id().map_err(QueueBackendError::ConfigError)?;
258 let topic_prefix = ServerConfig::get_pubsub_topic_prefix();
259 let emulator_host = ServerConfig::get_pubsub_emulator_host();
260 let emulator = emulator_host.is_some();
261
262 let config = ClientConfig {
266 project_id: Some(project_id.clone()),
267 ..ClientConfig::default()
268 };
269 let config = if emulator {
270 info!(
271 project_id = %project_id,
272 emulator_host = %emulator_host.unwrap_or_default(),
273 "Pub/Sub backend targeting emulator (auth skipped)"
274 );
275 config
276 } else {
277 config.with_auth().await.map_err(|e| {
278 QueueBackendError::ConfigError(format!(
279 "Pub/Sub ADC authentication failed (set GOOGLE_APPLICATION_CREDENTIALS, \
280 use workload identity, or the GCE metadata server): {e}"
281 ))
282 })?
283 };
284
285 let client = Client::new(config).await.map_err(|e| {
286 QueueBackendError::ConfigError(format!("Failed to create Pub/Sub client: {e}"))
287 })?;
288
289 let topic_names: HashMap<QueueType, String> = ALL_QUEUE_TYPES
290 .iter()
291 .map(|&qt| (qt, topic_name(&topic_prefix, qt)))
292 .collect();
293 let subscription_names: HashMap<QueueType, String> = ALL_QUEUE_TYPES
294 .iter()
295 .map(|&qt| (qt, subscription_name(&topic_prefix, qt)))
296 .collect();
297
298 let probes = ALL_QUEUE_TYPES.iter().map(|&qt| {
301 let client = client.clone();
302 let topic = topic_names[&qt].clone();
303 let sub = subscription_names[&qt].clone();
304 async move {
305 let topic_exists = client.topic(&topic).exists(None).await;
306 let sub_exists = client.subscription(&sub).exists(None).await;
307 (qt, topic, sub, topic_exists, sub_exists)
308 }
309 });
310 let probe_results = futures::future::join_all(probes).await;
311
312 let mut missing: Vec<String> = Vec::new();
313 for (qt, topic, sub, topic_exists, sub_exists) in probe_results {
314 match topic_exists {
315 Ok(true) => debug!(queue_type = %qt, topic = %topic, "Pub/Sub topic probe ok"),
316 Ok(false) => missing.push(format!("topic '{topic}' (for {qt}) does not exist")),
317 Err(e) => missing.push(format!("topic '{topic}' (for {qt}) probe failed: {e}")),
318 }
319 match sub_exists {
320 Ok(true) => {
321 debug!(queue_type = %qt, subscription = %sub, "Pub/Sub subscription probe ok")
322 }
323 Ok(false) => {
324 missing.push(format!("subscription '{sub}' (for {qt}) does not exist"))
325 }
326 Err(e) => {
327 missing.push(format!("subscription '{sub}' (for {qt}) probe failed: {e}"))
328 }
329 }
330 }
331 if !missing.is_empty() {
332 return Err(missing_resources_error(&missing));
333 }
334
335 let publishers: HashMap<QueueType, Publisher> = topic_names
337 .iter()
338 .map(|(&qt, name)| (qt, client.topic(name).new_publisher(None)))
339 .collect();
340
341 let key_prefix = ServerConfig::get_redis_key_prefix();
342 let (shutdown_tx, _) = watch::channel(false);
343
344 let monitoring = if emulator {
348 None
349 } else {
350 match monitoring::monitoring_token_source().await {
351 Ok(token_source) => {
352 let subscription_to_queue = subscription_names
353 .iter()
354 .map(|(&qt, name)| (name.clone(), qt))
355 .collect();
356 Some(MonitoringReader {
357 http: reqwest::Client::new(),
358 token_source,
359 subscription_to_queue,
360 })
361 }
362 Err(e) => {
363 warn!(error = %e, "Cloud Monitoring depth read disabled; backlog depth will be unavailable");
364 None
365 }
366 }
367 };
368
369 info!(
370 project_id = %project_id,
371 queue_count = topic_names.len(),
372 emulator = emulator,
373 depth_read = monitoring.is_some(),
374 "Pub/Sub backend initialized"
375 );
376
377 Ok(Self {
378 client,
379 project_id,
380 topic_names,
381 subscription_names,
382 publishers,
383 redis_connections,
384 key_prefix,
385 emulator,
386 depth_snapshot: Arc::new(RwLock::new(DepthSnapshot::default())),
387 monitoring,
388 shutdown_tx: Arc::new(shutdown_tx),
389 })
390 }
391
392 async fn enqueue<T: Serialize>(
397 &self,
398 queue_type: QueueType,
399 job: &Job<T>,
400 scheduled_on: Option<i64>,
401 ) -> Result<String, QueueBackendError> {
402 let now = chrono::Utc::now().timestamp();
403 match scheduled_on {
404 Some(run_at) if run_at > now => {
405 let body = serde_json::to_vec(job).map_err(|e| {
407 error!(queue_type = %queue_type, error = %e, "Failed to serialize job");
408 QueueBackendError::SerializationError(e.to_string())
409 })?;
410 check_message_size(body.len())?;
411 let scheduled = ScheduledJob {
412 body: String::from_utf8(body).map_err(|e| {
413 QueueBackendError::SerializationError(format!(
414 "job body is not valid UTF-8: {e}"
415 ))
416 })?,
417 retry_attempt: 0,
418 };
419 schedule::zadd_scheduled(
420 self.redis_connections.primary(),
421 &self.key_prefix,
422 queue_type,
423 &scheduled,
424 run_at,
425 )
426 .await?;
427 debug!(
428 queue_type = %queue_type,
429 run_at = run_at,
430 task_id = %job.message_id,
431 "Deferred job to Redis scheduled set"
432 );
433 Ok(job.message_id.clone())
434 }
435 _ => {
437 let message = job_to_message(job, 0)?;
438 self.publish(queue_type, message).await
439 }
440 }
441 }
442
443 async fn publish(
445 &self,
446 queue_type: QueueType,
447 message: PubsubMessage,
448 ) -> Result<String, QueueBackendError> {
449 let publisher = self
450 .publishers
451 .get(&queue_type)
452 .ok_or_else(|| QueueBackendError::QueueNotFound(format!("{queue_type}")))?;
453
454 let awaiter = publisher.publish(message).await;
455 let message_id = awaiter.get().await.map_err(|e| {
456 error!(queue_type = %queue_type, error = %e, "Pub/Sub publish failed");
457 QueueBackendError::QueueError(format!("Pub/Sub publish failed: {e}"))
458 })?;
459
460 debug!(
461 queue_type = %queue_type,
462 message_id = %message_id,
463 "Published message to Pub/Sub topic"
464 );
465 Ok(message_id)
466 }
467}
468
469#[async_trait]
470impl QueueBackend for PubSubBackend {
471 async fn produce_transaction_request(
472 &self,
473 job: Job<TransactionRequest>,
474 scheduled_on: Option<i64>,
475 ) -> Result<String, QueueBackendError> {
476 self.enqueue(QueueType::TransactionRequest, &job, scheduled_on)
477 .await
478 }
479
480 async fn produce_transaction_submission(
481 &self,
482 job: Job<TransactionSend>,
483 scheduled_on: Option<i64>,
484 ) -> Result<String, QueueBackendError> {
485 self.enqueue(QueueType::TransactionSubmission, &job, scheduled_on)
486 .await
487 }
488
489 async fn produce_transaction_status_check(
490 &self,
491 job: Job<TransactionStatusCheck>,
492 scheduled_on: Option<i64>,
493 ) -> Result<String, QueueBackendError> {
494 let queue_type = status_check_queue_type(job.data.network_type.as_ref());
496 self.enqueue(queue_type, &job, scheduled_on).await
497 }
498
499 async fn produce_notification(
500 &self,
501 job: Job<NotificationSend>,
502 scheduled_on: Option<i64>,
503 ) -> Result<String, QueueBackendError> {
504 self.enqueue(QueueType::Notification, &job, scheduled_on)
505 .await
506 }
507
508 async fn produce_token_swap_request(
509 &self,
510 job: Job<TokenSwapRequest>,
511 scheduled_on: Option<i64>,
512 ) -> Result<String, QueueBackendError> {
513 self.enqueue(QueueType::TokenSwapRequest, &job, scheduled_on)
514 .await
515 }
516
517 async fn produce_relayer_health_check(
518 &self,
519 job: Job<RelayerHealthCheck>,
520 scheduled_on: Option<i64>,
521 ) -> Result<String, QueueBackendError> {
522 self.enqueue(QueueType::RelayerHealthCheck, &job, scheduled_on)
523 .await
524 }
525
526 async fn initialize_workers(
527 &self,
528 app_state: Arc<ThinData<DefaultAppState>>,
529 handle: tokio::runtime::Handle,
530 ) -> Result<Vec<WorkerHandle>, QueueBackendError> {
531 info!(
532 queue_count = self.topic_names.len(),
533 "Initializing Pub/Sub workers"
534 );
535
536 let mut handles = Vec::new();
537 let pool = self.redis_connections.primary().clone();
538
539 for &queue_type in ALL_QUEUE_TYPES.iter() {
540 let subscription_name = self.subscription_names[&queue_type].clone();
541
542 handles.push(super::worker::spawn_worker_for_subscription(
544 self.client.clone(),
545 queue_type,
546 subscription_name,
547 app_state.clone(),
548 pool.clone(),
549 self.key_prefix.clone(),
550 self.shutdown_tx.subscribe(),
551 handle.clone(),
552 ));
553
554 handles.push(schedule::spawn_due_sweep(
556 queue_type,
557 self.publishers[&queue_type].clone(),
558 pool.clone(),
559 self.key_prefix.clone(),
560 self.shutdown_tx.subscribe(),
561 handle.clone(),
562 ));
563 }
564
565 let cron_scheduler = crate::queues::cron::CronScheduler::new(
569 app_state.clone(),
570 self.shutdown_tx.subscribe(),
571 handle.clone(),
572 );
573 handles.extend(cron_scheduler.start().await?);
574
575 if let Some(reader) = self.monitoring.clone() {
578 let snapshot = self.depth_snapshot.clone();
579 let project_id = self.project_id.clone();
580 let mut shutdown_rx = self.shutdown_tx.subscribe();
581 let depth_handle = handle.spawn(async move {
582 let interval = Duration::from_secs(DEPTH_REFRESH_INTERVAL_SECS);
583 loop {
584 match monitoring::read_backlog_depths(
585 &reader.http,
586 &reader.token_source,
587 &project_id,
588 &reader.subscription_to_queue,
589 )
590 .await
591 {
592 Ok(depths) => {
593 for (qt, depth) in &depths {
594 crate::metrics::set_queue_depth(
595 "pubsub",
596 qt.queue_name(),
597 *depth as f64,
598 );
599 }
600 *snapshot.write() = DepthSnapshot {
601 available: true,
602 depths,
603 };
604 }
605 Err(e) => {
606 snapshot.write().available = false;
608 warn!(error = %e, "Cloud Monitoring depth read failed; depth unavailable");
609 }
610 }
611 tokio::select! {
612 _ = tokio::time::sleep(interval) => {}
613 _ = shutdown_rx.changed() => break,
614 }
615 }
616 });
617 handles.push(WorkerHandle::Tokio(depth_handle));
618 }
619
620 {
627 let shutdown_tx = self.shutdown_tx.clone();
628 handle.spawn(async move {
629 let mut sigint =
630 tokio::signal::unix::signal(tokio::signal::unix::SignalKind::interrupt())
631 .expect("Failed to create SIGINT handler");
632 let mut sigterm =
633 tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate())
634 .expect("Failed to create SIGTERM handler");
635 tokio::select! {
636 _ = sigint.recv() => info!("Pub/Sub backend: received SIGINT, shutting down"),
637 _ = sigterm.recv() => info!("Pub/Sub backend: received SIGTERM, shutting down"),
638 }
639 let _ = shutdown_tx.send(true);
640 });
641 }
642
643 info!(
644 handle_count = handles.len(),
645 "Pub/Sub workers and due-sweeps started"
646 );
647 Ok(handles)
648 }
649
650 async fn health_check(&self) -> Result<Vec<QueueHealth>, QueueBackendError> {
651 let snapshot = self.depth_snapshot.read().clone();
656
657 let probes = ALL_QUEUE_TYPES.iter().map(|&queue_type| {
658 let client = self.client.clone();
659 let subscription = self.subscription_names[&queue_type].clone();
660 async move {
661 let reachable = client
662 .subscription(&subscription)
663 .exists(None)
664 .await
665 .unwrap_or(false);
666 (queue_type, reachable)
667 }
668 });
669 let reachability = futures::future::join_all(probes).await;
670
671 let mut health_statuses = Vec::with_capacity(reachability.len());
672 for (queue_type, is_healthy) in reachability {
673 let messages_visible = depth_for(&snapshot, queue_type);
674 health_statuses.push(QueueHealth {
675 queue_type,
676 messages_visible,
677 messages_in_flight: 0,
678 messages_dlq: 0,
681 backend: "pubsub".to_string(),
682 is_healthy,
683 });
684 }
685 Ok(health_statuses)
686 }
687
688 fn backend_type(&self) -> QueueBackendType {
689 QueueBackendType::PubSub
690 }
691
692 fn shutdown(&self) {
693 info!("Pub/Sub backend: broadcasting shutdown signal to all workers");
694 let _ = self.shutdown_tx.send(true);
695 }
696}
697
698#[cfg(test)]
699mod tests {
700 use super::*;
701 use crate::jobs::{Job, JobType, TransactionStatusCheck};
702 use crate::models::NetworkType;
703
704 fn sample_status_job(network: Option<NetworkType>) -> Job<TransactionStatusCheck> {
705 let data = TransactionStatusCheck {
706 transaction_id: "tx-1".to_string(),
707 relayer_id: "relayer-1".to_string(),
708 network_type: network,
709 metadata: None,
710 };
711 Job::new(JobType::TransactionStatusCheck, data)
712 }
713
714 #[test]
717 fn test_job_to_message_round_trip_and_attribute() {
718 let job = sample_status_job(Some(NetworkType::Evm));
719 let msg = job_to_message(&job, 3).expect("encode");
720
721 assert_eq!(
723 msg.attributes.get(RETRY_ATTEMPT_ATTR),
724 Some(&"3".to_string())
725 );
726 assert_eq!(msg.ordering_key, "");
728
729 let decoded: Job<TransactionStatusCheck> =
731 serde_json::from_slice(&msg.data).expect("decode");
732 assert_eq!(decoded.message_id, job.message_id);
733 assert_eq!(decoded.data.transaction_id, "tx-1");
734 }
735
736 #[test]
737 fn test_retry_attempt_from_attrs_defaults_to_zero() {
738 let empty = HashMap::new();
740 assert_eq!(retry_attempt_from_attrs(&empty), 0);
741
742 let mut attrs = HashMap::new();
744 attrs.insert(RETRY_ATTEMPT_ATTR.to_string(), "7".to_string());
745 assert_eq!(retry_attempt_from_attrs(&attrs), 7);
746
747 let mut bad = HashMap::new();
749 bad.insert(RETRY_ATTEMPT_ATTR.to_string(), "not-a-number".to_string());
750 assert_eq!(retry_attempt_from_attrs(&bad), 0);
751 }
752
753 #[test]
754 fn test_job_to_message_round_trips_retry_attempt() {
755 let job = sample_status_job(None);
757 let msg = job_to_message(&job, 5).expect("encode");
758 assert_eq!(retry_attempt_from_attrs(&msg.attributes), 5);
759 }
760
761 #[test]
764 fn test_topic_and_subscription_names_all_eight() {
765 let prefix = "relayer";
768 let expected = [
769 (QueueType::TransactionRequest, "relayer-transaction-request"),
770 (
771 QueueType::TransactionSubmission,
772 "relayer-transaction-submission",
773 ),
774 (QueueType::StatusCheck, "relayer-status-check"),
775 (QueueType::StatusCheckEvm, "relayer-status-check-evm"),
776 (
777 QueueType::StatusCheckStellar,
778 "relayer-status-check-stellar",
779 ),
780 (QueueType::Notification, "relayer-notification"),
781 (QueueType::TokenSwapRequest, "relayer-token-swap-request"),
782 (
783 QueueType::RelayerHealthCheck,
784 "relayer-relayer-health-check",
785 ),
786 ];
787 for (qt, topic) in expected {
788 assert_eq!(topic_name(prefix, qt), topic);
789 assert_eq!(subscription_name(prefix, qt), format!("{topic}-sub"));
790 }
791 }
792
793 #[test]
794 fn test_status_check_routing_four_cases() {
795 assert_eq!(
796 status_check_queue_type(Some(&NetworkType::Evm)),
797 QueueType::StatusCheckEvm
798 );
799 assert_eq!(
800 status_check_queue_type(Some(&NetworkType::Stellar)),
801 QueueType::StatusCheckStellar
802 );
803 assert_eq!(
804 status_check_queue_type(Some(&NetworkType::Solana)),
805 QueueType::StatusCheck
806 );
807 assert_eq!(status_check_queue_type(None), QueueType::StatusCheck);
808 }
809
810 #[test]
811 fn test_all_queue_types_has_eight_distinct() {
812 assert_eq!(ALL_QUEUE_TYPES.len(), 8);
813 let names: std::collections::HashSet<&str> =
814 ALL_QUEUE_TYPES.iter().map(|qt| qt.queue_name()).collect();
815 assert_eq!(names.len(), 8, "queue names must be distinct");
816 }
817
818 #[test]
819 fn test_pubsub_backend_type_value() {
820 assert_eq!(QueueBackendType::PubSub.as_str(), "pubsub");
821 assert_eq!(QueueBackendType::PubSub.to_string(), "pubsub");
822 }
823
824 #[test]
827 fn test_depth_for_unavailable_is_none_not_zero() {
828 let snap = DepthSnapshot::default();
830 assert!(!snap.available);
831 assert_eq!(depth_for(&snap, QueueType::StatusCheckEvm), None);
832 }
833
834 #[test]
835 fn test_depth_for_available_reports_real_value() {
836 let mut depths = HashMap::new();
837 depths.insert(QueueType::StatusCheckEvm, 42);
838 let snap = DepthSnapshot {
839 available: true,
840 depths,
841 };
842 assert_eq!(depth_for(&snap, QueueType::StatusCheckEvm), Some(42));
844 assert_eq!(depth_for(&snap, QueueType::TransactionRequest), Some(0));
847 }
848
849 #[test]
852 fn test_check_message_size_boundary() {
853 assert!(check_message_size(PUBSUB_MAX_MESSAGE_SIZE_BYTES).is_ok());
854 let err = check_message_size(PUBSUB_MAX_MESSAGE_SIZE_BYTES + 1)
855 .expect_err("over-limit must error");
856 assert!(matches!(err, QueueBackendError::SerializationError(_)));
857 assert!(err.to_string().contains("exceeds Pub/Sub limit"));
858 }
859
860 #[test]
861 fn test_job_to_message_rejects_oversized_payload() {
862 let big = "x".repeat(PUBSUB_MAX_MESSAGE_SIZE_BYTES + 1024);
864 let data = TransactionStatusCheck {
865 transaction_id: "tx".to_string(),
866 relayer_id: "r".to_string(),
867 network_type: Some(NetworkType::Evm),
868 metadata: Some(std::collections::HashMap::from([("blob".to_string(), big)])),
869 };
870 let job = Job::new(JobType::TransactionStatusCheck, data);
871 let err = job_to_message(&job, 0).expect_err("oversized payload must error");
872 assert!(matches!(err, QueueBackendError::SerializationError(_)));
873 assert!(err.to_string().contains("exceeds Pub/Sub limit"));
874 }
875
876 #[test]
879 fn test_missing_resources_error_names_each_missing_resource() {
880 let missing = vec![
881 "topic 'relayer-status-check' (for status-check) does not exist".to_string(),
882 "subscription 'relayer-notification-sub' (for notification) does not exist".to_string(),
883 ];
884 let err = missing_resources_error(&missing);
885 assert!(matches!(err, QueueBackendError::ConfigError(_)));
886 let msg = err.to_string();
887 assert!(msg.contains("Missing/inaccessible resources"));
888 assert!(msg.contains("relayer-status-check"));
889 assert!(msg.contains("relayer-notification-sub"));
890 }
891}