Skip to content

Commit eae5f5c

Browse files
committed
feat: Implemented job queue with Redis
1 parent 00791c8 commit eae5f5c

14 files changed

Lines changed: 349 additions & 5 deletions

File tree

Cargo.lock

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ tokio-test = "0.4.4"
2121
mockall = "0.13.1"
2222
redis = { version = "0.32.5", features = ["tokio-comp", "connection-manager"] }
2323
actix-http = "3.10.0"
24+
uuid = { version = "1.0", features = ["v4"] }
2425

2526
[dev-dependencies]
2627
husky = "0.3.0"
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
query BulkValidationWithQueue {
2+
validateEmailsBulk(
3+
emails: [
4+
"test1@example.com",
5+
"test2@example.com",
6+
"test3@example.com",
7+
"test4@example.com",
8+
"test5@example.com",
9+
"test6@example.com",
10+
"test7@example.com",
11+
"test8@example.com",
12+
"test9@example.com",
13+
"test10@example.com",
14+
"test11@example.com"
15+
]
16+
useQueue: true
17+
) {
18+
results {
19+
email
20+
validation {
21+
isValid
22+
status
23+
error {
24+
code
25+
message
26+
}
27+
}
28+
}
29+
validCount
30+
invalidCount
31+
}
32+
}
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
query JobStatus {
2+
getJobStatus(jobId: "replace-with-actual-job-id")
3+
}
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
### Check job status (replace {job_id} with actual job ID from queue response)
2+
GET http://localhost:8080/api/v1/job-status/{job_id}
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
### Test bulk validation with job queue (>10 emails)
2+
POST http://localhost:8080/api/v1/validate-emails-bulk
3+
Content-Type: application/json
4+
5+
{
6+
"emails": [
7+
"test1@example.com",
8+
"test2@example.com",
9+
"test3@example.com",
10+
"test4@example.com",
11+
"test5@example.com",
12+
"test6@example.com",
13+
"test7@example.com",
14+
"test8@example.com",
15+
"test9@example.com",
16+
"test10@example.com",
17+
"test11@example.com"
18+
]
19+
}

readme.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -172,8 +172,8 @@ MIT License.
172172
173173
8. **Bulk Processing**
174174
- Add async bulk validation REST endpoint (`POST /bulk/validate`). ✅
175-
- Implement job queue (Redis or MongoDB).
176-
- **DoD**: Processes 10K emails in <5 mins, returns job status.
175+
- Implement job queue (Redis). ✅
176+
- **DoD**: Processes 10K emails in <5 mins, returns job status.
177177
178178
---
179179

src/graphql/email.rs

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
use crate::handlers::validation::{disposable, dnsmx, role_based, syntax};
2+
use crate::job_queue::JobQueue;
23
use async_graphql::{Context, Object, Result, SimpleObject};
34
use futures::future::join_all;
45
use redis::{Client, Commands, RedisError};
@@ -165,7 +166,37 @@ impl EmailQuery {
165166
&self,
166167
ctx: &Context<'_>,
167168
emails: Vec<String>,
169+
use_queue: Option<bool>,
168170
) -> Result<BulkEmailValidationResponse> {
171+
// Use job queue for large batches if available and requested
172+
if use_queue.unwrap_or(false)
173+
&& emails.len() > 10
174+
&& let Some(job_queue) = ctx.data_opt::<JobQueue>()
175+
{
176+
match job_queue
177+
.enqueue_bulk_validation(emails.clone(), false)
178+
.await
179+
{
180+
Ok(job_id) => {
181+
return Ok(BulkEmailValidationResponse {
182+
results: vec![BulkEmailValidationResult {
183+
email: "queued".to_string(),
184+
validation: EmailValidationResponse {
185+
is_valid: false,
186+
status: Some(format!("QUEUED:{}", job_id)),
187+
error: None,
188+
},
189+
}],
190+
valid_count: 0,
191+
invalid_count: 0,
192+
});
193+
}
194+
Err(_) => {
195+
// Fallback to immediate processing
196+
}
197+
}
198+
}
199+
169200
let validation_futures = emails
170201
.iter()
171202
.map(|email| {
@@ -216,6 +247,18 @@ impl EmailQuery {
216247
invalid_count,
217248
})
218249
}
250+
251+
async fn get_job_status(&self, ctx: &Context<'_>, job_id: String) -> Result<String> {
252+
if let Some(job_queue) = ctx.data_opt::<JobQueue>() {
253+
match job_queue.get_job_status(&job_id).await {
254+
Ok(Some(job)) => Ok(format!("{:?}", job.status)),
255+
Ok(None) => Err(async_graphql::Error::new("Job not found")),
256+
Err(e) => Err(async_graphql::Error::new(format!("Redis error: {:?}", e))),
257+
}
258+
} else {
259+
Err(async_graphql::Error::new("Job queue not available"))
260+
}
261+
}
219262
}
220263

221264
// Move the validation logic to a separate method outside the Object impl
@@ -780,6 +823,7 @@ mod tests {
780823
&self,
781824
ctx: &Context<'_>,
782825
emails: Vec<String>,
826+
_use_queue: Option<bool>,
783827
) -> Result<BulkEmailValidationResponse> {
784828
// Create a vector of futures for validating each email
785829
let validation_futures = emails

src/handlers/mod.rs

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,6 @@ pub mod validation;
2626

2727
#[cfg(test)]
2828
mod tests {
29-
use super::*;
3029

3130
#[test]
3231
fn test_health_module_exists() {

src/job_queue.rs

Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
use redis::{AsyncCommands, Client};
2+
use serde::{Deserialize, Serialize};
3+
use std::sync::Arc;
4+
use tokio::time::{Duration, sleep};
5+
use uuid::Uuid;
6+
7+
#[derive(Debug, Clone, Serialize, Deserialize)]
8+
pub struct BulkValidationJob {
9+
pub id: String,
10+
pub emails: Vec<String>,
11+
pub check_role_based: bool,
12+
pub status: JobStatus,
13+
pub created_at: i64,
14+
}
15+
16+
#[derive(Debug, Clone, Serialize, Deserialize)]
17+
pub enum JobStatus {
18+
Pending,
19+
Processing,
20+
Completed,
21+
Failed,
22+
}
23+
24+
#[derive(Clone)]
25+
pub struct JobQueue {
26+
redis: Arc<Client>,
27+
}
28+
29+
impl JobQueue {
30+
pub fn new(redis_url: &str) -> Result<Self, redis::RedisError> {
31+
let client = Client::open(redis_url)?;
32+
Ok(Self {
33+
redis: Arc::new(client),
34+
})
35+
}
36+
37+
pub async fn enqueue_bulk_validation(
38+
&self,
39+
emails: Vec<String>,
40+
check_role_based: bool,
41+
) -> Result<String, redis::RedisError> {
42+
let job_id = Uuid::new_v4().to_string();
43+
let job = BulkValidationJob {
44+
id: job_id.clone(),
45+
emails,
46+
check_role_based,
47+
status: JobStatus::Pending,
48+
created_at: chrono::Utc::now().timestamp(),
49+
};
50+
51+
let mut conn = self.redis.get_multiplexed_async_connection().await?;
52+
let job_json = serde_json::to_string(&job).unwrap();
53+
54+
let _: () = conn.lpush("bulk_validation_queue", &job_json).await?;
55+
let _: () = conn.set(format!("job:{}", job_id), &job_json).await?;
56+
let _: () = conn.expire(format!("job:{}", job_id), 3600).await?; // 1 hour TTL
57+
58+
Ok(job_id)
59+
}
60+
61+
pub async fn get_job_status(
62+
&self,
63+
job_id: &str,
64+
) -> Result<Option<BulkValidationJob>, redis::RedisError> {
65+
let mut conn = self.redis.get_multiplexed_async_connection().await?;
66+
let job_json: Option<String> = conn.get(format!("job:{}", job_id)).await?;
67+
68+
Ok(job_json.and_then(|json| serde_json::from_str(&json).ok()))
69+
}
70+
71+
pub async fn update_job_status(
72+
&self,
73+
job_id: &str,
74+
status: JobStatus,
75+
) -> Result<(), redis::RedisError> {
76+
let mut conn = self.redis.get_multiplexed_async_connection().await?;
77+
78+
if let Some(mut job) = self.get_job_status(job_id).await? {
79+
job.status = status;
80+
let job_json = serde_json::to_string(&job).unwrap();
81+
let _: () = conn.set(format!("job:{}", job_id), &job_json).await?;
82+
}
83+
84+
Ok(())
85+
}
86+
87+
pub async fn process_jobs<F, Fut>(&self, processor: F)
88+
where
89+
F: Fn(BulkValidationJob) -> Fut + Send + Sync + 'static,
90+
Fut: std::future::Future<Output = ()> + Send + 'static,
91+
{
92+
loop {
93+
match self.get_next_job().await {
94+
Ok(Some(job)) => {
95+
let _ = self.update_job_status(&job.id, JobStatus::Processing).await;
96+
processor(job).await;
97+
}
98+
Ok(None) => {
99+
sleep(Duration::from_secs(1)).await;
100+
}
101+
Err(_) => {
102+
sleep(Duration::from_secs(5)).await;
103+
}
104+
}
105+
}
106+
}
107+
108+
async fn get_next_job(&self) -> Result<Option<BulkValidationJob>, redis::RedisError> {
109+
let mut conn = self.redis.get_multiplexed_async_connection().await?;
110+
let result: Option<(String, String)> = conn.brpop("bulk_validation_queue", 1.0).await?;
111+
let job_json = result.map(|(_, value)| value);
112+
113+
Ok(job_json.and_then(|json| serde_json::from_str(&json).ok()))
114+
}
115+
}

0 commit comments

Comments
 (0)