openzeppelin_relayer/queues/pubsub/
schedule.rs

1//! Redis-backed scheduled-job store and due-sweep for the Pub/Sub backend.
2//!
3//! Deferred and retrying jobs live in a per-queue Redis sorted set
4//! `{key_prefix}:pubsub:scheduled:{queue_name}` (member = serialized
5//! [`ScheduledJob`], score = target run time in Unix seconds). A due-sweep
6//! atomically claims due members (so one fleet instance publishes each) and
7//! publishes them to the queue's topic — the apalis store-and-run-when-due
8//! pattern. The topic therefore only ever carries already-due jobs.
9
10use std::sync::Arc;
11use std::time::Duration;
12
13use deadpool_redis::Pool;
14use serde::{Deserialize, Serialize};
15use tokio::sync::watch;
16use tokio::task::JoinHandle;
17use tracing::{debug, error, info, warn};
18
19use gcloud_pubsub::publisher::Publisher;
20
21use super::backend::message_from_body;
22use super::{QueueBackendError, QueueType, WorkerHandle};
23
24/// A job awaiting its due time in the Redis scheduled set.
25///
26/// The `body` is the JSON-serialized `Job<T>` (becomes the published message
27/// `data`); `retry_attempt` is the logical counter carried as the published
28/// message's `retry_attempt` attribute.
29#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
30pub(crate) struct ScheduledJob {
31    pub body: String,
32    pub retry_attempt: usize,
33}
34
35/// Redis sorted-set key holding a queue's deferred/retrying jobs.
36pub(crate) fn scheduled_set_key(key_prefix: &str, queue_type: QueueType) -> String {
37    format!("{key_prefix}:pubsub:scheduled:{}", queue_type.queue_name())
38}
39
40/// Adds a job to the scheduled set, scored by its target run time (Unix secs).
41///
42/// The member is the serialized [`ScheduledJob`]; the score is `run_at`.
43/// Re-adding a member with the same content updates its score (idempotent).
44pub(crate) async fn zadd_scheduled(
45    pool: &Arc<Pool>,
46    key_prefix: &str,
47    queue_type: QueueType,
48    job: &ScheduledJob,
49    run_at: i64,
50) -> Result<(), QueueBackendError> {
51    let key = scheduled_set_key(key_prefix, queue_type);
52    let member = serde_json::to_vec(job)
53        .map_err(|e| QueueBackendError::SerializationError(e.to_string()))?;
54
55    let mut conn = pool
56        .get()
57        .await
58        .map_err(|e| QueueBackendError::RedisError(e.to_string()))?;
59
60    let _: () = redis::cmd("ZADD")
61        .arg(&key)
62        .arg(run_at)
63        .arg(member)
64        .query_async(&mut conn)
65        .await
66        .map_err(|e| QueueBackendError::RedisError(e.to_string()))?;
67
68    Ok(())
69}
70
71/// Atomically claims up to `max` due members (`score <= now`), removing them so
72/// only one fleet instance publishes each.
73///
74/// Implemented as a single Lua script (`ZRANGEBYSCORE` + `ZREM`) so the
75/// range-and-remove is one atomic step — every instance runs the sweep and the
76/// atomic claim dedups (no leader election). Corrupt members are skipped (logged
77/// and dropped) so one bad entry can't wedge the sweep.
78pub(crate) async fn claim_due(
79    pool: &Arc<Pool>,
80    key_prefix: &str,
81    queue_type: QueueType,
82    now: i64,
83    max: usize,
84) -> Result<Vec<ScheduledJob>, QueueBackendError> {
85    let key = scheduled_set_key(key_prefix, queue_type);
86
87    // Range due members oldest-first (bounded), then remove them in one step.
88    let script = redis::Script::new(
89        r#"
90        local due = redis.call('ZRANGEBYSCORE', KEYS[1], '-inf', ARGV[1], 'LIMIT', 0, ARGV[2])
91        if #due > 0 then
92            redis.call('ZREM', KEYS[1], unpack(due))
93        end
94        return due
95        "#,
96    );
97
98    let mut conn = pool
99        .get()
100        .await
101        .map_err(|e| QueueBackendError::RedisError(e.to_string()))?;
102
103    let raw: Vec<Vec<u8>> = script
104        .key(&key)
105        .arg(now)
106        .arg(max as i64)
107        .invoke_async(&mut conn)
108        .await
109        .map_err(|e| QueueBackendError::RedisError(e.to_string()))?;
110
111    let mut jobs = Vec::with_capacity(raw.len());
112    for bytes in raw {
113        match serde_json::from_slice::<ScheduledJob>(&bytes) {
114            Ok(job) => jobs.push(job),
115            Err(e) => warn!(
116                queue_type = %queue_type,
117                error = %e,
118                "Dropping corrupt scheduled-set member"
119            ),
120        }
121    }
122    Ok(jobs)
123}
124
125/// Maximum due members claimed and published per sweep tick (bounds a burst).
126const DUE_SWEEP_BATCH: usize = 256;
127
128/// Per-queue sweep cadence. Status-check queues sweep fast (~1s) to match the
129/// proven apalis 2s fast-queue poll and preserve status-check latency; other
130/// queues sweep coarser. This cadence is the floor on retry/schedule latency.
131pub(crate) fn sweep_interval(queue_type: QueueType) -> Duration {
132    if queue_type.is_status_check() {
133        Duration::from_secs(1)
134    } else {
135        Duration::from_secs(5)
136    }
137}
138
139/// Spawns the due-sweep task for one queue: every `sweep_interval`, atomically
140/// claim due jobs and publish them to the topic.
141///
142/// Every fleet instance runs the sweep; the atomic claim (`claim_due`) dedups so
143/// each due job is published once. A rare double-publish is harmless (at-least-
144/// once + idempotent handlers). Fully interruptible by `shutdown_rx`.
145pub(crate) fn spawn_due_sweep(
146    queue_type: QueueType,
147    publisher: Publisher,
148    pool: Arc<Pool>,
149    key_prefix: String,
150    mut shutdown_rx: watch::Receiver<bool>,
151    runtime_handle: tokio::runtime::Handle,
152) -> WorkerHandle {
153    let interval = sweep_interval(queue_type);
154    info!(
155        queue_type = %queue_type,
156        sweep_interval_secs = interval.as_secs(),
157        "Spawning Pub/Sub due-sweep"
158    );
159
160    let handle: JoinHandle<()> = runtime_handle.spawn(async move {
161        loop {
162            if *shutdown_rx.borrow() {
163                break;
164            }
165
166            let now = chrono::Utc::now().timestamp();
167            match claim_due(&pool, &key_prefix, queue_type, now, DUE_SWEEP_BATCH).await {
168                Ok(jobs) => {
169                    for job in jobs {
170                        publish_scheduled(&publisher, &pool, &key_prefix, queue_type, &job).await;
171                    }
172                }
173                Err(e) => warn!(
174                    queue_type = %queue_type,
175                    error = %e,
176                    "Due-sweep claim failed; will retry next tick"
177                ),
178            }
179
180            tokio::select! {
181                _ = tokio::time::sleep(interval) => {}
182                _ = shutdown_rx.changed() => {
183                    if *shutdown_rx.borrow() {
184                        break;
185                    }
186                }
187            }
188        }
189        info!(queue_type = %queue_type, "Pub/Sub due-sweep stopped");
190    });
191
192    WorkerHandle::Tokio(handle)
193}
194
195/// Publishes one claimed scheduled job to its topic.
196///
197/// `claim_due` has already removed the job from Redis, so on publish failure the
198/// job is re-queued into the scheduled set (scored for immediate re-sweep) to
199/// avoid silently dropping a deferred or retrying job on a transient Pub/Sub
200/// error. A re-publish of a job that actually succeeded is harmless: Pub/Sub is
201/// at-least-once and the handlers are idempotent.
202async fn publish_scheduled(
203    publisher: &Publisher,
204    pool: &Arc<Pool>,
205    key_prefix: &str,
206    queue_type: QueueType,
207    job: &ScheduledJob,
208) {
209    let message = message_from_body(job.body.clone().into_bytes(), job.retry_attempt);
210    let awaiter = publisher.publish(message).await;
211    match awaiter.get().await {
212        Ok(message_id) => debug!(
213            queue_type = %queue_type,
214            message_id = %message_id,
215            retry_attempt = job.retry_attempt,
216            "Published due job from scheduled set"
217        ),
218        Err(e) => {
219            error!(
220                queue_type = %queue_type,
221                error = %e,
222                "Failed to publish due job; re-queuing to the scheduled set"
223            );
224            let now = chrono::Utc::now().timestamp();
225            if let Err(re) = zadd_scheduled(pool, key_prefix, queue_type, job, now).await {
226                error!(
227                    queue_type = %queue_type,
228                    error = %re,
229                    "Failed to re-queue due job after publish failure; job dropped this tick"
230                );
231            }
232        }
233    }
234}
235
236#[cfg(test)]
237mod tests {
238    use super::*;
239
240    #[test]
241    fn test_scheduled_set_key_format() {
242        assert_eq!(
243            scheduled_set_key("oz-relayer", QueueType::StatusCheckEvm),
244            "oz-relayer:pubsub:scheduled:status-check-evm"
245        );
246        assert_eq!(
247            scheduled_set_key("custom", QueueType::TransactionRequest),
248            "custom:pubsub:scheduled:transaction-request"
249        );
250    }
251
252    #[test]
253    fn test_scheduled_set_key_distinct_per_queue() {
254        let prefix = "oz-relayer";
255        let keys: std::collections::HashSet<String> = super::super::backend::ALL_QUEUE_TYPES
256            .iter()
257            .map(|&qt| scheduled_set_key(prefix, qt))
258            .collect();
259        assert_eq!(keys.len(), 8, "each queue type must have a distinct key");
260    }
261
262    #[test]
263    fn test_scheduled_job_round_trips() {
264        let job = ScheduledJob {
265            body: r#"{"message_id":"m1"}"#.to_string(),
266            retry_attempt: 3,
267        };
268        let bytes = serde_json::to_vec(&job).unwrap();
269        let decoded: ScheduledJob = serde_json::from_slice(&bytes).unwrap();
270        assert_eq!(decoded, job);
271    }
272
273    #[test]
274    fn test_sweep_interval_status_checks_are_fast() {
275        // Status checks sweep at ~1s; others coarser.
276        assert_eq!(
277            sweep_interval(QueueType::StatusCheck),
278            Duration::from_secs(1)
279        );
280        assert_eq!(
281            sweep_interval(QueueType::StatusCheckEvm),
282            Duration::from_secs(1)
283        );
284        assert_eq!(
285            sweep_interval(QueueType::StatusCheckStellar),
286            Duration::from_secs(1)
287        );
288        assert!(sweep_interval(QueueType::Notification) > Duration::from_secs(1));
289        assert!(sweep_interval(QueueType::TransactionRequest) > Duration::from_secs(1));
290    }
291
292    // ── due-sweep at-most-once (gated; requires running Redis) ───────
293    //
294    // Run with: cargo test --lib queues::pubsub::schedule -- --ignored
295    // Requires Redis on localhost:6379.
296
297    fn test_pool() -> Option<Arc<Pool>> {
298        let pool = deadpool_redis::Config::from_url("redis://127.0.0.1:6379")
299            .builder()
300            .ok()?
301            .max_size(8)
302            .runtime(deadpool_redis::Runtime::Tokio1)
303            .build()
304            .ok()?;
305        Some(Arc::new(pool))
306    }
307
308    #[tokio::test]
309    #[ignore]
310    async fn integration_claim_due_at_most_once_under_concurrency() {
311        let pool = test_pool().expect("Redis required for this test");
312        // Unique prefix per run so concurrent CI runs don't collide.
313        let prefix = format!("test-pubsub-{}", uuid::Uuid::new_v4());
314        let queue = QueueType::StatusCheckEvm;
315        let key = scheduled_set_key(&prefix, queue);
316
317        // One due member (run_at in the past).
318        let job = ScheduledJob {
319            body: r#"{"message_id":"only-one"}"#.to_string(),
320            retry_attempt: 0,
321        };
322        let now = chrono::Utc::now().timestamp();
323        zadd_scheduled(&pool, &prefix, queue, &job, now - 5)
324            .await
325            .expect("zadd");
326
327        // Two concurrent sweepers race to claim the single due member.
328        let (a, b) = tokio::join!(
329            claim_due(&pool, &prefix, queue, now, 256),
330            claim_due(&pool, &prefix, queue, now, 256),
331        );
332        let a = a.expect("claim a");
333        let b = b.expect("claim b");
334
335        // Exactly one sweeper got it; the atomic claim deduped.
336        let total = a.len() + b.len();
337        assert_eq!(
338            total, 1,
339            "due member must be claimed exactly once, got {total}"
340        );
341        assert!(a.contains(&job) ^ b.contains(&job));
342
343        // The set is now empty (claimed member removed).
344        let leftover = claim_due(&pool, &prefix, queue, now, 256)
345            .await
346            .expect("claim leftover");
347        assert!(leftover.is_empty(), "claimed member must be removed");
348
349        // Cleanup.
350        let mut conn = pool.get().await.unwrap();
351        let _: () = redis::cmd("DEL")
352            .arg(&key)
353            .query_async(&mut conn)
354            .await
355            .unwrap();
356    }
357
358    #[tokio::test]
359    #[ignore]
360    async fn integration_claim_due_excludes_future_members() {
361        // A far-future member is never claimed (topic only carries due jobs).
362        let pool = test_pool().expect("Redis required for this test");
363        let prefix = format!("test-pubsub-{}", uuid::Uuid::new_v4());
364        let queue = QueueType::TransactionRequest;
365        let key = scheduled_set_key(&prefix, queue);
366
367        let now = chrono::Utc::now().timestamp();
368        let future = ScheduledJob {
369            body: r#"{"message_id":"future"}"#.to_string(),
370            retry_attempt: 0,
371        };
372        zadd_scheduled(&pool, &prefix, queue, &future, now + 3600)
373            .await
374            .expect("zadd");
375
376        let claimed = claim_due(&pool, &prefix, queue, now, 256)
377            .await
378            .expect("claim");
379        assert!(claimed.is_empty(), "future member must not be claimed yet");
380
381        // Cleanup.
382        let mut conn = pool.get().await.unwrap();
383        let _: () = redis::cmd("DEL")
384            .arg(&key)
385            .query_async(&mut conn)
386            .await
387            .unwrap();
388    }
389}