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:
2026-03-25 20:14:07 +00:00
parent b2c37701b1
commit f95bef3883
8 changed files with 388 additions and 0 deletions

7
wfe-valkey/src/lib.rs Normal file
View 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;

View 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
View 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
View 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(())
}
}