openzeppelin_relayer/queues/pubsub/
schedule.rs1use 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#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
30pub(crate) struct ScheduledJob {
31 pub body: String,
32 pub retry_attempt: usize,
33}
34
35pub(crate) fn scheduled_set_key(key_prefix: &str, queue_type: QueueType) -> String {
37 format!("{key_prefix}:pubsub:scheduled:{}", queue_type.queue_name())
38}
39
40pub(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
71pub(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 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
125const DUE_SWEEP_BATCH: usize = 256;
127
128pub(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
139pub(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
195async 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 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 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 let prefix = format!("test-pubsub-{}", uuid::Uuid::new_v4());
314 let queue = QueueType::StatusCheckEvm;
315 let key = scheduled_set_key(&prefix, queue);
316
317 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 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 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 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 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 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 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}