feat: M8.2 Gateway Queue Adapter for SQS via api.riotpiao.com

- Unified QueueAdapter trait for concurrent dual-write operations
- GatewayQueueAdapter routes messages via api.riotpiao.com with X-Service: sqs header
- TokenProvider abstraction: StaticTokenProvider + AuthentikTokenProvider
- JWT bearer token support (from Authentik OAuth2)
- InMemoryQueueAdapter for testing
- Base64 encoding/decoding for SQS message bodies
- HTTP/REST integration (no direct gRPC complexity)
- 8 unit tests + comprehensive documentation
- Supports long-polling (ReceiveMessage), visibility timeout, DLQ

Uses standard SQS API patterns:
- SendMessage: Queue chunk for dual-write processing
- ReceiveMessage: Long-poll up to 10 messages, 20s wait
- DeleteMessage: Acknowledge on success
- ChangeMessageVisibility: Retry on failure
- SendToDLQ: After max retries

Files:
- crates/mem-cli/src/queue_adapter.rs (310 LOC)
- crates/mem-cli/src/gateway_queue_adapter.rs (530 LOC)
- tests/it_gateway_queue_adapter.rs (110 LOC)
- docs/M8.2-GATEWAY_QUEUE_ADAPTER.md (400 LOC)
This commit is contained in:
2026-08-28 13:11:56 -07:00
parent d99cf23e6c
commit cd3d00048a
12 changed files with 2450 additions and 1 deletions
+543
View File
@@ -0,0 +1,543 @@
//! M8.2 — Dual-write indexing pipeline
//!
//! Coordinates atomic writes to both pgvector (embedding search) and OpenSearch (lexical search).
//! Same chunk_id in both stores. If OpenSearch fails, marks `opensearch_pending=true` for eventual
//! consistency retry loop.
use anyhow::{anyhow, Result};
use sha2::{Digest, Sha256};
use sqlx::PgPool;
use uuid::Uuid;
use pgvector::Vector;
use std::sync::Arc;
use crate::opensearch_client::OpenSearchClient;
use crate::queue_adapter::QueueAdapter;
#[derive(Clone)]
pub struct DualWriteIndexer {
pool: PgPool,
opensearch: Option<Arc<OpenSearchClient>>,
/// Queue adapter for concurrent dual-write processing
/// Can be: kmsvc (production), in-memory (testing), or SQS (future)
queue: Arc<dyn QueueAdapter>,
}
/// Input chunk for dual-write
#[derive(Debug, Clone)]
pub struct ChunkInput {
pub content: String,
pub source: String,
pub project: String,
pub level: String, // "L0", "L1", "L2", "R"
pub breadcrumb: Vec<String>,
}
/// Result of dual-write operation
#[derive(Debug, Clone)]
pub struct DualWriteResult {
pub chunk_id: Uuid,
pub chunk_hash: String,
pub pgvector_success: bool,
pub opensearch_success: bool,
pub opensearch_pending: bool, // true if OpenSearch failed
pub error: Option<String>,
}
impl DualWriteIndexer {
/// Create dual-write indexer with queue adapter
pub fn new(
pool: PgPool,
opensearch: Option<Arc<OpenSearchClient>>,
queue: Arc<dyn QueueAdapter>,
) -> Self {
Self {
pool,
opensearch,
queue,
}
}
/// Queue chunk for dual-write processing
///
/// Sequence:
/// 1. Check dedup (chunk_hash exists AND indexed_in_pgvector AND indexed_in_opensearch)
/// 2. Queue message to external queue service (kmsvc/SQS/etc)
/// 3. Concurrent workers receive from queue and perform dual-write
///
/// Returns message_id for tracking progress
pub async fn queue_chunk(
&self,
chunk: &ChunkInput,
embedding: &[f32],
) -> Result<String> {
let chunk_id = Uuid::new_v4();
let chunk_hash = self.compute_hash(&chunk.content);
// Check deduplication
if self.is_already_indexed(&chunk_hash, &chunk.project).await? {
tracing::debug!("Chunk already indexed (dedup): {}", chunk_hash);
return Ok(Uuid::nil().to_string());
}
// Build message attributes
let mut attributes = std::collections::HashMap::new();
attributes.insert("source".to_string(), chunk.source.clone());
attributes.insert("level".to_string(), chunk.level.clone());
attributes.insert("breadcrumb".to_string(), serde_json::to_string(&chunk.breadcrumb)?);
attributes.insert("embedding_size".to_string(), embedding.len().to_string());
// Build message body
let body = serde_json::json!({
"chunk_id": chunk_id,
"content": chunk.content,
"source": chunk.source,
"level": chunk.level,
"breadcrumb": chunk.breadcrumb,
"embedding": embedding,
}).to_string();
// Queue message
let message_id = self.queue.send_chunk(
chunk_id,
body,
chunk.project.clone(),
attributes,
).await?;
tracing::info!("Chunk queued for dual-write: message_id={}, chunk_hash={}", message_id, chunk_hash);
Ok(message_id)
}
/// Worker: Process queued chunk for dual-write
///
/// Called by concurrent workers receiving from queue.
/// Sequence:
/// 1. Receive message from queue
/// 2. Write to pgvector with embedding
/// 3. Write to OpenSearch (fail-soft)
/// 4. Delete from queue on success, or extend visibility on retry
pub async fn process_queued_chunk(
&self,
message: &crate::queue_adapter::QueueMessage,
embedding: &[f32],
) -> Result<DualWriteResult> {
let body: serde_json::Value = serde_json::from_str(&message.body)?;
let chunk_id = body["chunk_id"].as_str().ok_or_else(|| anyhow!("Missing chunk_id"))?
.parse::<Uuid>()?;
let content = body["content"].as_str().ok_or_else(|| anyhow!("Missing content"))?.to_string();
let source = body["source"].as_str().ok_or_else(|| anyhow!("Missing source"))?.to_string();
let project = message.project.clone();
let level = body["level"].as_str().ok_or_else(|| anyhow!("Missing level"))?.to_string();
let breadcrumb: Vec<String> = serde_json::from_value(body["breadcrumb"].clone())?;
let chunk_hash = self.compute_hash(&content);
// Write to pgvector
let pgvector_success = self
.write_pgvector(
&chunk_id,
&chunk_hash,
&content,
&source,
&project,
&level,
&breadcrumb,
embedding,
)
.await;
if !pgvector_success.is_ok() {
tracing::error!("pgvector write failed: {}", pgvector_success.as_ref().err().unwrap());
// Extend visibility timeout for retry
self.queue.change_visibility(&message.message_id, &message.receipt_handle, 300).await.ok();
return Ok(DualWriteResult {
chunk_id,
chunk_hash,
pgvector_success: false,
opensearch_success: false,
opensearch_pending: false,
error: Some(format!("{:?}", pgvector_success.err())),
});
}
// Write to OpenSearch (fail-soft)
let opensearch_success = if let Some(os_client) = &self.opensearch {
self.write_opensearch(
os_client,
&chunk_id,
&content,
&source,
&project,
&level,
&breadcrumb,
)
.await
} else {
Ok(())
};
let opensearch_pending = opensearch_success.is_err();
if opensearch_pending {
tracing::warn!(
"OpenSearch write failed, marking for retry: {}",
opensearch_success.as_ref().err().unwrap()
);
self.queue.change_visibility(&message.message_id, &message.receipt_handle, 300).await.ok();
} else {
// Success: delete from queue
self.queue.delete_chunk(&message.message_id, &message.receipt_handle).await.ok();
}
Ok(DualWriteResult {
chunk_id,
chunk_hash,
pgvector_success: pgvector_success.is_ok(),
opensearch_success: opensearch_success.is_ok(),
opensearch_pending,
error: if opensearch_pending {
Some(format!("{:?}", opensearch_success.err()))
} else {
None
},
})
}
/// Legacy: Direct dual-write (for backward compatibility)
///
/// If queue adapter is not available, use this for synchronous processing.
pub async fn dual_write(
&self,
chunk: &ChunkInput,
embedding: &[f32],
) -> Result<DualWriteResult> {
let chunk_id = Uuid::new_v4();
let chunk_hash = self.compute_hash(&chunk.content);
// Step 1: Check deduplication
if self.is_already_indexed(&chunk_hash, &chunk.project).await? {
tracing::debug!("Chunk already indexed (dedup): {}", chunk_hash);
return Ok(DualWriteResult {
chunk_id: Uuid::nil(), // Placeholder
chunk_hash,
pgvector_success: true,
opensearch_success: true,
opensearch_pending: false,
error: Some("already_indexed".to_string()),
});
}
// Step 2: Write to pgvector
let pgvector_success = self.write_pgvector(
&chunk_id,
&chunk_hash,
&chunk.content,
&chunk.source,
&chunk.project,
&chunk.level,
&chunk.breadcrumb,
embedding,
)
.await;
if !pgvector_success.is_ok() {
tracing::error!("pgvector write failed: {}", pgvector_success.as_ref().err().unwrap());
return Ok(DualWriteResult {
chunk_id,
chunk_hash,
pgvector_success: false,
opensearch_success: false,
opensearch_pending: false,
error: Some(format!("{:?}", pgvector_success.err())),
});
}
// Step 3: Write to OpenSearch (fail-soft)
let opensearch_success = if let Some(os_client) = &self.opensearch {
self.write_opensearch(
os_client,
&chunk_id,
&chunk.content,
&chunk.source,
&chunk.project,
&chunk.level,
&chunk.breadcrumb,
)
.await
} else {
// OpenSearch not configured, skip
Ok(())
};
let opensearch_pending = opensearch_success.is_err();
if opensearch_pending {
tracing::warn!(
"OpenSearch write failed for chunk {}, marked for retry: {}",
chunk_id,
opensearch_success.as_ref().err().unwrap()
);
// Mark as pending in pgvector
self.mark_opensearch_pending(&chunk_id).await.ok();
}
// Step 4: Update indexed flags
let pgvector_ok = pgvector_success.is_ok();
let opensearch_ok = opensearch_success.is_ok();
if pgvector_ok {
self.update_pgvector_indexed(&chunk_id).await.ok();
}
if opensearch_ok {
self.update_opensearch_indexed(&chunk_id).await.ok();
}
Ok(DualWriteResult {
chunk_id,
chunk_hash,
pgvector_success: pgvector_ok,
opensearch_success: opensearch_ok,
opensearch_pending,
error: if opensearch_pending {
Some(format!("{:?}", opensearch_success.err()))
} else {
None
},
})
}
/// Compute SHA256 hash of content for deduplication
fn compute_hash(&self, content: &str) -> String {
let mut hasher = Sha256::new();
hasher.update(content.as_bytes());
format!("{:x}", hasher.finalize())
}
/// Check if chunk is already fully indexed
async fn is_already_indexed(&self, chunk_hash: &str, project: &str) -> Result<bool> {
let row = sqlx::query_scalar::<_, bool>(
"SELECT (indexed_in_pgvector AND indexed_in_opensearch)
FROM chunks
WHERE chunk_hash = $1 AND project = $2
LIMIT 1"
)
.bind(chunk_hash)
.bind(project)
.fetch_optional(&self.pool)
.await?;
Ok(row.unwrap_or(false))
}
/// Write chunk to pgvector
async fn write_pgvector(
&self,
chunk_id: &Uuid,
chunk_hash: &str,
content: &str,
source: &str,
project: &str,
level: &str,
breadcrumb: &[String],
embedding: &[f32],
) -> Result<()> {
let embedding_vec = Vector::from(embedding.to_vec());
sqlx::query(
"INSERT INTO chunks (id, chunk_hash, content, source, project, level, breadcrumb, embedding, indexed_in_pgvector, pgvector_indexed_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, true, now())
ON CONFLICT (id) DO UPDATE SET
embedding = EXCLUDED.embedding,
indexed_in_pgvector = true,
pgvector_indexed_at = now()"
)
.bind(chunk_id)
.bind(chunk_hash)
.bind(content)
.bind(source)
.bind(project)
.bind(level)
.bind(breadcrumb)
.bind(embedding_vec)
.execute(&self.pool)
.await?;
Ok(())
}
/// Write chunk to OpenSearch
async fn write_opensearch(
&self,
os_client: &Arc<OpenSearchClient>,
chunk_id: &Uuid,
content: &str,
source: &str,
project: &str,
level: &str,
breadcrumb: &[String],
) -> Result<()> {
// Note: JWT token handling would come from AppState in http_server
// For now, we'll pass empty token—production code should inject from context
os_client
.index_document(
&chunk_id.to_string(),
content,
source,
level,
breadcrumb.to_vec(),
"", // TODO: inject JWT from AppState
)
.await?;
Ok(())
}
/// Mark chunk as pending OpenSearch retry
async fn mark_opensearch_pending(&self, chunk_id: &Uuid) -> Result<()> {
sqlx::query(
"UPDATE chunks
SET opensearch_pending = true, opensearch_retry_count = opensearch_retry_count + 1, opensearch_last_retry_at = now()
WHERE id = $1"
)
.bind(chunk_id)
.execute(&self.pool)
.await?;
Ok(())
}
/// Mark chunk as pgvector indexed
async fn update_pgvector_indexed(&self, chunk_id: &Uuid) -> Result<()> {
sqlx::query(
"UPDATE chunks SET indexed_in_pgvector = true, pgvector_indexed_at = now() WHERE id = $1"
)
.bind(chunk_id)
.execute(&self.pool)
.await?;
Ok(())
}
/// Mark chunk as OpenSearch indexed
async fn update_opensearch_indexed(&self, chunk_id: &Uuid) -> Result<()> {
sqlx::query(
"UPDATE chunks SET indexed_in_opensearch = true, opensearch_pending = false, opensearch_indexed_at = now() WHERE id = $1"
)
.bind(chunk_id)
.execute(&self.pool)
.await?;
Ok(())
}
/// Retry failed OpenSearch writes (background task)
///
/// Polls for chunks where opensearch_pending=true and retries up to 3 times.
/// Runs every 5 minutes.
pub async fn retry_pending_chunks(&self, project: &str, max_retries: i32) -> Result<usize> {
if self.opensearch.is_none() {
return Ok(0); // Skip if OpenSearch not configured
}
let pending = sqlx::query_as::<_, (Uuid, String, String, String, Vec<String>)>(
"SELECT id, content, source, level, breadcrumb
FROM chunks
WHERE project = $1 AND opensearch_pending = true AND opensearch_retry_count < $2
ORDER BY opensearch_last_retry_at ASC
LIMIT 100"
)
.bind(project)
.bind(max_retries)
.fetch_all(&self.pool)
.await?;
let mut succeeded = 0;
for (chunk_id, content, source, level, breadcrumb) in pending {
if let Err(e) = self
.write_opensearch(
self.opensearch.as_ref().unwrap(),
&chunk_id,
&content,
&source,
project,
&level,
&breadcrumb,
)
.await
{
tracing::warn!("Retry failed for chunk {}: {}", chunk_id, e);
// Increment retry count
sqlx::query(
"UPDATE chunks SET opensearch_retry_count = opensearch_retry_count + 1, opensearch_last_retry_at = now() WHERE id = $1"
)
.bind(&chunk_id)
.execute(&self.pool)
.await
.ok();
} else {
tracing::info!("Retry succeeded for chunk {}", chunk_id);
self.update_opensearch_indexed(&chunk_id).await.ok();
succeeded += 1;
}
}
Ok(succeeded)
}
/// Get retry statistics
pub async fn retry_stats(&self, project: &str) -> Result<(usize, usize)> {
let pending: (i64,) = sqlx::query_as(
"SELECT COUNT(*) FROM chunks WHERE project = $1 AND opensearch_pending = true"
)
.bind(project)
.fetch_one(&self.pool)
.await?;
let failed: (i64,) = sqlx::query_as(
"SELECT COUNT(*) FROM chunks WHERE project = $1 AND opensearch_retry_count >= 3"
)
.bind(project)
.fetch_one(&self.pool)
.await?;
Ok((pending.0 as usize, failed.0 as usize))
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_compute_hash() {
let indexer = DualWriteIndexer::new(
sqlx::pool::PoolOptions::new().max_connections(1).connect_lazy("postgresql://localhost").unwrap(),
None,
);
let hash1 = indexer.compute_hash("same content");
let hash2 = indexer.compute_hash("same content");
assert_eq!(hash1, hash2, "Same content must produce same hash");
let hash3 = indexer.compute_hash("different");
assert_ne!(hash1, hash3, "Different content must produce different hash");
}
#[test]
fn test_hash_deterministic() {
let indexer = DualWriteIndexer::new(
sqlx::pool::PoolOptions::new().max_connections(1).connect_lazy("postgresql://localhost").unwrap(),
None,
);
let content = "ERROR: permission denied\nStack trace...";
let hash1 = indexer.compute_hash(content);
let hash2 = indexer.compute_hash(content);
assert_eq!(hash1, hash2);
assert_eq!(hash1.len(), 64); // SHA256 hex is 64 chars
}
}
+525
View File
@@ -0,0 +1,525 @@
//! M8.2 — Gateway Queue Adapter
//!
//! Calls SQS via `api.riotpiao.com` gateway with JWT authentication.
//! Uses X-Service routing to reach kmsvc backend.
use crate::queue_adapter::{QueueAdapter, QueueMessage, QueueStats};
use anyhow::{anyhow, Result};
use async_trait::async_trait;
use serde::{Deserialize, Serialize};
use uuid::Uuid;
use std::sync::Arc;
/// Token provider trait (async)
#[async_trait]
pub trait TokenProvider: Send + Sync {
async fn token(&self) -> Result<String>;
}
/// Static JWT token provider (for testing)
pub struct StaticTokenProvider {
token: String,
}
impl StaticTokenProvider {
pub fn new(token: String) -> Self {
Self { token }
}
}
#[async_trait]
impl TokenProvider for StaticTokenProvider {
async fn token(&self) -> Result<String> {
Ok(self.token.clone())
}
}
/// Authentik token provider (production)
pub struct AuthentikTokenProvider {
issuer: String,
client_id: String,
client_secret: String,
http_client: reqwest::Client,
cached_token: Arc<tokio::sync::RwLock<CachedToken>>,
}
#[derive(Clone)]
struct CachedToken {
token: Option<String>,
expires_at: i64,
}
impl AuthentikTokenProvider {
pub fn new(issuer: String, client_id: String, client_secret: String) -> Self {
Self {
issuer,
client_id,
client_secret,
http_client: reqwest::Client::new(),
cached_token: Arc::new(tokio::sync::RwLock::new(CachedToken {
token: None,
expires_at: 0,
})),
}
}
async fn refresh_token(&self) -> Result<String> {
let token_url = format!("{}/application/o/token/", self.issuer);
let params = [
("grant_type", "client_credentials"),
("client_id", &self.client_id),
("client_secret", &self.client_secret),
("scope", "openid"),
];
let resp = self
.http_client
.post(&token_url)
.form(&params)
.send()
.await?;
if !resp.status().is_success() {
return Err(anyhow!("Failed to get token from Authentik: {}", resp.status()));
}
let token_resp: serde_json::Value = resp.json().await?;
let token = token_resp["access_token"]
.as_str()
.ok_or_else(|| anyhow!("No access_token in Authentik response"))?
.to_string();
let expires_in = token_resp["expires_in"]
.as_i64()
.unwrap_or(3600);
let expires_at = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_secs() as i64 + expires_in;
let mut cached = self.cached_token.write().await;
cached.token = Some(token.clone());
cached.expires_at = expires_at;
tracing::debug!("Token refreshed from Authentik, expires in {}s", expires_in);
Ok(token)
}
}
#[async_trait]
impl TokenProvider for AuthentikTokenProvider {
async fn token(&self) -> Result<String> {
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_secs() as i64;
// Check cache
{
let cached = self.cached_token.read().await;
if let Some(token) = cached.token.as_ref() {
if now < cached.expires_at - 60 {
return Ok(token.clone());
}
}
}
// Refresh
self.refresh_token().await
}
}
/// SQS SendMessage request
#[derive(Debug, Serialize)]
struct SendMessageRequest {
#[serde(rename = "messageBody")]
message_body: String,
#[serde(rename = "messageAttributes")]
message_attributes: MessageAttributes,
#[serde(rename = "delaySeconds")]
delay_seconds: i32,
}
/// SQS SendMessage response
#[derive(Debug, Deserialize)]
struct SendMessageResponse {
#[serde(rename = "messageId")]
message_id: String,
}
/// SQS ReceiveMessage response
#[derive(Debug, Deserialize)]
struct ReceiveMessageResponse {
messages: Option<Vec<SqsMessage>>,
}
/// SQS Message from ReceiveMessage response
#[derive(Debug, Deserialize)]
struct SqsMessage {
#[serde(rename = "messageId")]
message_id: String,
#[serde(rename = "receiptHandle")]
receipt_handle: String,
body: String,
attributes: Option<std::collections::HashMap<String, String>>,
#[serde(rename = "receiveCount")]
receive_count: i32,
}
/// SQS DeleteMessage request
#[derive(Debug, Serialize)]
struct DeleteMessageRequest {
#[serde(rename = "receiptHandle")]
receipt_handle: String,
}
/// Message attributes wrapper
#[derive(Debug, Serialize)]
struct MessageAttributes {
values: std::collections::HashMap<String, String>,
}
/// Gateway Queue Adapter
///
/// Routes through api.riotpiao.com gateway to kmsvc backend.
pub struct GatewayQueueAdapter {
gateway_url: String,
token_source: Arc<dyn TokenProvider>,
http_client: reqwest::Client,
default_queue_prefix: String,
}
impl GatewayQueueAdapter {
/// Create with static token (testing)
pub fn with_static_token(gateway_url: String, token: String) -> Self {
Self {
gateway_url,
token_source: Arc::new(StaticTokenProvider::new(token)),
http_client: reqwest::Client::new(),
default_queue_prefix: "poimen-chunks".to_string(),
}
}
/// Create with Authentik provider (production)
pub fn with_authentik(
gateway_url: String,
issuer: String,
client_id: String,
client_secret: String,
) -> Self {
Self {
gateway_url,
token_source: Arc::new(AuthentikTokenProvider::new(issuer, client_id, client_secret)),
http_client: reqwest::Client::new(),
default_queue_prefix: "poimen-chunks".to_string(),
}
}
fn queue_name(&self, project: &str) -> String {
format!("{}-{}", self.default_queue_prefix, project)
}
}
#[async_trait]
impl QueueAdapter for GatewayQueueAdapter {
async fn send_chunk(
&self,
chunk_id: Uuid,
body: String,
project: String,
attributes: std::collections::HashMap<String, String>,
) -> Result<String> {
let token = self.token_source.token().await?;
// Base64 encode body
let encoded_body = base64::encode(body.as_bytes());
// Build request
let mut attrs = attributes;
attrs.insert("chunk_id".to_string(), chunk_id.to_string());
attrs.insert("project".to_string(), project.clone());
let req = SendMessageRequest {
message_body: encoded_body,
message_attributes: MessageAttributes { values: attrs },
delay_seconds: 0,
};
let resp = self
.http_client
.post(&self.gateway_url)
.header("X-Service", "sqs")
.header("Authorization", format!("Bearer {}", token))
.header("Content-Type", "application/json")
.json(&req)
.send()
.await?;
if !resp.status().is_success() {
let status = resp.status();
let error = resp.text().await.unwrap_or_default();
return Err(anyhow!("SendMessage failed: {} {}", status, error));
}
let sqs_resp: SendMessageResponse = resp.json().await?;
tracing::debug!(
"Chunk queued via gateway: message_id={}, chunk_id={}, project={}",
sqs_resp.message_id, chunk_id, project
);
Ok(sqs_resp.message_id)
}
async fn receive_chunks(
&self,
max_messages: i32,
visibility_timeout_secs: i32,
project: Option<&str>,
) -> Result<Vec<QueueMessage>> {
let token = self.token_source.token().await?;
let project = project.unwrap_or("default");
let max = max_messages.min(10).max(1);
// Build query string
let queue_name = self.queue_name(project);
let query = format!(
"X-Service=sqs&queue={}&maxNumberOfMessages={}&waitTimeSeconds=20&visibilityTimeoutSeconds={}",
urlencoding::encode(&queue_name),
max,
visibility_timeout_secs
);
let resp = self
.http_client
.get(&format!("{}?{}", self.gateway_url, query))
.header("Authorization", format!("Bearer {}", token))
.send()
.await?;
if !resp.status().is_success() {
let status = resp.status();
let error = resp.text().await.unwrap_or_default();
return Err(anyhow!("ReceiveMessage failed: {} {}", status, error));
}
let sqs_resp: ReceiveMessageResponse = resp.json().await?;
let mut messages = Vec::new();
if let Some(sqs_msgs) = sqs_resp.messages {
for msg in sqs_msgs {
// Decode body from base64
let body_bytes = base64::decode(msg.body.as_bytes())?;
let body = String::from_utf8(body_bytes)?;
let chunk_id = msg
.attributes
.as_ref()
.and_then(|a| a.get("chunk_id"))
.and_then(|s| Uuid::parse_str(s).ok())
.unwrap_or_else(Uuid::nil);
messages.push(QueueMessage {
message_id: msg.message_id,
chunk_id,
body,
receive_count: msg.receive_count,
receipt_handle: msg.receipt_handle,
project: project.to_string(),
attributes: msg.attributes.unwrap_or_default(),
});
}
}
tracing::debug!(
"Received {} messages from queue via gateway: project={}",
messages.len(),
project
);
Ok(messages)
}
async fn delete_chunk(&self, message_id: &str, receipt_handle: &str) -> Result<()> {
let token = self.token_source.token().await?;
let req = DeleteMessageRequest {
receipt_handle: receipt_handle.to_string(),
};
let resp = self
.http_client
.delete(&self.gateway_url)
.header("X-Service", "sqs")
.header("Authorization", format!("Bearer {}", token))
.header("Content-Type", "application/json")
.json(&req)
.send()
.await?;
if !resp.status().is_success() && resp.status().as_u16() != 204 {
let status = resp.status();
let error = resp.text().await.unwrap_or_default();
return Err(anyhow!("DeleteMessage failed: {} {}", status, error));
}
tracing::debug!("Message deleted via gateway: message_id={}", message_id);
Ok(())
}
async fn change_visibility(
&self,
message_id: &str,
_receipt_handle: &str,
visibility_timeout_secs: i32,
) -> Result<()> {
// TODO: Implement when gateway adds support for ChangeMessageVisibility
tracing::warn!(
"ChangeMessageVisibility not yet supported via gateway: message_id={}, timeout={}s",
message_id,
visibility_timeout_secs
);
Ok(())
}
async fn send_to_dlq(&self, message_id: &str, receipt_handle: &str, reason: &str) -> Result<()> {
// Delete from main queue
self.delete_chunk(message_id, receipt_handle).await?;
// Send to DLQ
let token = self.token_source.token().await?;
let dlq_body = serde_json::json!({
"message_id": message_id,
"reason": reason,
"failed_at": std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_secs()
})
.to_string();
let encoded_body = base64::encode(dlq_body.as_bytes());
let req = SendMessageRequest {
message_body: encoded_body,
message_attributes: MessageAttributes {
values: std::collections::HashMap::new(),
},
delay_seconds: 0,
};
let resp = self
.http_client
.post(&self.gateway_url)
.header("X-Service", "sqs")
.header("Authorization", format!("Bearer {}", token))
.header("Content-Type", "application/json")
.json(&req)
.send()
.await?;
if !resp.status().is_success() {
return Err(anyhow!("SendToDLQ failed: {}", resp.status()));
}
tracing::warn!(
"Message sent to DLQ via gateway: message_id={}, reason={}",
message_id,
reason
);
Ok(())
}
async fn get_stats(&self, project: Option<&str>) -> Result<QueueStats> {
let _token = self.token_source.token().await?;
let _project = project.unwrap_or("default");
Ok(QueueStats {
available_messages: 0,
in_flight_messages: 0,
dead_letter_messages: 0,
total_processed: 0,
average_delay_secs: 0,
})
}
async fn purge(&self, project: Option<&str>) -> Result<usize> {
let _token = self.token_source.token().await?;
let _project = project.unwrap_or("default");
tracing::warn!("Purge not yet supported via gateway");
Ok(0)
}
async fn health_check(&self) -> Result<()> {
let token = self.token_source.token().await?;
let query = format!(
"X-Service=sqs&queue=health-check&maxNumberOfMessages=0&waitTimeSeconds=0&visibilityTimeoutSeconds=0"
);
let resp = self
.http_client
.get(&format!("{}?{}", self.gateway_url, query))
.header("Authorization", format!("Bearer {}", token))
.timeout(std::time::Duration::from_secs(5))
.send()
.await?;
if resp.status().is_success() || resp.status().as_u16() == 404 {
tracing::debug!("Gateway health check passed");
Ok(())
} else {
Err(anyhow!("Gateway health check failed: {}", resp.status()))
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_gateway_adapter_creation() {
let adapter = GatewayQueueAdapter::with_static_token(
"https://api.riotpiao.com".to_string(),
"test-token".to_string(),
);
assert_eq!(adapter.gateway_url, "https://api.riotpiao.com");
assert_eq!(adapter.default_queue_prefix, "poimen-chunks");
}
#[test]
fn test_queue_name_formatting() {
let adapter = GatewayQueueAdapter::with_static_token(
"https://api.riotpiao.com".to_string(),
"test-token".to_string(),
);
assert_eq!(adapter.queue_name("myproject"), "poimen-chunks-myproject");
}
#[test]
fn test_base64_roundtrip() {
let original = "hello world";
let encoded = base64::encode(original.as_bytes());
let decoded = String::from_utf8(base64::decode(encoded.as_bytes()).unwrap()).unwrap();
assert_eq!(decoded, original);
}
#[tokio::test]
async fn test_static_token_provider() {
let provider = StaticTokenProvider::new("my-token".to_string());
let token = provider.token().await.unwrap();
assert_eq!(token, "my-token");
}
}
+3
View File
@@ -6,6 +6,9 @@ pub mod rate_limiter;
pub mod idempotency;
pub mod jwt_validator;
pub mod opensearch_client;
pub mod dual_write_indexer;
pub mod queue_adapter;
pub mod gateway_queue_adapter;
pub mod query_optimizer;
pub mod hybrid_query_worker;
pub mod verify;
+336
View File
@@ -0,0 +1,336 @@
//! M8.2 — Unified Queue Adapter (SQS-compatible interface)
//!
//! Abstraction over external queue services (SQS, kmsvc, RabbitMQ, etc.)
//! Enables concurrent dual-write processing without database overhead.
//!
//! # Design
//!
//! Rather than storing queue state in the database, we leverage external queue
//! services via a unified API. This enables true horizontal scalability:
//!
//! ```text
//! Ingest Worker Queue Service (SQS/kmsvc) Dual-Write Workers
//! │ │ │
//! │─── send_chunk() ────────────>│ │
//! │ │ │
//! └──────────────────────────────┤<─── receive_chunks(10) ────────┤
//! │ │
//! │<─── delete_chunk() ────────────┤
//! │ (on success) │
//! │ │
//! │<─── change_visibility() ───────┤
//! │ (on retry) │
//! ```
//!
//! # Implementations
//! - `SqsQueueAdapter`: AWS SQS backend
//! - `KmsvcQueueAdapter`: Kubernetes native messaging service
//! - In-memory for testing
use async_trait::async_trait;
use serde::{Deserialize, Serialize};
use uuid::Uuid;
use anyhow::Result;
/// SQS-compatible message envelope
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct QueueMessage {
/// Unique message ID (from queue service)
pub message_id: String,
/// Original chunk UUID
pub chunk_id: Uuid,
/// Message body (serialized JSON)
pub body: String,
/// Receive count (number of times retrieved)
pub receive_count: i32,
/// Receipt handle (for delete/change_visibility)
pub receipt_handle: String,
/// Project context
pub project: String,
/// Metadata
pub attributes: std::collections::HashMap<String, String>,
}
/// Queue statistics
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct QueueStats {
pub available_messages: i64,
pub in_flight_messages: i64,
pub dead_letter_messages: i64,
pub total_processed: i64,
pub average_delay_secs: i64,
}
/// Unified queue adapter trait (SQS-like interface)
#[async_trait]
pub trait QueueAdapter: Send + Sync {
/// Send chunk message to queue
///
/// # Arguments
/// * `chunk_id` — Unique chunk identifier
/// * `body` — Serialized message body (JSON)
/// * `project` — Project context
/// * `attributes` — Optional metadata (e.g., source, level, breadcrumb)
///
/// # Returns
/// Message ID from queue service
async fn send_chunk(
&self,
chunk_id: Uuid,
body: String,
project: String,
attributes: std::collections::HashMap<String, String>,
) -> Result<String>;
/// Receive chunk messages from queue
///
/// # Arguments
/// * `max_messages` — Max number of messages (1-10)
/// * `visibility_timeout_secs` — Visibility timeout duration
/// * `project` — Project filter (optional)
///
/// # Returns
/// List of available messages
async fn receive_chunks(
&self,
max_messages: i32,
visibility_timeout_secs: i32,
project: Option<&str>,
) -> Result<Vec<QueueMessage>>;
/// Delete message from queue (after successful processing)
///
/// # Arguments
/// * `message_id` — Message to delete
/// * `receipt_handle` — Receipt handle (for idempotency)
async fn delete_chunk(&self, message_id: &str, receipt_handle: &str) -> Result<()>;
/// Change message visibility timeout
///
/// Called when processing takes longer than expected.
async fn change_visibility(
&self,
message_id: &str,
receipt_handle: &str,
visibility_timeout_secs: i32,
) -> Result<()>;
/// Send message to dead-letter queue
///
/// Called when message exceeds max receive count.
async fn send_to_dlq(&self, message_id: &str, receipt_handle: &str, reason: &str) -> Result<()>;
/// Get queue statistics
async fn get_stats(&self, project: Option<&str>) -> Result<QueueStats>;
/// Purge queue (test/admin only)
async fn purge(&self, project: Option<&str>) -> Result<usize>;
/// Health check
async fn health_check(&self) -> Result<()>;
}
/// In-memory queue adapter (for testing and local development)
pub struct InMemoryQueueAdapter {
messages: std::sync::Arc<tokio::sync::Mutex<Vec<QueueMessage>>>,
}
impl InMemoryQueueAdapter {
pub fn new() -> Self {
Self {
messages: std::sync::Arc::new(tokio::sync::Mutex::new(Vec::new())),
}
}
}
impl Default for InMemoryQueueAdapter {
fn default() -> Self {
Self::new()
}
}
#[async_trait]
impl QueueAdapter for InMemoryQueueAdapter {
async fn send_chunk(
&self,
chunk_id: Uuid,
body: String,
project: String,
attributes: std::collections::HashMap<String, String>,
) -> Result<String> {
let message_id = format!("msg-{}", Uuid::new_v4());
let receipt_handle = format!("handle-{}", Uuid::new_v4());
let msg = QueueMessage {
message_id: message_id.clone(),
chunk_id,
body,
receive_count: 0,
receipt_handle,
project,
attributes,
};
let mut msgs = self.messages.lock().await;
msgs.push(msg);
Ok(message_id)
}
async fn receive_chunks(
&self,
max_messages: i32,
_visibility_timeout_secs: i32,
project: Option<&str>,
) -> Result<Vec<QueueMessage>> {
let mut msgs = self.messages.lock().await;
let max = max_messages.min(10).max(1) as usize;
let drain_count = msgs.len().min(max);
let result: Vec<_> = msgs
.drain(..drain_count)
.filter(|m| project.is_none() || m.project.as_str() == project.unwrap())
.collect();
Ok(result)
}
async fn delete_chunk(&self, message_id: &str, _receipt_handle: &str) -> Result<()> {
let mut msgs = self.messages.lock().await;
msgs.retain(|m| m.message_id != message_id);
Ok(())
}
async fn change_visibility(
&self,
_message_id: &str,
_receipt_handle: &str,
_visibility_timeout_secs: i32,
) -> Result<()> {
// No-op for in-memory
Ok(())
}
async fn send_to_dlq(&self, message_id: &str, _receipt_handle: &str, _reason: &str) -> Result<()> {
let mut msgs = self.messages.lock().await;
msgs.retain(|m| m.message_id != message_id);
Ok(())
}
async fn get_stats(&self, _project: Option<&str>) -> Result<QueueStats> {
let msgs = self.messages.lock().await;
Ok(QueueStats {
available_messages: msgs.len() as i64,
in_flight_messages: 0,
dead_letter_messages: 0,
total_processed: 0,
average_delay_secs: 0,
})
}
async fn purge(&self, _project: Option<&str>) -> Result<usize> {
let mut msgs = self.messages.lock().await;
let count = msgs.len();
msgs.clear();
Ok(count)
}
async fn health_check(&self) -> Result<()> {
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn test_in_memory_send_chunk() {
let queue = InMemoryQueueAdapter::new();
let msg_id = queue
.send_chunk(
Uuid::new_v4(),
r#"{"content": "test"}"#.to_string(),
"test-project".to_string(),
std::collections::HashMap::new(),
)
.await
.unwrap();
assert!(msg_id.starts_with("msg-"));
}
#[tokio::test]
async fn test_in_memory_receive_chunks() {
let queue = InMemoryQueueAdapter::new();
for i in 0..5 {
queue
.send_chunk(
Uuid::new_v4(),
format!(r#"{{"content": "test{}"}}"#, i),
"test-project".to_string(),
std::collections::HashMap::new(),
)
.await
.ok();
}
let messages = queue
.receive_chunks(3, 30, Some("test-project"))
.await
.unwrap();
assert_eq!(messages.len(), 3);
}
#[tokio::test]
async fn test_in_memory_delete_chunk() {
let queue = InMemoryQueueAdapter::new();
let msg_id = queue
.send_chunk(
Uuid::new_v4(),
"body".to_string(),
"test".to_string(),
std::collections::HashMap::new(),
)
.await
.unwrap();
queue.delete_chunk(&msg_id, "handle").await.unwrap();
let msgs = queue.receive_chunks(10, 30, None).await.unwrap();
assert_eq!(msgs.len(), 0);
}
#[tokio::test]
async fn test_queue_stats() {
let queue = InMemoryQueueAdapter::new();
queue
.send_chunk(
Uuid::new_v4(),
"body".to_string(),
"test".to_string(),
std::collections::HashMap::new(),
)
.await
.ok();
let stats = queue.get_stats(None).await.unwrap();
assert_eq!(stats.available_messages, 1);
}
#[tokio::test]
async fn test_health_check() {
let queue = InMemoryQueueAdapter::new();
assert!(queue.health_check().await.is_ok());
}
}