openzeppelin_relayer/queues/pubsub/
backend.rs

1//! GCP Pub/Sub backend implementation.
2//!
3//! Provides a Pub/Sub-backed implementation of the `QueueBackend` trait. Each of
4//! the 8 queue types maps to a topic (publish) and a subscription (consume).
5//! Deferred/retrying jobs are held in Redis sorted sets and published when due
6//! (store-and-run-when-due), so the topic only ever carries already-due jobs.
7//!
8//! Anchored to the proven Redis/Apalis semantics; uses `src/queues/sqs/` only as
9//! a reference for the dumb-pipe plumbing.
10
11use 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
41/// How often the backlog-depth snapshot is refreshed from Cloud Monitoring.
42const DEPTH_REFRESH_INTERVAL_SECS: u64 = 60;
43
44/// Cached backlog-depth snapshot. `available = false` means depth is unavailable
45/// (the read failed, or we're under the emulator) — never reported as 0.
46#[derive(Clone, Default)]
47struct DepthSnapshot {
48    available: bool,
49    depths: HashMap<QueueType, u64>,
50}
51
52/// Holds what the low-frequency Cloud Monitoring depth read needs.
53#[derive(Clone)]
54struct MonitoringReader {
55    http: reqwest::Client,
56    token_source: Arc<dyn TokenSource>,
57    /// Reverse of `subscription_names`: subscription id → queue type.
58    subscription_to_queue: HashMap<String, QueueType>,
59}
60
61/// Backlog depth for a queue from the cached snapshot, for the health endpoint.
62///
63/// `Some(n)` is a real count (including a genuine `Some(0)` empty queue); `None`
64/// is **unavailable** (read hasn't succeeded / emulator) — never conflated with
65/// empty.
66fn 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
74/// Pub/Sub maximum message size (10 MB).
75pub(crate) const PUBSUB_MAX_MESSAGE_SIZE_BYTES: usize = 10 * 1024 * 1024;
76
77/// Message attribute carrying the logical retry counter (single canonical
78/// source — Pub/Sub's physical `delivery_attempt` is deliberately not used).
79pub(crate) const RETRY_ATTEMPT_ATTR: &str = "retry_attempt";
80
81/// All queue types this backend serves (8 topics + 8 subscriptions).
82pub(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
93// ── Wire codec: Job<T> ⇄ PubsubMessage ──────────────────────────────
94
95/// Errors if an encoded body exceeds Pub/Sub's 10 MB message-size limit.
96pub(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
105/// Builds a `PubsubMessage` from an already-serialized body + logical retry
106/// attempt. The `retry_attempt` travels as a string attribute and `ordering_key`
107/// is left unset (ordering off in v1). No size check: the
108/// body was validated by the producer (see [`check_message_size`]).
109pub(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
120/// Serializes a `Job<T>` into a `PubsubMessage`.
121///
122/// The body is UTF-8 JSON (the same serialization the SQS/Redis backends use).
123/// Returns a serialization/size error if the encoded body exceeds Pub/Sub's
124/// 10 MB limit.
125pub(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
137/// Reads the logical retry attempt from a message's attributes.
138///
139/// Defaults to 0 when the attribute is absent or unparsable (the first
140/// delivery of a freshly produced job). Pub/Sub's physical `delivery_attempt`
141/// is deliberately NOT consulted (absent without a dead-letter policy; also
142/// counts non-failure redeliveries).
143pub(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
150// ── Resource naming + status routing ────────────────────────────────
151
152/// Topic name for a queue type: `{prefix}-{queue_name}`.
153pub(crate) fn topic_name(prefix: &str, queue_type: QueueType) -> String {
154    format!("{prefix}-{}", queue_type.queue_name())
155}
156
157/// Subscription name for a queue type: `{prefix}-{queue_name}-sub`.
158pub(crate) fn subscription_name(prefix: &str, queue_type: QueueType) -> String {
159    format!("{prefix}-{}-sub", queue_type.queue_name())
160}
161
162/// Builds the startup fail-fast error naming every missing/inaccessible
163/// topic/subscription. Each entry already says exactly what is missing.
164fn 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
171/// Selects the status-check queue for a network type, mirroring the SQS backend.
172///
173/// EVM and Stellar use dedicated queues (independent concurrency pools +
174/// network-tuned backoff); all other/unknown network types use the generic
175/// status-check queue. Status checks MUST NOT collapse onto a single queue.
176pub(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// ── Backend ──────────────────────────────────────────────────────────
185
186/// GCP Pub/Sub backend for job queue operations.
187///
188/// Constructed only after a startup probe confirms every required topic AND
189/// subscription exists. Deferred-job scheduling and cron locks reuse the
190/// relayer's existing Redis.
191#[derive(Clone)]
192pub struct PubSubBackend {
193    /// Pub/Sub client (cloneable, holds the gRPC connection pool).
194    client: Client,
195    /// GCP project id (from `PUBSUB_PROJECT_ID`).
196    project_id: String,
197    /// Topic name per queue type (publish channel).
198    topic_names: HashMap<QueueType, String>,
199    /// Subscription name per queue type (consume channel).
200    subscription_names: HashMap<QueueType, String>,
201    /// Cached per-topic publishers (each runs background flush tasks).
202    publishers: HashMap<QueueType, Publisher>,
203    /// Reused for the scheduled-job sorted sets and cron `DistributedLock`.
204    redis_connections: Arc<RedisConnections>,
205    /// Redis key prefix for scheduled-set keys (same prefix the repos/locks use).
206    key_prefix: String,
207    /// True when targeting the emulator (Cloud Monitoring depth is unavailable).
208    emulator: bool,
209    /// Cached backlog-depth snapshot, refreshed by a low-frequency Cloud
210    /// Monitoring read. Shared across clones so `health_check` sees fresh data.
211    depth_snapshot: Arc<RwLock<DepthSnapshot>>,
212    /// Cloud Monitoring depth reader; `None` under the emulator or when ADC for
213    /// monitoring can't be resolved (depth then stays unavailable).
214    monitoring: Option<MonitoringReader>,
215    /// Broadcast graceful-shutdown signal to all workers and cron tasks.
216    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    /// Creates a new Pub/Sub backend.
232    ///
233    /// Authenticates via ADC (or targets the emulator when `PUBSUB_EMULATOR_HOST`
234    /// is set), builds the topic/subscription maps and cached publishers, and
235    /// probes that every required topic AND subscription exists — failing fast
236    /// with a `ConfigError` naming what is missing. Dead-letter topics are NOT
237    /// probed.
238    ///
239    /// # Errors
240    /// Returns `ConfigError` if `PUBSUB_PROJECT_ID` is unset, ADC cannot be
241    /// resolved, or any required topic/subscription is missing/inaccessible.
242    pub async fn new(redis_connections: Arc<RedisConnections>) -> Result<Self, QueueBackendError> {
243        info!("Initializing Pub/Sub queue backend");
244
245        // rustls 0.23 needs a process-default CryptoProvider when more than one
246        // provider is compiled in. Both are present here (aws-lc-rs via the gcloud
247        // and AWS SDK trees, ring via aws-config/Solana), so rustls can't select
248        // one automatically and gcloud-pubsub's TLS — which uses the process
249        // default — panics on the first real-GCP connection. The emulator path is
250        // plaintext, so this only surfaces against real GCP. Install once; ignore
251        // if a default is already set.
252        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        // ClientConfig::default() picks up PUBSUB_EMULATOR_HOST and selects the
263        // Emulator environment; we always pin the project id explicitly. Real
264        // GCP additionally resolves ADC via with_auth().
265        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        // Probe every topic AND subscription concurrently; fail fast on any
299        // missing/inaccessible resource (mirrors the SQS startup probe).
300        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        // Cache one publisher per topic (each runs background flush tasks).
336        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        // Cloud Monitoring depth reader (real GCP only). Failure to set up ADC
345        // for monitoring is non-fatal — depth simply stays unavailable; the
346        // backend's core publish/consume path does not depend on it.
347        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    /// Enqueues a job: if `scheduled_on` is in the future, store it in the Redis
393    /// scheduled set (published when due by the sweep); otherwise publish to the
394    /// mapped topic now. Returns the server message id (immediate) or the job's
395    /// message id (deferred). First enqueue carries `retry_attempt = 0`.
396    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                // Defer: hold in Redis, published when due (store-and-run-when-due).
406                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            // Immediate: serialize + size-check + publish now (retry_attempt = 0).
436            _ => {
437                let message = job_to_message(job, 0)?;
438                self.publish(queue_type, message).await
439            }
440        }
441    }
442
443    /// Publishes a message to a queue's topic now, returning the server message id.
444    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        // Route by network type to one of three queues (never collapse to one).
495        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            // One pull-loop worker per subscription (consume → handle → settle).
543            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            // One due-sweep per queue (publishes deferred/retrying jobs when due).
555            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        // Shared, backend-neutral cron scheduler (cleanup + token-swap crons).
566        // Same scheduler/lock keys as the SQS backend, so a mixed fleet runs
567        // each cron at most once per interval.
568        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        // Low-frequency, batched Cloud Monitoring depth read → snapshot + gauge.
576        // Only on real GCP; under the emulator depth stays unavailable.
577        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                            // Mark unavailable (never report a stale/0 depth as real).
607                            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        // SIGINT/SIGTERM → broadcast shutdown to all workers and due-sweeps.
621        //
622        // NOT pushed into `handles`: this task only resolves on an OS signal, so on a
623        // programmatic/server-driven shutdown (no signal sent) it would never complete
624        // and `drain_worker_handles` would block for the full drain timeout waiting on
625        // it instead of the real worker/due-sweep tasks.
626        {
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        // Reachability per queue type (probe the subscription), plus backlog
652        // depth from the cached Cloud Monitoring snapshot. Depth is `None`
653        // (unavailable) when the read hasn't succeeded or we're on the emulator
654        // — never a hardcoded 0.
655        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                // The relayer publishes to no dead-letter destination; an
679                // operator's optional native policy is not tracked here.
680                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    // ── wire codec ─────────────────────────────────────────────────
715
716    #[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        // retry_attempt attribute carried as a string.
722        assert_eq!(
723            msg.attributes.get(RETRY_ATTEMPT_ATTR),
724            Some(&"3".to_string())
725        );
726        // ordering off in v1.
727        assert_eq!(msg.ordering_key, "");
728
729        // Body is the same JSON the other backends use.
730        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        // Missing attribute → 0.
739        let empty = HashMap::new();
740        assert_eq!(retry_attempt_from_attrs(&empty), 0);
741
742        // Present and valid.
743        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        // Present but unparsable → 0 (defensive default).
748        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        // The attribute the worker writes is the one it reads back.
756        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    // ── resource naming + status routing ───────────────────────────
762
763    #[test]
764    fn test_topic_and_subscription_names_all_eight() {
765        // The prefix carries no trailing separator; the `-` is inserted by the
766        // name builders, so the final names are still `relayer-<queue>`.
767        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    // ── depth observability — unavailable is never 0 ──
825
826    #[test]
827    fn test_depth_for_unavailable_is_none_not_zero() {
828        // Snapshot unavailable (read never succeeded / emulator) → None, NOT 0.
829        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        // Present → real value (never 0 while work is queued).
843        assert_eq!(depth_for(&snap, QueueType::StatusCheckEvm), Some(42));
844        // Available but absent for this queue → a genuine empty 0 (distinct from
845        // the unavailable `None`).
846        assert_eq!(depth_for(&snap, QueueType::TransactionRequest), Some(0));
847    }
848
849    // ── oversized payload returns a clear size error ────
850
851    #[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        // A job whose JSON body exceeds 10 MB returns a serialization/size error.
863        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    // ── startup fail-fast wording names what's missing ──────
877
878    #[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}