feat(wfe-valkey): add Valkey provider for locks, queues, and lifecycle events
ValkeyLockProvider: SET NX EX for acquisition, Lua script for safe release. ValkeyQueueProvider: LPUSH/RPOP for FIFO queues. ValkeyLifecyclePublisher: PUBLISH to per-instance and global channels. Connections obtained once during construction (no per-operation TCP handshakes).
This commit is contained in:
7
wfe-valkey/src/lib.rs
Normal file
7
wfe-valkey/src/lib.rs
Normal file
@@ -0,0 +1,7 @@
|
||||
pub mod lifecycle;
|
||||
pub mod lock;
|
||||
pub mod queue;
|
||||
|
||||
pub use lifecycle::ValkeyLifecyclePublisher;
|
||||
pub use lock::ValkeyLockProvider;
|
||||
pub use queue::ValkeyQueueProvider;
|
||||
55
wfe-valkey/src/lifecycle.rs
Normal file
55
wfe-valkey/src/lifecycle.rs
Normal file
@@ -0,0 +1,55 @@
|
||||
use async_trait::async_trait;
|
||||
use wfe_core::models::LifecycleEvent;
|
||||
use wfe_core::traits::LifecyclePublisher;
|
||||
|
||||
pub struct ValkeyLifecyclePublisher {
|
||||
conn: redis::aio::MultiplexedConnection,
|
||||
prefix: String,
|
||||
}
|
||||
|
||||
impl ValkeyLifecyclePublisher {
|
||||
pub async fn new(redis_url: &str, prefix: &str) -> wfe_core::Result<Self> {
|
||||
let client = redis::Client::open(redis_url)
|
||||
.map_err(|e| wfe_core::WfeError::Persistence(e.to_string()))?;
|
||||
let conn = client
|
||||
.get_multiplexed_tokio_connection()
|
||||
.await
|
||||
.map_err(|e| wfe_core::WfeError::Persistence(e.to_string()))?;
|
||||
Ok(Self {
|
||||
conn,
|
||||
prefix: prefix.to_string(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl LifecyclePublisher for ValkeyLifecyclePublisher {
|
||||
async fn publish(&self, event: LifecycleEvent) -> wfe_core::Result<()> {
|
||||
let mut conn = self.conn.clone();
|
||||
let json = serde_json::to_string(&event)?;
|
||||
|
||||
let instance_channel = format!(
|
||||
"{}:lifecycle:{}",
|
||||
self.prefix, event.workflow_instance_id
|
||||
);
|
||||
let all_channel = format!("{}:lifecycle:all", self.prefix);
|
||||
|
||||
// Publish to the instance-specific channel.
|
||||
redis::cmd("PUBLISH")
|
||||
.arg(&instance_channel)
|
||||
.arg(&json)
|
||||
.query_async::<i64>(&mut conn)
|
||||
.await
|
||||
.map_err(|e| wfe_core::WfeError::Persistence(e.to_string()))?;
|
||||
|
||||
// Publish to the global "all" channel.
|
||||
redis::cmd("PUBLISH")
|
||||
.arg(&all_channel)
|
||||
.arg(&json)
|
||||
.query_async::<i64>(&mut conn)
|
||||
.await
|
||||
.map_err(|e| wfe_core::WfeError::Persistence(e.to_string()))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
98
wfe-valkey/src/lock.rs
Normal file
98
wfe-valkey/src/lock.rs
Normal file
@@ -0,0 +1,98 @@
|
||||
use std::time::Duration;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use uuid::Uuid;
|
||||
use wfe_core::traits::DistributedLockProvider;
|
||||
|
||||
pub struct ValkeyLockProvider {
|
||||
conn: redis::aio::MultiplexedConnection,
|
||||
prefix: String,
|
||||
lock_duration: Duration,
|
||||
/// Unique identifier for this provider instance, used as the lock value.
|
||||
instance_id: String,
|
||||
}
|
||||
|
||||
impl ValkeyLockProvider {
|
||||
pub async fn new(redis_url: &str, prefix: &str) -> wfe_core::Result<Self> {
|
||||
let client = redis::Client::open(redis_url)
|
||||
.map_err(|e| wfe_core::WfeError::Persistence(e.to_string()))?;
|
||||
let conn = client
|
||||
.get_multiplexed_tokio_connection()
|
||||
.await
|
||||
.map_err(|e| wfe_core::WfeError::Persistence(e.to_string()))?;
|
||||
Ok(Self {
|
||||
conn,
|
||||
prefix: prefix.to_string(),
|
||||
lock_duration: Duration::from_secs(30),
|
||||
instance_id: Uuid::new_v4().to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
/// Create a provider with a custom lock duration.
|
||||
pub fn with_lock_duration(mut self, duration: Duration) -> Self {
|
||||
self.lock_duration = duration;
|
||||
self
|
||||
}
|
||||
|
||||
fn lock_key(&self, resource: &str) -> String {
|
||||
format!("{}:lock:{}", self.prefix, resource)
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl DistributedLockProvider for ValkeyLockProvider {
|
||||
async fn acquire_lock(&self, resource: &str) -> wfe_core::Result<bool> {
|
||||
let mut conn = self.conn.clone();
|
||||
let key = self.lock_key(resource);
|
||||
let seconds = self.lock_duration.as_secs();
|
||||
|
||||
// SET key value NX EX seconds
|
||||
let result: Option<String> = redis::cmd("SET")
|
||||
.arg(&key)
|
||||
.arg(&self.instance_id)
|
||||
.arg("NX")
|
||||
.arg("EX")
|
||||
.arg(seconds)
|
||||
.query_async(&mut conn)
|
||||
.await
|
||||
.map_err(|e| wfe_core::WfeError::Persistence(e.to_string()))?;
|
||||
|
||||
Ok(result.is_some())
|
||||
}
|
||||
|
||||
async fn release_lock(&self, resource: &str) -> wfe_core::Result<()> {
|
||||
let mut conn = self.conn.clone();
|
||||
let key = self.lock_key(resource);
|
||||
|
||||
// Use a Lua script to only delete the key if the value matches our instance_id.
|
||||
// This prevents accidentally releasing a lock held by another instance.
|
||||
let script = redis::Script::new(
|
||||
r#"
|
||||
if redis.call("GET", KEYS[1]) == ARGV[1] then
|
||||
return redis.call("DEL", KEYS[1])
|
||||
else
|
||||
return 0
|
||||
end
|
||||
"#,
|
||||
);
|
||||
|
||||
let _: i64 = script
|
||||
.key(&key)
|
||||
.arg(&self.instance_id)
|
||||
.invoke_async(&mut conn)
|
||||
.await
|
||||
.map_err(|e| wfe_core::WfeError::Persistence(e.to_string()))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn start(&self) -> wfe_core::Result<()> {
|
||||
// No-op: Redis/Valkey is always-on.
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn stop(&self) -> wfe_core::Result<()> {
|
||||
// No-op.
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
73
wfe-valkey/src/queue.rs
Normal file
73
wfe-valkey/src/queue.rs
Normal file
@@ -0,0 +1,73 @@
|
||||
use async_trait::async_trait;
|
||||
use redis::AsyncCommands;
|
||||
use wfe_core::models::QueueType;
|
||||
use wfe_core::traits::QueueProvider;
|
||||
|
||||
pub struct ValkeyQueueProvider {
|
||||
conn: redis::aio::MultiplexedConnection,
|
||||
prefix: String,
|
||||
}
|
||||
|
||||
impl ValkeyQueueProvider {
|
||||
pub async fn new(redis_url: &str, prefix: &str) -> wfe_core::Result<Self> {
|
||||
let client = redis::Client::open(redis_url)
|
||||
.map_err(|e| wfe_core::WfeError::Persistence(e.to_string()))?;
|
||||
let conn = client
|
||||
.get_multiplexed_tokio_connection()
|
||||
.await
|
||||
.map_err(|e| wfe_core::WfeError::Persistence(e.to_string()))?;
|
||||
Ok(Self {
|
||||
conn,
|
||||
prefix: prefix.to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
fn queue_key(&self, queue_type: QueueType) -> String {
|
||||
let type_str = match queue_type {
|
||||
QueueType::Workflow => "workflow",
|
||||
QueueType::Event => "event",
|
||||
QueueType::Index => "index",
|
||||
};
|
||||
format!("{}:queue:{}", self.prefix, type_str)
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl QueueProvider for ValkeyQueueProvider {
|
||||
async fn queue_work(&self, id: &str, queue: QueueType) -> wfe_core::Result<()> {
|
||||
let mut conn = self.conn.clone();
|
||||
let key = self.queue_key(queue);
|
||||
|
||||
conn.lpush::<_, _, ()>(&key, id)
|
||||
.await
|
||||
.map_err(|e| wfe_core::WfeError::Persistence(e.to_string()))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn dequeue_work(&self, queue: QueueType) -> wfe_core::Result<Option<String>> {
|
||||
let mut conn = self.conn.clone();
|
||||
let key = self.queue_key(queue);
|
||||
|
||||
let result: Option<String> = conn
|
||||
.rpop(&key, None)
|
||||
.await
|
||||
.map_err(|e| wfe_core::WfeError::Persistence(e.to_string()))?;
|
||||
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
fn is_dequeue_blocking(&self) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
async fn start(&self) -> wfe_core::Result<()> {
|
||||
// No-op.
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn stop(&self) -> wfe_core::Result<()> {
|
||||
// No-op.
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user