1use std::sync::atomic::{AtomicBool, Ordering};
19use std::sync::Arc;
20
21use crate::cache::store::QueuedEventRow;
22use crate::cache::CacheStore;
23use crate::error::{RovenueError, RovenueResult};
24use crate::events::{EventEnvelope, EventsClient};
25use crate::logging::{LogLevel, Logger};
26
27const QUEUED_EVENT_PREFIXES: [&str; 1] = ["funnel_"];
35
36pub struct EventQueue {
37 store: Arc<CacheStore>,
38 events: Arc<EventsClient>,
39 logger: Option<Arc<Logger>>,
40 in_flight: AtomicBool,
41}
42
43impl EventQueue {
44 pub fn new(store: Arc<CacheStore>, events: Arc<EventsClient>) -> Self {
45 Self {
46 store,
47 events,
48 logger: None,
49 in_flight: AtomicBool::new(false),
50 }
51 }
52
53 pub fn with_logger(mut self, logger: Arc<Logger>) -> Self {
54 self.logger = Some(logger);
55 self
56 }
57
58 pub fn enqueue(self: &Arc<Self>, envelope_json: &str) -> RovenueResult<()> {
71 let envelope: EventEnvelope =
72 serde_json::from_str(envelope_json).map_err(|_| RovenueError::InvalidArgument())?;
73 if !QUEUED_EVENT_PREFIXES
74 .iter()
75 .any(|prefix| envelope.event_type.starts_with(prefix))
76 {
77 return Err(RovenueError::InvalidArgument());
78 }
79 self.store.append_queued_event(envelope_json)?;
80 self.trigger_drain();
81 Ok(())
82 }
83
84 pub fn trigger_drain(self: &Arc<Self>) {
88 let me = Arc::clone(self);
89 std::thread::spawn(move || me.drain_once());
90 }
91
92 pub fn drain_once(&self) {
110 if self
111 .in_flight
112 .compare_exchange(false, true, Ordering::SeqCst, Ordering::SeqCst)
113 .is_err()
114 {
115 return;
116 }
117 let stopped_early = self.drain_inner();
118 if stopped_early {
119 return;
123 }
124
125 if self.next_row().is_some()
137 && self
138 .in_flight
139 .compare_exchange(false, true, Ordering::SeqCst, Ordering::SeqCst)
140 .is_ok()
141 {
142 self.drain_inner();
143 }
144 }
145
146 fn drain_inner(&self) -> bool {
151 let mut stopped_early = false;
152 while let Some(row) = self.next_row() {
153 if !self.drain_one(&row) {
154 stopped_early = true;
155 break;
156 }
157 }
158 self.in_flight.store(false, Ordering::SeqCst);
159 stopped_early
160 }
161
162 fn next_row(&self) -> Option<QueuedEventRow> {
163 self.store.list_queued_events(1).ok()?.into_iter().next()
164 }
165
166 fn drain_one(&self, row: &QueuedEventRow) -> bool {
170 let envelope: EventEnvelope = match serde_json::from_str(&row.envelope_json) {
171 Ok(e) => e,
172 Err(_) => {
173 self.log_poison(row.id, None);
177 let _ = self.store.delete_queued_events(&[row.id]);
178 return true;
179 }
180 };
181 match self
182 .events
183 .post(&envelope, envelope.subscriber_id.as_deref())
184 {
185 Ok(()) => {
186 let _ = self.store.delete_queued_events(&[row.id]);
187 true
188 }
189 Err(err) => {
190 if err.is_poison() {
191 self.log_poison(row.id, err.http_status);
192 let _ = self.store.delete_queued_events(&[row.id]);
193 true
194 } else {
195 false
196 }
197 }
198 }
199 }
200
201 pub(crate) fn log_dropped(&self, event_type: &str, err: &RovenueError) {
205 let Some(logger) = &self.logger else { return };
206 let message = format!("rovenue: dropped {event_type} event, could not enqueue it: {err:?}");
207 let event_type = event_type.to_string();
208 logger.log(
209 LogLevel::Warn,
210 move || message,
211 move || {
212 let mut f = std::collections::HashMap::new();
213 f.insert("op".to_string(), "event_queue_enqueue".to_string());
214 f.insert("event_type".to_string(), event_type);
215 f
216 },
217 );
218 }
219
220 fn log_poison(&self, id: i64, http_status: Option<u16>) {
221 let Some(logger) = &self.logger else { return };
222 let message = format!(
223 "rovenue: dropping poisoned queued event id={id} (permanent rejection, http_status={http_status:?}) — retaining it would wedge the queue"
224 );
225 logger.log(
226 LogLevel::Error,
227 move || message,
228 move || {
229 let mut f = std::collections::HashMap::new();
230 f.insert("op".to_string(), "event_queue_drain".to_string());
231 if let Some(s) = http_status {
232 f.insert("http_status".to_string(), s.to_string());
233 }
234 f
235 },
236 );
237 }
238
239 #[doc(hidden)]
241 pub fn test_queue_len(&self) -> usize {
242 self.store
243 .list_queued_events(usize::MAX)
244 .map(|r| r.len())
245 .unwrap_or(0)
246 }
247}
248
249#[cfg(test)]
250mod tests {
251 use super::*;
252 use crate::transport::http_client::HttpClient;
253
254 fn envelope_json(event_id: &str, event_type: &str) -> String {
255 format!(
256 r#"{{"eventType":"{event_type}","occurredAt":"2026-05-28T10:00:00Z","eventId":"{event_id}","subscriberId":"rov_test"}}"#
257 )
258 }
259
260 fn setup(base_url: String) -> (Arc<CacheStore>, Arc<EventQueue>) {
261 let store = Arc::new(CacheStore::open_in_memory().unwrap());
262 setup_with_store(base_url, store)
263 }
264
265 fn setup_with_store(
266 base_url: String,
267 store: Arc<CacheStore>,
268 ) -> (Arc<CacheStore>, Arc<EventQueue>) {
269 let http = Arc::new(HttpClient::new(base_url, "pk_test".to_string()).with_max_attempts(1));
270 let events = Arc::new(EventsClient::new(http));
271 let queue = Arc::new(EventQueue::new(Arc::clone(&store), events));
272 (store, queue)
273 }
274
275 #[test]
280 fn enqueue_rejects_malformed_json() {
281 let (_store, queue) = setup("http://127.0.0.1:1".to_string());
282 let err = queue.enqueue("not json").unwrap_err();
283 assert_eq!(err.kind, crate::error::ErrorKind::InvalidArgument);
284 }
285
286 #[test]
287 fn enqueue_rejects_non_funnel_event_types() {
288 let (_store, queue) = setup("http://127.0.0.1:1".to_string());
289 let err = queue
290 .enqueue(&envelope_json("evt_1", "purchase"))
291 .unwrap_err();
292 assert_eq!(err.kind, crate::error::ErrorKind::InvalidArgument);
293 assert_eq!(queue.test_queue_len(), 0);
294 }
295
296 #[test]
297 fn enqueue_accepts_funnel_prefixed_event_types() {
298 let (store, queue) = setup("http://127.0.0.1:1".to_string());
299 queue
300 .enqueue(&envelope_json("evt_1", "funnel_session_started"))
301 .unwrap();
302 queue
303 .enqueue(&envelope_json("evt_2", "funnel_session_completed"))
304 .unwrap();
305 assert_eq!(store.list_queued_events(10).unwrap().len(), 2);
306 }
307
308 #[test]
315 fn drain_once_deletes_after_successful_post() {
316 let mut server = mockito::Server::new();
317 let m = server
318 .mock("POST", "/v1/events")
319 .with_status(202)
320 .with_header("content-type", "application/json")
321 .create();
322
323 let (store, queue) = setup(server.url());
324 store
325 .append_queued_event(&envelope_json("evt_1", "funnel_session_started"))
326 .unwrap();
327
328 queue.drain_once();
329
330 m.assert();
331 assert_eq!(store.list_queued_events(10).unwrap().len(), 0);
332 }
333
334 #[test]
335 fn drain_once_retains_and_stops_on_503() {
336 let mut server = mockito::Server::new();
337 let m1 = server
338 .mock("POST", "/v1/events")
339 .match_body(mockito::Matcher::Regex(r#""eventId":"evt_1""#.into()))
340 .with_status(503)
341 .create();
342 let m2 = server
345 .mock("POST", "/v1/events")
346 .match_body(mockito::Matcher::Regex(r#""eventId":"evt_2""#.into()))
347 .with_status(202)
348 .expect(0)
349 .create();
350
351 let (store, queue) = setup(server.url());
352 store
353 .append_queued_event(&envelope_json("evt_1", "funnel_session_started"))
354 .unwrap();
355 store
356 .append_queued_event(&envelope_json("evt_2", "funnel_session_completed"))
357 .unwrap();
358
359 queue.drain_once();
360
361 m1.assert();
362 m2.assert();
363 let remaining = store.list_queued_events(10).unwrap();
364 assert_eq!(
365 remaining.len(),
366 2,
367 "both entries retained — the drain must stop, not skip ahead"
368 );
369 assert!(remaining[0].envelope_json.contains("evt_1"));
370 assert!(remaining[1].envelope_json.contains("evt_2"));
371 }
372
373 #[test]
374 fn drain_once_retains_and_stops_on_network_error() {
375 let (store, queue) = setup("http://127.0.0.1:1".to_string());
378 store
379 .append_queued_event(&envelope_json("evt_1", "funnel_session_started"))
380 .unwrap();
381
382 queue.drain_once();
383
384 assert_eq!(store.list_queued_events(10).unwrap().len(), 1);
385 }
386
387 #[test]
388 fn drain_once_poison_deletes_4xx_and_continues() {
389 let mut server = mockito::Server::new();
390 let m1 = server
391 .mock("POST", "/v1/events")
392 .match_body(mockito::Matcher::Regex(r#""eventId":"evt_poison""#.into()))
393 .with_status(400)
394 .create();
395 let m2 = server
396 .mock("POST", "/v1/events")
397 .match_body(mockito::Matcher::Regex(r#""eventId":"evt_ok""#.into()))
398 .with_status(202)
399 .create();
400
401 let (store, queue) = setup(server.url());
402 store
403 .append_queued_event(&envelope_json("evt_poison", "funnel_session_started"))
404 .unwrap();
405 store
406 .append_queued_event(&envelope_json("evt_ok", "funnel_session_completed"))
407 .unwrap();
408
409 queue.drain_once();
410
411 m1.assert();
412 m2.assert();
413 assert_eq!(
414 store.list_queued_events(10).unwrap().len(),
415 0,
416 "the poisoned entry must not wedge the good entry behind it"
417 );
418 }
419
420 #[test]
424 fn drain_once_retains_and_stops_on_401() {
425 let mut server = mockito::Server::new();
426 let m1 = server
427 .mock("POST", "/v1/events")
428 .match_body(mockito::Matcher::Regex(r#""eventId":"evt_1""#.into()))
429 .with_status(401)
430 .create();
431 let m2 = server
432 .mock("POST", "/v1/events")
433 .match_body(mockito::Matcher::Regex(r#""eventId":"evt_2""#.into()))
434 .with_status(202)
435 .expect(0)
436 .create();
437
438 let (store, queue) = setup(server.url());
439 store
440 .append_queued_event(&envelope_json("evt_1", "funnel_session_started"))
441 .unwrap();
442 store
443 .append_queued_event(&envelope_json("evt_2", "funnel_session_completed"))
444 .unwrap();
445
446 queue.drain_once();
447
448 m1.assert();
449 m2.assert();
450 let remaining = store.list_queued_events(10).unwrap();
451 assert_eq!(
452 remaining.len(),
453 2,
454 "a 401 must retain the entry (and everything behind it), not poison-delete it"
455 );
456 }
457
458 #[test]
462 fn drain_once_retains_and_stops_on_403() {
463 let mut server = mockito::Server::new();
464 let m = server
465 .mock("POST", "/v1/events")
466 .with_status(403)
467 .expect(1)
468 .create();
469
470 let (store, queue) = setup(server.url());
471 store
472 .append_queued_event(&envelope_json("evt_1", "funnel_session_started"))
473 .unwrap();
474
475 queue.drain_once();
476
477 m.assert();
478 assert_eq!(
479 store.list_queued_events(10).unwrap().len(),
480 1,
481 "a 403 must retain the entry, not poison-delete it"
482 );
483 }
484
485 #[test]
491 fn drain_once_retains_and_stops_on_408() {
492 let mut server = mockito::Server::new();
493 let m = server
494 .mock("POST", "/v1/events")
495 .with_status(408)
496 .expect(1)
497 .create();
498
499 let (store, queue) = setup(server.url());
500 store
501 .append_queued_event(&envelope_json("evt_1", "funnel_session_started"))
502 .unwrap();
503
504 queue.drain_once();
505
506 m.assert();
507 assert_eq!(
508 store.list_queued_events(10).unwrap().len(),
509 1,
510 "a 408 must retain the entry, not poison-delete it"
511 );
512 }
513
514 #[test]
518 fn drain_once_poison_deletes_422() {
519 let mut server = mockito::Server::new();
520 let m1 = server
521 .mock("POST", "/v1/events")
522 .match_body(mockito::Matcher::Regex(r#""eventId":"evt_poison""#.into()))
523 .with_status(422)
524 .create();
525 let m2 = server
526 .mock("POST", "/v1/events")
527 .match_body(mockito::Matcher::Regex(r#""eventId":"evt_ok""#.into()))
528 .with_status(202)
529 .create();
530
531 let (store, queue) = setup(server.url());
532 store
533 .append_queued_event(&envelope_json("evt_poison", "funnel_session_started"))
534 .unwrap();
535 store
536 .append_queued_event(&envelope_json("evt_ok", "funnel_session_completed"))
537 .unwrap();
538
539 queue.drain_once();
540
541 m1.assert();
542 m2.assert();
543 assert_eq!(
544 store.list_queued_events(10).unwrap().len(),
545 0,
546 "422 is a genuine envelope rejection and must still be poison-deleted"
547 );
548 }
549
550 #[test]
551 fn drain_once_processes_oldest_first() {
552 let mut server = mockito::Server::new();
553 let seen = Arc::new(std::sync::Mutex::new(Vec::<String>::new()));
554 let seen_clone = Arc::clone(&seen);
555 let m = server
556 .mock("POST", "/v1/events")
557 .with_status(202)
558 .with_body_from_request(move |req| {
559 let empty = Vec::new();
560 let body = String::from_utf8_lossy(req.body().unwrap_or(&empty)).to_string();
561 seen_clone.lock().unwrap().push(body);
562 Vec::new()
563 })
564 .expect(3)
565 .create();
566
567 let (store, queue) = setup(server.url());
568 store
569 .append_queued_event(&envelope_json("evt_a", "funnel_session_started"))
570 .unwrap();
571 store
572 .append_queued_event(&envelope_json("evt_b", "funnel_session_completed"))
573 .unwrap();
574 store
575 .append_queued_event(&envelope_json("evt_c", "funnel_session_started"))
576 .unwrap();
577
578 queue.drain_once();
579
580 m.assert();
581 let order = seen.lock().unwrap();
582 assert_eq!(order.len(), 3);
583 assert!(order[0].contains("evt_a"));
584 assert!(order[1].contains("evt_b"));
585 assert!(order[2].contains("evt_c"));
586 }
587
588 #[test]
593 fn drain_once_is_a_noop_when_already_in_flight() {
594 let mut server = mockito::Server::new();
595 let m = server
596 .mock("POST", "/v1/events")
597 .with_status(202)
598 .expect(0)
599 .create();
600
601 let (store, queue) = setup(server.url());
602 store
603 .append_queued_event(&envelope_json("evt_1", "funnel_session_started"))
604 .unwrap();
605
606 queue.in_flight.store(true, Ordering::SeqCst);
608 queue.drain_once();
609 m.assert();
610 assert_eq!(
611 store.list_queued_events(10).unwrap().len(),
612 1,
613 "a concurrent drain must not touch the queue"
614 );
615
616 queue.in_flight.store(false, Ordering::SeqCst);
618 queue.drain_once();
619 assert_eq!(store.list_queued_events(10).unwrap().len(), 0);
620 }
621
622 #[test]
638 fn rearm_picks_up_entry_that_appears_right_after_the_first_pass_finds_the_queue_empty() {
639 let mut server = mockito::Server::new();
640 let m = server.mock("POST", "/v1/events").with_status(202).create();
641 let (store, queue) = setup(server.url());
642
643 assert!(queue
645 .in_flight
646 .compare_exchange(false, true, Ordering::SeqCst, Ordering::SeqCst)
647 .is_ok());
648
649 assert!(
654 !queue.drain_inner(),
655 "an empty queue must not report stopped_early"
656 );
657 assert!(!queue.in_flight.load(Ordering::SeqCst));
658
659 store
667 .append_queued_event(&envelope_json("evt_1", "funnel_session_started"))
668 .unwrap();
669
670 assert!(queue.next_row().is_some(), "the row must be visible");
674 assert!(
675 queue
676 .in_flight
677 .compare_exchange(false, true, Ordering::SeqCst, Ordering::SeqCst)
678 .is_ok(),
679 "re-arm CAS must succeed — nothing else holds the flag here"
680 );
681 queue.drain_inner();
682
683 m.assert();
684 assert_eq!(
685 store.list_queued_events(10).unwrap().len(),
686 0,
687 "the re-arm pass must deliver work that appeared right after the \
688 first pass found the queue empty, without waiting for another trigger"
689 );
690 assert!(!queue.in_flight.load(Ordering::SeqCst));
691 }
692
693 #[test]
694 fn drain_once_end_to_end_rearms_when_an_entry_is_already_queued() {
695 let mut server = mockito::Server::new();
704 let m = server.mock("POST", "/v1/events").with_status(202).create();
705 let (store, queue) = setup(server.url());
706 store
707 .append_queued_event(&envelope_json("evt_1", "funnel_session_started"))
708 .unwrap();
709
710 queue.drain_once();
711
712 m.assert();
713 assert_eq!(store.list_queued_events(10).unwrap().len(), 0);
714 assert!(!queue.in_flight.load(Ordering::SeqCst));
715 }
716
717 #[test]
725 fn drain_once_does_not_rearm_after_a_retry_later_stop() {
726 let mut server = mockito::Server::new();
727 let m = server
728 .mock("POST", "/v1/events")
729 .with_status(503)
730 .expect(1)
731 .create();
732
733 let (store, queue) = setup(server.url());
734 store
735 .append_queued_event(&envelope_json("evt_1", "funnel_session_started"))
736 .unwrap();
737
738 queue.drain_once();
739
740 m.assert();
741 assert_eq!(
742 store.list_queued_events(10).unwrap().len(),
743 1,
744 "retained entry must still be present — proves no spin-retry happened"
745 );
746 assert!(
747 !queue.in_flight.load(Ordering::SeqCst),
748 "the flag must still be cleared so the next explicit trigger can run"
749 );
750 }
751
752 #[test]
759 fn kill_safety_enqueue_survives_reopening_the_store() {
760 let dir = tempfile::tempdir().unwrap();
761 let path = dir.path().join("event_queue_test.sqlite3");
762
763 {
764 let store = Arc::new(CacheStore::open(&path).unwrap());
765 let (_s, queue) = setup_with_store("http://127.0.0.1:1".to_string(), store);
766 queue
767 .enqueue(&envelope_json("evt_1", "funnel_session_started"))
768 .unwrap();
769 }
773
774 let mut server = mockito::Server::new();
775 let m = server.mock("POST", "/v1/events").with_status(202).create();
776 let store2 = Arc::new(CacheStore::open(&path).unwrap());
777 assert_eq!(
778 store2.list_queued_events(10).unwrap().len(),
779 1,
780 "the entry must be visible to a brand-new store instance over the same file"
781 );
782 let (_s2, queue2) = setup_with_store(server.url(), store2);
783 queue2.drain_once();
784
785 m.assert();
786 assert_eq!(_s2.list_queued_events(10).unwrap().len(), 0);
787 }
788
789 #[test]
796 fn enqueue_triggers_a_background_drain() {
797 let mut server = mockito::Server::new();
798 let m = server.mock("POST", "/v1/events").with_status(202).create();
799 let (store, queue) = setup(server.url());
800
801 queue
802 .enqueue(&envelope_json("evt_1", "funnel_session_started"))
803 .unwrap();
804
805 let mut drained = false;
806 for _ in 0..50 {
807 if store.list_queued_events(10).unwrap().is_empty() {
808 drained = true;
809 break;
810 }
811 std::thread::sleep(std::time::Duration::from_millis(20));
812 }
813 assert!(drained, "enqueue() must trigger a background drain");
814 m.assert();
815 }
816}