a3s-boot 0.2.0

Adapter-first modular Rust web framework for A3S inspired by Nest.js
Documentation
#![cfg(feature = "queue-postgres")]

use std::collections::BTreeSet;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::{Arc, Mutex};
use std::time::Duration;

use a3s_boot::{ModuleRef, PostgresQueueBackend, Queue, QueueContext, QueueJob, QueueOptions};
use serde_json::json;
use uuid::Uuid;

fn postgres_url() -> Option<String> {
    std::env::var("A3S_BOOT_POSTGRES_URL")
        .ok()
        .filter(|value| !value.trim().is_empty())
}

#[tokio::test]
async fn independent_backends_share_leases_without_duplicate_processing() {
    let Some(url) = postgres_url() else {
        eprintln!("skipping PostgreSQL queue test; set A3S_BOOT_POSTGRES_URL");
        return;
    };
    let name = format!("boot-shared-{}", Uuid::new_v4());
    let options = QueueOptions::new()
        .with_poll_interval(Duration::from_millis(5))
        .with_lease_duration(Duration::from_millis(60));
    let first_backend = PostgresQueueBackend::connect(&url, &name, options)
        .await
        .expect("connect first shared backend");
    let diagnostics = first_backend.clone();
    let second_backend = PostgresQueueBackend::connect(&url, &name, options)
        .await
        .expect("connect second shared backend");
    let first = Queue::new(name.clone(), first_backend);
    let second = Queue::new(name, second_backend);
    let calls = Arc::new(AtomicUsize::new(0));
    let processed = Arc::new(Mutex::new(Vec::new()));
    let processor = || {
        let calls = Arc::clone(&calls);
        let processed = Arc::clone(&processed);
        move |job: QueueJob, _context: QueueContext| {
            let calls = Arc::clone(&calls);
            let processed = Arc::clone(&processed);
            async move {
                tokio::time::sleep(Duration::from_millis(90)).await;
                processed.lock().expect("processed lock").push(job.id);
                calls.fetch_add(1, Ordering::SeqCst);
                Ok(())
            }
        }
    };
    first
        .process("shared", processor())
        .expect("register first processor");
    second
        .process("shared", processor())
        .expect("register second processor");

    let mut expected = BTreeSet::new();
    for value in 0..12 {
        let receipt = first
            .enqueue("shared", &json!({"value": value}))
            .await
            .expect("enqueue shared job");
        expected.insert(receipt.id);
    }
    first
        .start(ModuleRef::new())
        .await
        .expect("start first queue");
    second
        .start(ModuleRef::new())
        .await
        .expect("start second queue");
    tokio::time::timeout(Duration::from_secs(10), async {
        while calls.load(Ordering::SeqCst) < expected.len() {
            tokio::time::sleep(Duration::from_millis(10)).await;
        }
    })
    .await
    .expect("both backends should drain shared work");
    first.shutdown().await.expect("shutdown first queue");
    second.shutdown().await.expect("shutdown second queue");

    let actual = processed
        .lock()
        .expect("processed lock")
        .iter()
        .cloned()
        .collect::<BTreeSet<_>>();
    assert_eq!(calls.load(Ordering::SeqCst), expected.len());
    assert_eq!(actual, expected);
    assert_eq!(
        diagnostics
            .stats_async()
            .await
            .expect("queue stats")
            .completed,
        12
    );
}