Add fenced attempt mutation and outcome publication

This commit is contained in:
ms2k 2026-07-17 07:40:36 +09:00
commit 80c631f780
5 changed files with 1868 additions and 18 deletions

View file

@ -10,6 +10,8 @@ pub enum CoordinationError {
Config(String),
#[error("local permit capacity exhausted")]
LocalFull,
#[error("invalid coordination argument: {0}")]
InvalidArgument(String),
/// Value exceeds the IEEE-754 exact integer range used by Lua `tonumber`.
#[error("value {value} exceeds max safe integer {max}")]
UnsafeInteger { value: u64, max: u64 },

View file

@ -14,5 +14,5 @@ pub use local::LocalPermitGate;
pub use store::{CoordinationStore, StoreConfig};
pub use types::{
ActivateOptions, LeaseOwner, LeaseRecord, LiveLease, MAX_SAFE_INTEGER, OpCode, OpResult,
ProgressHint, Reservation, ensure_safe_integer,
ProgressHint, ProgressValue, Reservation, ensure_safe_integer,
};

View file

@ -1,4 +1,5 @@
-- KEYS: lease, progress. ARGV: attempt_id, fence, worker_id, bytes, ttl_ms.
-- KEYS: lease, progress.
-- ARGV: attempt_id, fence, worker_id, bytes, total_bytes, members, total_members, ttl_ms.
-- Fence and bytes remain decimal strings to preserve integer identity.
local function now_ms()
local t = redis.call('TIME')
@ -24,7 +25,10 @@ local attempt_id = ARGV[1]
local fence = ARGV[2]
local worker_id = ARGV[3]
local bytes = ARGV[4]
local ttl_ms = tonumber(ARGV[5])
local total_bytes = ARGV[5]
local completed_members = ARGV[6]
local total_members = ARGV[7]
local ttl_ms = tonumber(ARGV[8])
local raw = redis.call('GET', lease_key)
if not raw then
@ -38,11 +42,38 @@ end
if not owner_eq(rec, attempt_id, fence, worker_id) then
return {'FAIL_STOLEN'}
end
local old_raw = redis.call('GET', progress_key)
if old_raw then
local old = cjson.decode(old_raw)
if old.attempt_id == attempt_id and fence_str(old.fence) == fence_str(fence) then
if tonumber(old.bytes) > tonumber(bytes)
or tonumber(old.completed_members or '0') > tonumber(completed_members) then
return {'FAIL_STALE'}
end
if old.total_bytes then
if total_bytes == '' then
total_bytes = fence_str(old.total_bytes)
elseif fence_str(old.total_bytes) ~= fence_str(total_bytes) then
return {'FAIL_STALE'}
end
end
if old.total_members then
if total_members == '' then
total_members = fence_str(old.total_members)
elseif fence_str(old.total_members) ~= fence_str(total_members) then
return {'FAIL_STALE'}
end
end
end
end
local prog = {
attempt_id = attempt_id,
fence = fence_str(fence),
bytes = fence_str(bytes)
bytes = fence_str(bytes),
completed_members = fence_str(completed_members)
}
if total_bytes ~= '' then prog.total_bytes = fence_str(total_bytes) end
if total_members ~= '' then prog.total_members = fence_str(total_members) end
redis.call('SET', progress_key, cjson.encode(prog))
redis.call('PEXPIRE', progress_key, ttl_ms)
return {'OK', fence_str(bytes)}

View file

@ -8,7 +8,7 @@ use crate::keys::KeySpace;
use crate::scripts::Scripts;
use crate::types::{
ActivateOptions, LeaseOwner, LeaseRecord, LiveLease, OpCode, OpResult, ProgressHint,
Reservation, ensure_safe_integer,
ProgressValue, Reservation,
};
#[derive(Clone, Debug)]
@ -284,10 +284,31 @@ impl CoordinationStore {
job_id: &str,
owner: &LeaseOwner,
bytes: u64,
) -> Result<OpResult, CoordinationError> {
self.report_progress_value(
job_id,
owner,
ProgressValue {
completed_bytes: bytes,
total_bytes: None,
completed_members: 0,
total_members: None,
},
)
.await
}
pub async fn report_progress_value(
&self,
job_id: &str,
owner: &LeaseOwner,
progress: ProgressValue,
) -> Result<OpResult, CoordinationError> {
owner.validate()?;
ensure_safe_integer(bytes)?;
let progress = progress.validate()?;
let ttl_ms = duration_ms(self.config.progress_ttl);
let total_bytes = progress.total_bytes.map(|value| value.to_string());
let total_members = progress.total_members.map(|value| value.to_string());
self.eval_script(
&self.scripts.report_progress,
&[self.keys.lease(job_id), self.keys.progress(job_id)],
@ -295,7 +316,10 @@ impl CoordinationStore {
owner.attempt_id.as_str(),
&owner.fence.to_string(),
owner.worker_id.as_str(),
&bytes.to_string(),
&progress.completed_bytes.to_string(),
total_bytes.as_deref().unwrap_or(""),
&progress.completed_members.to_string(),
total_members.as_deref().unwrap_or(""),
&ttl_ms.to_string(),
],
)

View file

@ -29,6 +29,7 @@ pub enum OpCode {
FailStolen,
FailFull,
FailFence,
FailStale,
}
impl OpCode {
@ -49,6 +50,7 @@ impl OpCode {
"FAIL_STOLEN" => Self::FailStolen,
"FAIL_FULL" => Self::FailFull,
"FAIL_FENCE" => Self::FailFence,
"FAIL_STALE" => Self::FailStale,
_ => return None,
})
}
@ -124,6 +126,44 @@ pub struct ProgressHint {
pub fence: u64,
#[serde(deserialize_with = "deserialize_u64_lenient")]
pub bytes: u64,
#[serde(default, deserialize_with = "deserialize_optional_u64_lenient")]
pub total_bytes: Option<u64>,
#[serde(default, deserialize_with = "deserialize_u64_lenient")]
pub completed_members: u64,
#[serde(default, deserialize_with = "deserialize_optional_u64_lenient")]
pub total_members: Option<u64>,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct ProgressValue {
pub completed_bytes: u64,
pub total_bytes: Option<u64>,
pub completed_members: u64,
pub total_members: Option<u64>,
}
impl ProgressValue {
pub fn validate(self) -> Result<Self, CoordinationError> {
ensure_safe_integer(self.completed_bytes)?;
ensure_safe_integer(self.completed_members)?;
if let Some(total) = self.total_bytes {
ensure_safe_integer(total)?;
if self.completed_bytes > total {
return Err(CoordinationError::InvalidArgument(
"completed bytes exceed total bytes".into(),
));
}
}
if let Some(total) = self.total_members {
ensure_safe_integer(total)?;
if self.completed_members > total {
return Err(CoordinationError::InvalidArgument(
"completed members exceed total members".into(),
));
}
}
Ok(self)
}
}
fn deserialize_u64_lenient<'de, D>(deserializer: D) -> Result<u64, D::Error>
@ -158,6 +198,23 @@ where
deserializer.deserialize_any(U64Visitor)
}
#[derive(Deserialize)]
#[serde(untagged)]
enum LenientU64 {
Number(u64),
String(String),
}
fn deserialize_optional_u64_lenient<'de, D>(deserializer: D) -> Result<Option<u64>, D::Error>
where
D: Deserializer<'de>,
{
Option::<LenientU64>::deserialize(deserializer)?.map_or(Ok(None), |value| match value {
LenientU64::Number(value) => Ok(Some(value)),
LenientU64::String(value) => value.parse().map(Some).map_err(serde::de::Error::custom),
})
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct OpResult {
pub code: OpCode,