use std::time::Duration;
use rainier_drivers::{AwsConnector, SqsClient};
use rainier_support::{BoxFuture, Error, Result};
use serde_json::json;
use crate::job::QueuedJob;
use crate::queue::Queue;
const RECEIPT_HANDLE: &str = "__sqs_receipt_handle";
pub struct SqsQueue {
client: SqsClient,
visibility: Duration,
wait_time: Duration,
}
impl SqsQueue {
pub fn new(connector: &AwsConnector, queue_url: impl Into<String>) -> Self {
Self::with_client(SqsClient::new(connector, queue_url))
}
pub fn with_client(client: SqsClient) -> Self {
Self { client, visibility: Duration::from_secs(90), wait_time: Duration::ZERO }
}
pub fn with_visibility_timeout(mut self, visibility: Duration) -> Self {
self.visibility = visibility;
self
}
pub fn with_wait_time(mut self, wait_time: Duration) -> Self {
self.wait_time = wait_time;
self
}
pub fn queue_url(&self) -> &str {
self.client.queue_url()
}
pub fn client(&self) -> &SqsClient {
&self.client
}
}
impl Queue for SqsQueue {
fn name(&self) -> &str {
"sqs"
}
fn push<'a>(&'a self, job: QueuedJob) -> BoxFuture<'a, Result<String>> {
Box::pin(async move {
let delay = job
.available_at
.signed_duration_since(chrono::Utc::now())
.to_std()
.unwrap_or(Duration::ZERO);
self.client.send(&serde_json::to_string(&job)?, delay).await?;
Ok(job.id)
})
}
fn reserve<'a>(&'a self, _queue: &'a str) -> BoxFuture<'a, Result<Option<QueuedJob>>> {
Box::pin(async move {
let Some(message) = self.client.receive(self.visibility, self.wait_time).await? else {
return Ok(None);
};
let mut job: QueuedJob = match serde_json::from_str(&message.body) {
Ok(job) => job,
Err(e) => {
tracing::error!(error = %e, "discarding an unparseable SQS message");
let _ = self.client.delete(&message.receipt_handle).await;
return Ok(None);
}
};
job.payload[RECEIPT_HANDLE] = json!(message.receipt_handle);
job.attempts = message.receive_count;
Ok(Some(job))
})
}
fn acknowledge<'a>(&'a self, job: &'a QueuedJob) -> BoxFuture<'a, Result<()>> {
Box::pin(async move { self.client.delete(receipt_handle(job)?).await })
}
fn release<'a>(&'a self, job: &'a QueuedJob, delay: Duration) -> BoxFuture<'a, Result<()>> {
Box::pin(async move { self.client.change_visibility(receipt_handle(job)?, delay).await })
}
fn fail<'a>(&'a self, job: &'a QueuedJob, error: &'a str) -> BoxFuture<'a, Result<()>> {
Box::pin(async move {
tracing::error!(
job = %job.name,
id = %job.id,
attempts = job.attempts,
%error,
"a job failed after its last attempt; configure a dead-letter queue to keep it"
);
self.client.delete(receipt_handle(job)?).await
})
}
fn size<'a>(&'a self, _queue: &'a str) -> BoxFuture<'a, Result<u64>> {
Box::pin(async move { self.client.approximate_size().await })
}
fn clear<'a>(&'a self, _queue: &'a str) -> BoxFuture<'a, Result<u64>> {
Box::pin(async move {
let before = self.client.approximate_size().await.unwrap_or(0);
self.client.purge().await?;
Ok(before)
})
}
}
impl std::fmt::Debug for SqsQueue {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("SqsQueue")
.field("queue_url", &self.queue_url())
.field("visibility", &self.visibility)
.field("wait_time", &self.wait_time)
.finish()
}
}
fn receipt_handle(job: &QueuedJob) -> Result<&str> {
job.payload.get(RECEIPT_HANDLE).and_then(|value| value.as_str()).ok_or_else(|| {
Error::internal("this job carries no SQS receipt handle, so it was not reserved from SQS")
})
}
#[cfg(test)]
mod tests {
use super::*;
const URL: &str = "https://sqs.eu-west-2.amazonaws.com/123456789012/jobs";
async fn queue() -> SqsQueue {
SqsQueue::new(&AwsConnector::with_credentials("id", "secret", "eu-west-2").await, URL)
}
fn job() -> QueuedJob {
QueuedJob {
id: "job-1".to_string(),
name: "test.job".to_string(),
queue: "default".to_string(),
payload: json!({ "x": 1 }),
attempts: 0,
max_attempts: 3,
available_at: chrono::Utc::now(),
created_at: chrono::Utc::now(),
unique_key: None,
}
}
#[tokio::test]
async fn the_driver_is_named() {
let queue = queue().await;
assert_eq!(queue.name(), "sqs");
assert_eq!(queue.queue_url(), URL);
}
#[tokio::test]
async fn the_timings_are_configurable() {
let queue = queue()
.await
.with_visibility_timeout(Duration::from_secs(600))
.with_wait_time(Duration::from_secs(20));
assert_eq!(queue.visibility, Duration::from_secs(600));
assert_eq!(queue.wait_time, Duration::from_secs(20));
}
#[test]
fn a_job_without_a_receipt_handle_cannot_be_acknowledged() {
let err = receipt_handle(&job()).unwrap_err();
assert!(err.message().contains("not reserved from SQS"), "{}", err.message());
}
#[test]
fn a_reserved_job_carries_its_handle() {
let mut reserved = job();
reserved.payload[RECEIPT_HANDLE] = json!("AQEB-handle");
assert_eq!(receipt_handle(&reserved).unwrap(), "AQEB-handle");
}
#[test]
fn a_job_round_trips_through_its_serialised_body() {
let original = job();
let decoded: QueuedJob =
serde_json::from_str(&serde_json::to_string(&original).unwrap()).unwrap();
assert_eq!(decoded.id, original.id);
assert_eq!(decoded.name, original.name);
assert_eq!(decoded.max_attempts, original.max_attempts);
}
#[tokio::test]
#[ignore = "needs a live SQS queue"]
async fn a_job_round_trips_through_sqs() {
let url = std::env::var("RAINIER_TEST_QUEUE_URL").expect("RAINIER_TEST_QUEUE_URL");
let queue = SqsQueue::new(&AwsConnector::from_env().await, url)
.with_wait_time(Duration::from_secs(5));
assert_eq!(queue.push(job()).await.unwrap(), "job-1");
let reserved = queue.reserve("default").await.unwrap().expect("a message");
assert_eq!(reserved.name, "test.job");
assert!(reserved.attempts >= 1, "SQS counts the delivery");
queue.acknowledge(&reserved).await.unwrap();
}
}