using StackExchange.Redis;
namespace w4c_workflows.Services;
/// Distributed lease over Redis for in-flight run state.
public interface ILeaseService
{
Task AcquireAsync(string tenantId, string runId, string owner, TimeSpan ttl);
Task RenewAsync(string tenantId, string runId, string owner, TimeSpan ttl);
Task ReleaseAsync(string tenantId, string runId, string owner);
}
///
/// Lease / distributed lock over Redis for in-flight run state:
/// wf:{tenant}:leases:{runId} → owner id, with a TTL.
/// Acquire is atomic (SET NX PX); release compares the owner before deleting so
/// a stale owner can never release someone else's lease.
///
public class LeaseService : ILeaseService
{
// Compare-and-delete: only delete if the value still matches the owner.
private const string ReleaseScript =
"if redis.call('GET', KEYS[1]) == ARGV[1] then return redis.call('DEL', KEYS[1]) else return 0 end";
private readonly IDatabase _db;
public LeaseService(IConnectionMultiplexer redis)
{
_db = redis.GetDatabase();
}
private static RedisKey KeyFor(string tenantId, string runId) => $"wf:{tenantId}:leases:{runId}";
/// Atomically acquires the lease; returns false if already held.
public Task AcquireAsync(string tenantId, string runId, string owner, TimeSpan ttl)
=> _db.StringSetAsync(KeyFor(tenantId, runId), owner, ttl, When.NotExists);
/// Extends the TTL if (and only if) the caller still owns the lease.
public async Task RenewAsync(string tenantId, string runId, string owner, TimeSpan ttl)
{
var current = await _db.StringGetAsync(KeyFor(tenantId, runId));
if (current != owner)
return false;
return await _db.KeyExpireAsync(KeyFor(tenantId, runId), ttl);
}
/// Releases the lease only if the caller owns it.
public async Task ReleaseAsync(string tenantId, string runId, string owner)
{
var result = await _db.ScriptEvaluateAsync(
ReleaseScript, new RedisKey[] { KeyFor(tenantId, runId) }, new RedisValue[] { owner });
return (long)result == 1;
}
}