2026-07-17 07:07:43 +09:00
|
|
|
use std::sync::Arc;
|
|
|
|
|
use std::sync::atomic::{AtomicBool, Ordering};
|
|
|
|
|
|
|
|
|
|
use remora_coordination::{
|
|
|
|
|
ActivateOptions, CoordinationStore, LeaseOwner, LocalPermitGate, MAX_SAFE_INTEGER, OpCode,
|
|
|
|
|
};
|
|
|
|
|
use remora_domain::{OpaqueId, fingerprint, ports::MutationScope};
|
|
|
|
|
use remora_durable::{
|
|
|
|
|
DurableError, MutationDisposition, MutationStatus, enqueue, execute_mutation, with_transaction,
|
|
|
|
|
};
|
|
|
|
|
use serde::Serialize;
|
|
|
|
|
use serde_json::json;
|
2026-07-17 07:40:36 +09:00
|
|
|
use sqlx::{PgPool, types::Json};
|
2026-07-17 07:07:43 +09:00
|
|
|
use time::OffsetDateTime;
|
|
|
|
|
|
|
|
|
|
use crate::{
|
2026-07-17 09:16:46 +09:00
|
|
|
ActivationMutationOutcome, ActivationOutcome, AttemptAuthority, AttemptError,
|
|
|
|
|
AttemptFaultInjector, AttemptFaultPoint, AttemptState, CheckpointProjection, ClaimOutcome,
|
|
|
|
|
ClaimRejection, ExecutionEnvelopeRecord, LeaseProjection, RecoveryOutcome,
|
|
|
|
|
RenewalMutationOutcome, RenewalOutcome,
|
2026-07-17 07:07:43 +09:00
|
|
|
};
|
|
|
|
|
|
|
|
|
|
struct NoFaults;
|
|
|
|
|
|
|
|
|
|
impl AttemptFaultInjector for NoFaults {
|
|
|
|
|
fn hit(&self, _point: AttemptFaultPoint) {}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[derive(Clone, Debug)]
|
|
|
|
|
pub struct AttemptConfig {
|
|
|
|
|
pub max_attempts: u64,
|
|
|
|
|
pub local_limit: u64,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
impl Default for AttemptConfig {
|
|
|
|
|
fn default() -> Self {
|
|
|
|
|
Self {
|
|
|
|
|
max_attempts: 3,
|
|
|
|
|
local_limit: 4,
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[derive(Clone)]
|
|
|
|
|
pub struct AttemptService {
|
2026-07-17 07:40:36 +09:00
|
|
|
pub(crate) pool: PgPool,
|
|
|
|
|
pub(crate) coordination: CoordinationStore,
|
2026-07-17 07:07:43 +09:00
|
|
|
local_permits: LocalPermitGate,
|
2026-07-17 07:40:36 +09:00
|
|
|
pub(crate) operation_subject: OpaqueId,
|
2026-07-17 08:36:39 +09:00
|
|
|
pub(crate) faults: Arc<dyn AttemptFaultInjector>,
|
2026-07-17 07:40:36 +09:00
|
|
|
pub(crate) config: AttemptConfig,
|
2026-07-17 07:07:43 +09:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
impl AttemptService {
|
|
|
|
|
pub fn new(
|
|
|
|
|
pool: PgPool,
|
|
|
|
|
coordination: CoordinationStore,
|
|
|
|
|
config: AttemptConfig,
|
|
|
|
|
) -> Result<Self, AttemptError> {
|
|
|
|
|
Self::new_with_fault_injector(pool, coordination, config, Arc::new(NoFaults))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
pub fn new_with_fault_injector(
|
|
|
|
|
pool: PgPool,
|
|
|
|
|
coordination: CoordinationStore,
|
|
|
|
|
config: AttemptConfig,
|
|
|
|
|
faults: Arc<dyn AttemptFaultInjector>,
|
|
|
|
|
) -> Result<Self, AttemptError> {
|
|
|
|
|
if config.max_attempts == 0 || config.max_attempts > MAX_SAFE_INTEGER {
|
|
|
|
|
return Err(AttemptError::Invalid(
|
|
|
|
|
"max_attempts must fit the coordination safe-integer range".into(),
|
|
|
|
|
));
|
|
|
|
|
}
|
|
|
|
|
if config.local_limit == 0 {
|
|
|
|
|
return Err(AttemptError::Invalid(
|
|
|
|
|
"local_limit must be greater than zero".into(),
|
|
|
|
|
));
|
|
|
|
|
}
|
|
|
|
|
Ok(Self {
|
|
|
|
|
pool,
|
|
|
|
|
coordination,
|
|
|
|
|
local_permits: LocalPermitGate::new(config.local_limit),
|
|
|
|
|
operation_subject: OpaqueId::parse("remora_attempt_service")
|
|
|
|
|
.map_err(|error| AttemptError::Invalid(error.to_string()))?,
|
|
|
|
|
faults,
|
|
|
|
|
config,
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
pub async fn claim(
|
|
|
|
|
&self,
|
|
|
|
|
job_id: &OpaqueId,
|
|
|
|
|
worker_incarnation: &OpaqueId,
|
|
|
|
|
operation_id: &OpaqueId,
|
|
|
|
|
) -> Result<ClaimOutcome, AttemptError> {
|
|
|
|
|
if self.local_permits.acquire(job_id.as_str()).is_err() {
|
|
|
|
|
return Ok(ClaimOutcome::Rejected {
|
|
|
|
|
reason: ClaimRejection::LocalCapacity,
|
|
|
|
|
disposition: None,
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
let request = ClaimRequest {
|
|
|
|
|
job_id,
|
|
|
|
|
worker_incarnation,
|
|
|
|
|
};
|
|
|
|
|
let request_fingerprint =
|
|
|
|
|
fingerprint(&request).map_err(|error| AttemptError::Invalid(error.to_string()))?;
|
|
|
|
|
let attempt_id = new_id('a')?;
|
|
|
|
|
let job = job_id.clone();
|
|
|
|
|
let worker = worker_incarnation.clone();
|
|
|
|
|
let max_attempts = self.config.max_attempts;
|
|
|
|
|
let coordination = self.coordination.clone();
|
|
|
|
|
let faults = self.faults.clone();
|
|
|
|
|
let reserved = Arc::new(AtomicBool::new(false));
|
|
|
|
|
let effect_reserved = reserved.clone();
|
|
|
|
|
let outcome = execute_mutation(
|
|
|
|
|
&self.pool,
|
|
|
|
|
MutationScope {
|
|
|
|
|
subject: &self.operation_subject,
|
|
|
|
|
operation: "attempt.mutation",
|
|
|
|
|
key: operation_id.as_str(),
|
|
|
|
|
},
|
|
|
|
|
"claim_attempt",
|
|
|
|
|
&[],
|
|
|
|
|
request_fingerprint,
|
|
|
|
|
move |tx| {
|
|
|
|
|
Box::pin(async move {
|
|
|
|
|
let decision = authorize_attempt(
|
|
|
|
|
tx,
|
|
|
|
|
AuthorizeInput {
|
|
|
|
|
coordination: &coordination,
|
|
|
|
|
job_id: &job,
|
|
|
|
|
attempt_id: &attempt_id,
|
|
|
|
|
worker: &worker,
|
|
|
|
|
max_attempts,
|
|
|
|
|
faults: faults.as_ref(),
|
|
|
|
|
reserved: effect_reserved.as_ref(),
|
|
|
|
|
},
|
|
|
|
|
)
|
|
|
|
|
.await?;
|
|
|
|
|
let status = if matches!(decision, ClaimOutcome::Claimed { .. }) {
|
|
|
|
|
MutationStatus::Accepted
|
|
|
|
|
} else {
|
|
|
|
|
MutationStatus::Rejected
|
|
|
|
|
};
|
|
|
|
|
let response = serde_json::to_value(decision)
|
|
|
|
|
.map_err(|error| DurableError::Effect(error.to_string()))?;
|
|
|
|
|
Ok((status, response))
|
|
|
|
|
})
|
|
|
|
|
},
|
|
|
|
|
)
|
|
|
|
|
.await;
|
|
|
|
|
let outcome = match outcome {
|
|
|
|
|
Ok(outcome) => outcome,
|
|
|
|
|
Err(error) => {
|
|
|
|
|
if reserved.load(Ordering::Acquire) {
|
|
|
|
|
self.compensate_reservation(job_id, worker_incarnation)
|
|
|
|
|
.await;
|
|
|
|
|
} else {
|
|
|
|
|
self.local_permits.release(job_id.as_str());
|
|
|
|
|
}
|
|
|
|
|
return Err(error.into());
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
self.faults.hit(AttemptFaultPoint::ClaimAfterDurable);
|
|
|
|
|
if outcome.disposition == MutationDisposition::Conflict {
|
|
|
|
|
self.local_permits.release(job_id.as_str());
|
|
|
|
|
return Err(AttemptError::OperationConflict);
|
|
|
|
|
}
|
|
|
|
|
let decision: ClaimOutcome = serde_json::from_value(outcome.response)
|
|
|
|
|
.map_err(|error| AttemptError::Invalid(error.to_string()))?;
|
|
|
|
|
if matches!(decision, ClaimOutcome::Rejected { .. }) {
|
|
|
|
|
self.local_permits.release(job_id.as_str());
|
|
|
|
|
}
|
|
|
|
|
Ok(decision.with_disposition(outcome.disposition))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
pub async fn activate(
|
|
|
|
|
&self,
|
|
|
|
|
authority: &AttemptAuthority,
|
|
|
|
|
operation_id: &OpaqueId,
|
|
|
|
|
) -> Result<ActivationOutcome, AttemptError> {
|
2026-07-17 09:16:46 +09:00
|
|
|
Ok(
|
|
|
|
|
match self.activate_operation(authority, operation_id).await? {
|
|
|
|
|
ActivationMutationOutcome::Activated { disposition, .. } => {
|
|
|
|
|
ActivationOutcome::Activated(disposition)
|
|
|
|
|
}
|
|
|
|
|
ActivationMutationOutcome::PreconditionFailed => {
|
|
|
|
|
ActivationOutcome::PreconditionFailed
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
pub async fn activate_operation(
|
|
|
|
|
&self,
|
|
|
|
|
authority: &AttemptAuthority,
|
|
|
|
|
operation_id: &OpaqueId,
|
|
|
|
|
) -> Result<ActivationMutationOutcome, AttemptError> {
|
2026-07-17 07:07:43 +09:00
|
|
|
validate_authority(authority)?;
|
|
|
|
|
let request_fingerprint = fingerprint(&ActivationRequest {
|
|
|
|
|
job_id: &authority.job_id,
|
|
|
|
|
attempt_id: &authority.attempt_id,
|
|
|
|
|
fence: authority.fence,
|
|
|
|
|
worker_incarnation: &authority.worker_incarnation,
|
|
|
|
|
})
|
|
|
|
|
.map_err(|error| AttemptError::Invalid(error.to_string()))?;
|
|
|
|
|
let eligibility = authority_binding(authority);
|
|
|
|
|
let authority_for_effect = authority.clone();
|
|
|
|
|
let coordination = self.coordination.clone();
|
|
|
|
|
let faults = self.faults.clone();
|
|
|
|
|
let outcome = execute_mutation(
|
|
|
|
|
&self.pool,
|
|
|
|
|
MutationScope {
|
|
|
|
|
subject: &self.operation_subject,
|
|
|
|
|
operation: "attempt.mutation",
|
|
|
|
|
key: operation_id.as_str(),
|
|
|
|
|
},
|
|
|
|
|
"activate_attempt",
|
|
|
|
|
&eligibility,
|
|
|
|
|
request_fingerprint,
|
|
|
|
|
move |tx| {
|
|
|
|
|
Box::pin(async move {
|
2026-07-17 09:16:46 +09:00
|
|
|
let lease =
|
2026-07-17 07:07:43 +09:00
|
|
|
activate_attempt(tx, &coordination, &authority_for_effect, faults.as_ref())
|
|
|
|
|
.await?;
|
|
|
|
|
Ok((
|
2026-07-17 09:16:46 +09:00
|
|
|
if lease.is_some() {
|
2026-07-17 07:07:43 +09:00
|
|
|
MutationStatus::Accepted
|
|
|
|
|
} else {
|
|
|
|
|
MutationStatus::Rejected
|
|
|
|
|
},
|
2026-07-17 09:16:46 +09:00
|
|
|
serde_json::to_value(ActivationStored { lease })
|
|
|
|
|
.map_err(DurableError::effect)?,
|
2026-07-17 07:07:43 +09:00
|
|
|
))
|
|
|
|
|
})
|
|
|
|
|
},
|
|
|
|
|
)
|
|
|
|
|
.await;
|
|
|
|
|
let outcome = match outcome {
|
|
|
|
|
Ok(outcome) => outcome,
|
|
|
|
|
Err(error) => {
|
|
|
|
|
if self
|
|
|
|
|
.exact_authority_is_executing_and_live(authority)
|
|
|
|
|
.await?
|
|
|
|
|
{
|
2026-07-17 09:16:46 +09:00
|
|
|
let lease = self
|
|
|
|
|
.live_lease(authority)
|
|
|
|
|
.await?
|
|
|
|
|
.ok_or_else(|| AttemptError::Invalid("live lease disappeared".into()))?;
|
|
|
|
|
return Ok(ActivationMutationOutcome::Activated {
|
|
|
|
|
disposition: MutationDisposition::First,
|
|
|
|
|
lease,
|
|
|
|
|
});
|
2026-07-17 07:07:43 +09:00
|
|
|
}
|
|
|
|
|
self.release_exact(authority).await;
|
|
|
|
|
return Err(error.into());
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
self.faults.hit(AttemptFaultPoint::ActivationAfterDurable);
|
|
|
|
|
if outcome.disposition == MutationDisposition::Conflict {
|
|
|
|
|
return Err(AttemptError::OperationConflict);
|
|
|
|
|
}
|
|
|
|
|
if outcome.status == MutationStatus::Rejected {
|
|
|
|
|
self.release_exact(authority).await;
|
2026-07-17 09:16:46 +09:00
|
|
|
return Ok(ActivationMutationOutcome::PreconditionFailed);
|
2026-07-17 07:07:43 +09:00
|
|
|
}
|
|
|
|
|
if !self
|
|
|
|
|
.exact_authority_is_executing_and_live(authority)
|
|
|
|
|
.await?
|
|
|
|
|
{
|
|
|
|
|
self.release_exact(authority).await;
|
2026-07-17 09:16:46 +09:00
|
|
|
return Ok(ActivationMutationOutcome::PreconditionFailed);
|
2026-07-17 07:07:43 +09:00
|
|
|
}
|
2026-07-17 09:16:46 +09:00
|
|
|
let stored: ActivationStored = serde_json::from_value(outcome.response)
|
|
|
|
|
.map_err(|error| AttemptError::Invalid(error.to_string()))?;
|
|
|
|
|
let lease = stored
|
|
|
|
|
.lease
|
|
|
|
|
.ok_or_else(|| AttemptError::Invalid("accepted activation omitted its lease".into()))?;
|
|
|
|
|
Ok(ActivationMutationOutcome::Activated {
|
|
|
|
|
disposition: outcome.disposition,
|
|
|
|
|
lease,
|
|
|
|
|
})
|
2026-07-17 07:07:43 +09:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
pub async fn renew(
|
|
|
|
|
&self,
|
|
|
|
|
authority: &AttemptAuthority,
|
|
|
|
|
) -> Result<RenewalOutcome, AttemptError> {
|
|
|
|
|
validate_authority(authority)?;
|
|
|
|
|
if !self.exact_authority_is_executing(authority).await? {
|
|
|
|
|
return Ok(RenewalOutcome::AuthorityLost);
|
|
|
|
|
}
|
|
|
|
|
self.faults.hit(AttemptFaultPoint::RenewalBeforeLease);
|
|
|
|
|
let result = self
|
|
|
|
|
.coordination
|
|
|
|
|
.renew(authority.job_id.as_str(), &lease_owner(authority)?)
|
|
|
|
|
.await?;
|
|
|
|
|
self.faults.hit(AttemptFaultPoint::RenewalAfterLease);
|
|
|
|
|
if !result.ok() || !self.exact_authority_is_executing(authority).await? {
|
|
|
|
|
return Ok(RenewalOutcome::AuthorityLost);
|
|
|
|
|
}
|
|
|
|
|
Ok(RenewalOutcome::Renewed)
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-17 09:16:46 +09:00
|
|
|
pub async fn renew_operation(
|
|
|
|
|
&self,
|
|
|
|
|
authority: &AttemptAuthority,
|
|
|
|
|
operation_id: &OpaqueId,
|
|
|
|
|
) -> Result<RenewalMutationOutcome, AttemptError> {
|
|
|
|
|
validate_authority(authority)?;
|
|
|
|
|
let request_fingerprint = fingerprint(&ActivationRequest {
|
|
|
|
|
job_id: &authority.job_id,
|
|
|
|
|
attempt_id: &authority.attempt_id,
|
|
|
|
|
fence: authority.fence,
|
|
|
|
|
worker_incarnation: &authority.worker_incarnation,
|
|
|
|
|
})
|
|
|
|
|
.map_err(|error| AttemptError::Invalid(error.to_string()))?;
|
|
|
|
|
let eligibility = authority_binding(authority);
|
|
|
|
|
let authority = authority.clone();
|
|
|
|
|
let coordination = self.coordination.clone();
|
|
|
|
|
let faults = self.faults.clone();
|
|
|
|
|
let outcome = execute_mutation(
|
|
|
|
|
&self.pool,
|
|
|
|
|
MutationScope {
|
|
|
|
|
subject: &self.operation_subject,
|
|
|
|
|
operation: "attempt.mutation",
|
|
|
|
|
key: operation_id.as_str(),
|
|
|
|
|
},
|
|
|
|
|
"renew_attempt",
|
|
|
|
|
&eligibility,
|
|
|
|
|
request_fingerprint,
|
|
|
|
|
move |tx| {
|
|
|
|
|
Box::pin(async move {
|
|
|
|
|
let Some(row) = lock_authority(tx, &authority.attempt_id).await? else {
|
|
|
|
|
return Ok((
|
|
|
|
|
MutationStatus::Rejected,
|
|
|
|
|
serde_json::to_value(RenewalMutationOutcome::AuthorityLost {
|
|
|
|
|
disposition: None,
|
|
|
|
|
})
|
|
|
|
|
.map_err(DurableError::effect)?,
|
|
|
|
|
));
|
|
|
|
|
};
|
|
|
|
|
if row.state != AttemptState::Executing
|
|
|
|
|
|| !row.matches(
|
|
|
|
|
&authority.job_id,
|
|
|
|
|
authority.fence,
|
|
|
|
|
&authority.worker_incarnation,
|
|
|
|
|
)
|
|
|
|
|
{
|
|
|
|
|
return Ok((
|
|
|
|
|
MutationStatus::Rejected,
|
|
|
|
|
serde_json::to_value(RenewalMutationOutcome::AuthorityLost {
|
|
|
|
|
disposition: None,
|
|
|
|
|
})
|
|
|
|
|
.map_err(DurableError::effect)?,
|
|
|
|
|
));
|
|
|
|
|
}
|
|
|
|
|
faults.hit(AttemptFaultPoint::RenewalBeforeLease);
|
|
|
|
|
let expected = lease_owner(&authority).map_err(DurableError::effect)?;
|
|
|
|
|
let renewed = coordination
|
|
|
|
|
.renew(authority.job_id.as_str(), &expected)
|
|
|
|
|
.await
|
|
|
|
|
.map_err(DurableError::effect)?;
|
|
|
|
|
faults.hit(AttemptFaultPoint::RenewalAfterLease);
|
|
|
|
|
let live = coordination
|
|
|
|
|
.get_live_lease(authority.job_id.as_str())
|
|
|
|
|
.await
|
|
|
|
|
.map_err(DurableError::effect)?;
|
|
|
|
|
let response = match live
|
|
|
|
|
.filter(|lease| renewed.ok() && lease.record.owner() == expected)
|
|
|
|
|
{
|
|
|
|
|
Some(lease) => RenewalMutationOutcome::Renewed {
|
|
|
|
|
lease: LeaseProjection {
|
|
|
|
|
expires_at_ms: lease.record.expires_at,
|
|
|
|
|
observed_at_ms: lease.now_ms,
|
|
|
|
|
},
|
|
|
|
|
disposition: None,
|
|
|
|
|
},
|
|
|
|
|
None => RenewalMutationOutcome::AuthorityLost { disposition: None },
|
|
|
|
|
};
|
|
|
|
|
let status = if matches!(response, RenewalMutationOutcome::Renewed { .. }) {
|
|
|
|
|
MutationStatus::Accepted
|
|
|
|
|
} else {
|
|
|
|
|
MutationStatus::Rejected
|
|
|
|
|
};
|
|
|
|
|
Ok((
|
|
|
|
|
status,
|
|
|
|
|
serde_json::to_value(response).map_err(DurableError::effect)?,
|
|
|
|
|
))
|
|
|
|
|
})
|
|
|
|
|
},
|
|
|
|
|
)
|
|
|
|
|
.await?;
|
|
|
|
|
if outcome.disposition == MutationDisposition::Conflict {
|
|
|
|
|
return Err(AttemptError::OperationConflict);
|
|
|
|
|
}
|
|
|
|
|
let response: RenewalMutationOutcome = serde_json::from_value(outcome.response)
|
|
|
|
|
.map_err(|error| AttemptError::Invalid(error.to_string()))?;
|
|
|
|
|
Ok(response.with_disposition(outcome.disposition))
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-17 07:07:43 +09:00
|
|
|
pub async fn recover_inactive(
|
|
|
|
|
&self,
|
|
|
|
|
authority: &AttemptAuthority,
|
|
|
|
|
) -> Result<RecoveryOutcome, AttemptError> {
|
|
|
|
|
validate_authority(authority)?;
|
|
|
|
|
let job_id = authority.job_id.clone();
|
|
|
|
|
let attempt_id = authority.attempt_id.clone();
|
|
|
|
|
let worker = authority.worker_incarnation.clone();
|
|
|
|
|
let fence = authority.fence;
|
|
|
|
|
let coordination = self.coordination.clone();
|
|
|
|
|
let faults = self.faults.clone();
|
|
|
|
|
let outcome = with_transaction(&self.pool, move |tx| {
|
|
|
|
|
Box::pin(async move {
|
|
|
|
|
let row = lock_authority(tx, &attempt_id).await?;
|
|
|
|
|
let Some(row) = row else {
|
|
|
|
|
return Ok(RecoveryOutcome::AlreadyInactive);
|
|
|
|
|
};
|
|
|
|
|
if !row.matches(&job_id, fence, &worker) {
|
|
|
|
|
return Ok(RecoveryOutcome::AlreadyInactive);
|
|
|
|
|
}
|
|
|
|
|
if row.state == AttemptState::Executing {
|
|
|
|
|
return Ok(RecoveryOutcome::StillOwned);
|
|
|
|
|
}
|
|
|
|
|
if row.state != AttemptState::Authorized {
|
|
|
|
|
return Ok(RecoveryOutcome::AlreadyInactive);
|
|
|
|
|
}
|
|
|
|
|
faults.hit(AttemptFaultPoint::RecoveryBeforeRevalidation);
|
|
|
|
|
if coordination
|
|
|
|
|
.get_reservation(job_id.as_str())
|
|
|
|
|
.await
|
|
|
|
|
.map_err(DurableError::effect)?
|
|
|
|
|
.is_some()
|
|
|
|
|
{
|
|
|
|
|
return Ok(RecoveryOutcome::StillOwned);
|
|
|
|
|
}
|
|
|
|
|
if let Some(lease) = coordination
|
|
|
|
|
.get_live_lease(job_id.as_str())
|
|
|
|
|
.await
|
|
|
|
|
.map_err(DurableError::effect)?
|
|
|
|
|
{
|
|
|
|
|
if lease.record.owner()
|
|
|
|
|
== LeaseOwner::new(attempt_id.as_str(), fence, worker.as_str())
|
|
|
|
|
.map_err(DurableError::effect)?
|
|
|
|
|
{
|
|
|
|
|
return Ok(RecoveryOutcome::StillOwned);
|
|
|
|
|
}
|
|
|
|
|
return Err(DurableError::Effect(
|
|
|
|
|
"foreign live lease conflicts with durable authority".into(),
|
|
|
|
|
));
|
|
|
|
|
}
|
|
|
|
|
let now = OffsetDateTime::now_utc();
|
|
|
|
|
faults.hit(AttemptFaultPoint::RecoveryBeforeDurable);
|
|
|
|
|
sqlx::query(
|
|
|
|
|
"UPDATE attempts
|
|
|
|
|
SET state = 'reaped', reaped_at = $2
|
|
|
|
|
WHERE attempt_id = $1 AND state = 'authorized'",
|
|
|
|
|
)
|
|
|
|
|
.bind(attempt_id.as_str())
|
|
|
|
|
.bind(now)
|
|
|
|
|
.execute(tx.as_mut())
|
|
|
|
|
.await?;
|
|
|
|
|
let changed = sqlx::query(
|
|
|
|
|
"UPDATE jobs
|
|
|
|
|
SET phase = 'queued', authority_attempt_id = NULL,
|
|
|
|
|
dispatch_needed = TRUE, revision = revision + 1, updated_at = $2
|
|
|
|
|
WHERE job_id = $1 AND authority_attempt_id = $3
|
|
|
|
|
AND current_fence = $4 AND phase = 'running'",
|
|
|
|
|
)
|
|
|
|
|
.bind(job_id.as_str())
|
|
|
|
|
.bind(now)
|
|
|
|
|
.bind(attempt_id.as_str())
|
|
|
|
|
.bind(i64::try_from(fence).map_err(DurableError::effect)?)
|
|
|
|
|
.execute(tx.as_mut())
|
|
|
|
|
.await?;
|
|
|
|
|
if changed.rows_affected() != 1 {
|
|
|
|
|
return Err(DurableError::Effect(
|
|
|
|
|
"attempt authority changed while recovery held its lock".into(),
|
|
|
|
|
));
|
|
|
|
|
}
|
|
|
|
|
enqueue(tx, "job.dispatch", json!({ "job_id": job_id })).await?;
|
|
|
|
|
Ok(RecoveryOutcome::Reaped)
|
|
|
|
|
})
|
|
|
|
|
})
|
|
|
|
|
.await?;
|
|
|
|
|
self.faults.hit(AttemptFaultPoint::RecoveryAfterDurable);
|
|
|
|
|
if matches!(
|
|
|
|
|
outcome,
|
|
|
|
|
RecoveryOutcome::Reaped | RecoveryOutcome::AlreadyInactive
|
|
|
|
|
) {
|
|
|
|
|
self.release_exact(authority).await;
|
|
|
|
|
}
|
|
|
|
|
Ok(outcome)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
pub async fn authority(
|
|
|
|
|
&self,
|
|
|
|
|
attempt_id: &OpaqueId,
|
|
|
|
|
) -> Result<Option<AttemptAuthority>, AttemptError> {
|
|
|
|
|
load_authority(&self.pool, attempt_id)
|
|
|
|
|
.await
|
|
|
|
|
.map_err(Into::into)
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-17 09:16:46 +09:00
|
|
|
pub async fn historical_authority(
|
|
|
|
|
&self,
|
|
|
|
|
attempt_id: &OpaqueId,
|
|
|
|
|
) -> Result<Option<AttemptAuthority>, AttemptError> {
|
|
|
|
|
load_historical_authority(&self.pool, attempt_id)
|
|
|
|
|
.await
|
|
|
|
|
.map_err(Into::into)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
pub async fn execution_envelope(
|
|
|
|
|
&self,
|
|
|
|
|
attempt_id: &OpaqueId,
|
|
|
|
|
) -> Result<Option<ExecutionEnvelopeRecord>, AttemptError> {
|
|
|
|
|
let value = sqlx::query_scalar::<_, Option<Json<ExecutionEnvelopeRecord>>>(
|
|
|
|
|
"SELECT execution_envelope FROM attempts WHERE attempt_id = $1",
|
|
|
|
|
)
|
|
|
|
|
.bind(attempt_id.as_str())
|
|
|
|
|
.fetch_optional(&self.pool)
|
|
|
|
|
.await?
|
|
|
|
|
.flatten();
|
|
|
|
|
Ok(value.map(|value| value.0))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
pub async fn live_lease(
|
|
|
|
|
&self,
|
|
|
|
|
authority: &AttemptAuthority,
|
|
|
|
|
) -> Result<Option<LeaseProjection>, AttemptError> {
|
|
|
|
|
let expected = lease_owner(authority)?;
|
|
|
|
|
Ok(self
|
|
|
|
|
.coordination
|
|
|
|
|
.get_live_lease(authority.job_id.as_str())
|
|
|
|
|
.await?
|
|
|
|
|
.filter(|lease| lease.record.owner() == expected)
|
|
|
|
|
.map(|lease| LeaseProjection {
|
|
|
|
|
expires_at_ms: lease.record.expires_at,
|
|
|
|
|
observed_at_ms: lease.now_ms,
|
|
|
|
|
}))
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-17 07:07:43 +09:00
|
|
|
pub fn release_local(&self, job_id: &OpaqueId) -> bool {
|
|
|
|
|
self.local_permits.release_all(job_id.as_str())
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[must_use]
|
|
|
|
|
pub fn local_used(&self) -> u64 {
|
|
|
|
|
self.local_permits.used()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async fn compensate_reservation(&self, job_id: &OpaqueId, worker: &OpaqueId) {
|
|
|
|
|
let _ = self
|
|
|
|
|
.coordination
|
|
|
|
|
.free_reserve(job_id.as_str(), Some(worker.as_str()))
|
|
|
|
|
.await;
|
|
|
|
|
self.local_permits.release(job_id.as_str());
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-17 18:51:44 +09:00
|
|
|
pub(crate) async fn release_exact(&self, authority: &AttemptAuthority) {
|
2026-07-17 07:07:43 +09:00
|
|
|
if let Ok(owner) = lease_owner(authority) {
|
|
|
|
|
let _ = self
|
|
|
|
|
.coordination
|
|
|
|
|
.release(authority.job_id.as_str(), &owner)
|
|
|
|
|
.await;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async fn exact_authority_is_executing(
|
|
|
|
|
&self,
|
|
|
|
|
authority: &AttemptAuthority,
|
|
|
|
|
) -> Result<bool, AttemptError> {
|
|
|
|
|
Ok(load_authority(&self.pool, &authority.attempt_id)
|
|
|
|
|
.await?
|
|
|
|
|
.is_some_and(|current| current == authority.clone_with_state(AttemptState::Executing)))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async fn exact_authority_is_executing_and_live(
|
|
|
|
|
&self,
|
|
|
|
|
authority: &AttemptAuthority,
|
|
|
|
|
) -> Result<bool, AttemptError> {
|
|
|
|
|
if !self.exact_authority_is_executing(authority).await? {
|
|
|
|
|
return Ok(false);
|
|
|
|
|
}
|
|
|
|
|
let Some(lease) = self
|
|
|
|
|
.coordination
|
|
|
|
|
.get_live_lease(authority.job_id.as_str())
|
|
|
|
|
.await?
|
|
|
|
|
else {
|
|
|
|
|
return Ok(false);
|
|
|
|
|
};
|
|
|
|
|
Ok(lease.record.owner() == lease_owner(authority)?)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
impl AttemptAuthority {
|
|
|
|
|
fn clone_with_state(&self, state: AttemptState) -> Self {
|
|
|
|
|
Self {
|
|
|
|
|
job_id: self.job_id.clone(),
|
|
|
|
|
attempt_id: self.attempt_id.clone(),
|
|
|
|
|
fence: self.fence,
|
|
|
|
|
worker_incarnation: self.worker_incarnation.clone(),
|
|
|
|
|
state,
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[derive(Serialize)]
|
|
|
|
|
struct ClaimRequest<'a> {
|
|
|
|
|
job_id: &'a OpaqueId,
|
|
|
|
|
worker_incarnation: &'a OpaqueId,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[derive(Serialize)]
|
|
|
|
|
struct ActivationRequest<'a> {
|
|
|
|
|
job_id: &'a OpaqueId,
|
|
|
|
|
attempt_id: &'a OpaqueId,
|
|
|
|
|
fence: u64,
|
|
|
|
|
worker_incarnation: &'a OpaqueId,
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-17 09:16:46 +09:00
|
|
|
#[derive(serde::Deserialize, Serialize)]
|
|
|
|
|
struct ActivationStored {
|
|
|
|
|
lease: Option<LeaseProjection>,
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-17 07:07:43 +09:00
|
|
|
struct AuthorityRow {
|
|
|
|
|
job_id: OpaqueId,
|
|
|
|
|
fence: u64,
|
|
|
|
|
worker: OpaqueId,
|
|
|
|
|
state: AttemptState,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
impl AuthorityRow {
|
|
|
|
|
fn matches(&self, job_id: &OpaqueId, fence: u64, worker: &OpaqueId) -> bool {
|
|
|
|
|
self.job_id == *job_id && self.fence == fence && self.worker == *worker
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
struct AuthorizeInput<'a> {
|
|
|
|
|
coordination: &'a CoordinationStore,
|
|
|
|
|
job_id: &'a OpaqueId,
|
|
|
|
|
attempt_id: &'a OpaqueId,
|
|
|
|
|
worker: &'a OpaqueId,
|
|
|
|
|
max_attempts: u64,
|
|
|
|
|
faults: &'a dyn AttemptFaultInjector,
|
|
|
|
|
reserved: &'a AtomicBool,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async fn authorize_attempt(
|
|
|
|
|
tx: &mut remora_durable::PgTx<'_>,
|
|
|
|
|
input: AuthorizeInput<'_>,
|
|
|
|
|
) -> Result<ClaimOutcome, DurableError> {
|
2026-07-17 07:40:36 +09:00
|
|
|
let row = sqlx::query_as::<
|
|
|
|
|
_,
|
|
|
|
|
(
|
|
|
|
|
String,
|
|
|
|
|
bool,
|
|
|
|
|
i64,
|
|
|
|
|
Option<String>,
|
|
|
|
|
Option<Json<crate::DurableDescriptor>>,
|
|
|
|
|
Option<Json<CheckpointProjection>>,
|
2026-07-17 18:51:44 +09:00
|
|
|
Option<String>,
|
2026-07-17 07:40:36 +09:00
|
|
|
),
|
|
|
|
|
>(
|
|
|
|
|
"SELECT phase, dispatch_needed, current_fence, authority_attempt_id,
|
2026-07-17 18:51:44 +09:00
|
|
|
resolved_source, checkpoint, checkpoint_owner_attempt_id
|
2026-07-17 07:07:43 +09:00
|
|
|
FROM jobs WHERE job_id = $1 FOR UPDATE",
|
|
|
|
|
)
|
|
|
|
|
.bind(input.job_id.as_str())
|
|
|
|
|
.fetch_optional(tx.as_mut())
|
|
|
|
|
.await?;
|
2026-07-17 18:51:44 +09:00
|
|
|
let Some((
|
|
|
|
|
phase,
|
|
|
|
|
dispatch_needed,
|
|
|
|
|
current_fence,
|
|
|
|
|
authority,
|
|
|
|
|
resolved_source,
|
|
|
|
|
checkpoint,
|
|
|
|
|
checkpoint_owner,
|
|
|
|
|
)) = row
|
2026-07-17 07:40:36 +09:00
|
|
|
else {
|
2026-07-17 07:07:43 +09:00
|
|
|
return Ok(claim_rejected(ClaimRejection::JobUnavailable));
|
|
|
|
|
};
|
|
|
|
|
if phase != "queued" || !dispatch_needed || authority.is_some() {
|
|
|
|
|
return Ok(claim_rejected(ClaimRejection::JobUnavailable));
|
|
|
|
|
}
|
|
|
|
|
let current_fence = u64::try_from(current_fence).map_err(DurableError::effect)?;
|
|
|
|
|
if current_fence >= input.max_attempts {
|
|
|
|
|
return Ok(claim_rejected(ClaimRejection::AttemptsExhausted));
|
|
|
|
|
}
|
2026-07-17 18:51:44 +09:00
|
|
|
let reusable = resolved_source
|
|
|
|
|
.as_ref()
|
|
|
|
|
.zip(checkpoint.as_ref())
|
|
|
|
|
.and_then(|(source, checkpoint)| checkpoint.0.reusable().map(|value| (source, value)))
|
|
|
|
|
.filter(|(source, checkpoint)| checkpoint.resolved_source_id == source.0.id);
|
|
|
|
|
if let (Some(_), Some(owner)) = (reusable, checkpoint_owner.as_deref()) {
|
|
|
|
|
let stopped = sqlx::query_scalar::<_, bool>(
|
|
|
|
|
"SELECT state = 'stopped'
|
|
|
|
|
FROM attempts
|
|
|
|
|
WHERE attempt_id = $1 AND job_id = $2",
|
|
|
|
|
)
|
|
|
|
|
.bind(owner)
|
|
|
|
|
.bind(input.job_id.as_str())
|
|
|
|
|
.fetch_optional(tx.as_mut())
|
|
|
|
|
.await?
|
|
|
|
|
.unwrap_or(false);
|
|
|
|
|
if !stopped {
|
|
|
|
|
return Ok(claim_rejected(ClaimRejection::JobUnavailable));
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-07-17 07:07:43 +09:00
|
|
|
input.faults.hit(AttemptFaultPoint::ClaimBeforeReserve);
|
|
|
|
|
let reservation = input
|
|
|
|
|
.coordination
|
|
|
|
|
.reserve(input.job_id.as_str(), input.worker.as_str())
|
|
|
|
|
.await
|
|
|
|
|
.map_err(DurableError::effect)?;
|
|
|
|
|
if !reservation.ok() {
|
|
|
|
|
return Ok(claim_rejected(match reservation.code {
|
|
|
|
|
OpCode::FailFull => ClaimRejection::GlobalCapacity,
|
|
|
|
|
_ => ClaimRejection::ReservationHeld,
|
|
|
|
|
}));
|
|
|
|
|
}
|
|
|
|
|
input.reserved.store(true, Ordering::Release);
|
|
|
|
|
input.faults.hit(AttemptFaultPoint::ClaimAfterReserve);
|
|
|
|
|
let fence = current_fence + 1;
|
2026-07-17 18:51:44 +09:00
|
|
|
let checkpoint_stage = match (reusable, checkpoint_owner.as_deref()) {
|
|
|
|
|
(Some((_, checkpoint)), Some(owner)) if checkpoint.validated_bytes > 0 => {
|
|
|
|
|
load_checkpoint_stage(tx, input.job_id, owner, checkpoint).await?
|
2026-07-17 07:40:36 +09:00
|
|
|
}
|
2026-07-17 18:51:44 +09:00
|
|
|
_ => None,
|
2026-07-17 07:40:36 +09:00
|
|
|
};
|
2026-07-17 18:51:44 +09:00
|
|
|
if checkpoint.is_some() && checkpoint_stage.is_none() {
|
|
|
|
|
sqlx::query(
|
|
|
|
|
"UPDATE jobs
|
|
|
|
|
SET checkpoint = NULL, checkpoint_owner_attempt_id = NULL,
|
|
|
|
|
revision = revision + 1, updated_at = $2
|
|
|
|
|
WHERE job_id = $1",
|
|
|
|
|
)
|
|
|
|
|
.bind(input.job_id.as_str())
|
|
|
|
|
.bind(OffsetDateTime::now_utc())
|
|
|
|
|
.execute(tx.as_mut())
|
|
|
|
|
.await?;
|
|
|
|
|
}
|
|
|
|
|
let initial_progress = checkpoint_stage
|
|
|
|
|
.as_ref()
|
|
|
|
|
.map_or(0, |stage| stage.validated_bytes);
|
|
|
|
|
let staging_origin = checkpoint_stage
|
|
|
|
|
.as_ref()
|
|
|
|
|
.map_or(input.attempt_id, |stage| &stage.staging_origin_attempt_id);
|
|
|
|
|
let checkpoint_owner_after_claim = checkpoint_stage.as_ref().map(|_| input.attempt_id.as_str());
|
2026-07-17 07:07:43 +09:00
|
|
|
let now = OffsetDateTime::now_utc();
|
2026-07-17 18:51:44 +09:00
|
|
|
let envelope = load_execution_envelope_record(
|
|
|
|
|
tx,
|
|
|
|
|
input.job_id,
|
|
|
|
|
input.attempt_id,
|
|
|
|
|
fence,
|
|
|
|
|
input.worker,
|
|
|
|
|
staging_origin,
|
|
|
|
|
)
|
|
|
|
|
.await?;
|
2026-07-17 07:07:43 +09:00
|
|
|
sqlx::query(
|
|
|
|
|
"INSERT INTO attempts (
|
|
|
|
|
attempt_id, job_id, fence, worker_incarnation, state,
|
2026-07-17 18:51:44 +09:00
|
|
|
initial_progress, authorized_at, executing_at, reaped_at, execution_envelope,
|
|
|
|
|
staging_origin_attempt_id
|
|
|
|
|
) VALUES ($1, $2, $3, $4, 'authorized', $5, $6, NULL, NULL, $7, $8)",
|
2026-07-17 07:07:43 +09:00
|
|
|
)
|
|
|
|
|
.bind(input.attempt_id.as_str())
|
|
|
|
|
.bind(input.job_id.as_str())
|
|
|
|
|
.bind(i64::try_from(fence).map_err(DurableError::effect)?)
|
|
|
|
|
.bind(input.worker.as_str())
|
2026-07-17 07:40:36 +09:00
|
|
|
.bind(i64::try_from(initial_progress).map_err(DurableError::effect)?)
|
2026-07-17 07:07:43 +09:00
|
|
|
.bind(now)
|
2026-07-17 09:16:46 +09:00
|
|
|
.bind(Json(&envelope))
|
2026-07-17 18:51:44 +09:00
|
|
|
.bind(staging_origin.as_str())
|
2026-07-17 07:07:43 +09:00
|
|
|
.execute(tx.as_mut())
|
|
|
|
|
.await?;
|
2026-07-17 18:51:44 +09:00
|
|
|
if let Some(stage) = checkpoint_stage {
|
|
|
|
|
transfer_checkpoint_stage(tx, &stage, input.attempt_id).await?;
|
|
|
|
|
}
|
2026-07-17 07:07:43 +09:00
|
|
|
sqlx::query(
|
|
|
|
|
"UPDATE jobs
|
|
|
|
|
SET phase = 'running', current_fence = $2, authority_attempt_id = $3,
|
2026-07-17 18:51:44 +09:00
|
|
|
checkpoint_owner_attempt_id = $5, dispatch_needed = FALSE,
|
|
|
|
|
revision = revision + 1, updated_at = $4
|
2026-07-17 07:07:43 +09:00
|
|
|
WHERE job_id = $1",
|
|
|
|
|
)
|
|
|
|
|
.bind(input.job_id.as_str())
|
|
|
|
|
.bind(i64::try_from(fence).map_err(DurableError::effect)?)
|
|
|
|
|
.bind(input.attempt_id.as_str())
|
|
|
|
|
.bind(now)
|
2026-07-17 18:51:44 +09:00
|
|
|
.bind(checkpoint_owner_after_claim)
|
2026-07-17 07:07:43 +09:00
|
|
|
.execute(tx.as_mut())
|
|
|
|
|
.await?;
|
|
|
|
|
Ok(ClaimOutcome::Claimed {
|
|
|
|
|
authority: AttemptAuthority {
|
|
|
|
|
job_id: input.job_id.clone(),
|
|
|
|
|
attempt_id: input.attempt_id.clone(),
|
|
|
|
|
fence,
|
|
|
|
|
worker_incarnation: input.worker.clone(),
|
|
|
|
|
state: AttemptState::Authorized,
|
|
|
|
|
},
|
|
|
|
|
disposition: None,
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-17 18:51:44 +09:00
|
|
|
struct CheckpointStage {
|
|
|
|
|
owner_attempt_id: OpaqueId,
|
|
|
|
|
staging_origin_attempt_id: OpaqueId,
|
|
|
|
|
validated_bytes: u64,
|
|
|
|
|
validated_members: u64,
|
|
|
|
|
inventory_bytes: u64,
|
|
|
|
|
staging_multipart: Option<crate::CheckpointMultipart>,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async fn load_checkpoint_stage(
|
|
|
|
|
tx: &mut remora_durable::PgTx<'_>,
|
|
|
|
|
job_id: &OpaqueId,
|
|
|
|
|
owner: &str,
|
|
|
|
|
checkpoint: &crate::CheckpointRecord,
|
|
|
|
|
) -> Result<Option<CheckpointStage>, DurableError> {
|
|
|
|
|
let Some(staging_origin) = sqlx::query_scalar::<_, String>(
|
|
|
|
|
"SELECT staging_origin_attempt_id
|
|
|
|
|
FROM attempts
|
|
|
|
|
WHERE attempt_id = $1 AND job_id = $2 AND state = 'stopped'",
|
|
|
|
|
)
|
|
|
|
|
.bind(owner)
|
|
|
|
|
.bind(job_id.as_str())
|
|
|
|
|
.fetch_optional(tx.as_mut())
|
|
|
|
|
.await?
|
|
|
|
|
else {
|
|
|
|
|
return Ok(None);
|
|
|
|
|
};
|
|
|
|
|
let Some((state, member_count, inventory_origin, inventory_prefix, target_prefix)) =
|
|
|
|
|
sqlx::query_as::<
|
|
|
|
|
_,
|
|
|
|
|
(
|
|
|
|
|
String,
|
|
|
|
|
i64,
|
|
|
|
|
Option<String>,
|
|
|
|
|
Option<String>,
|
|
|
|
|
Json<Vec<String>>,
|
|
|
|
|
),
|
|
|
|
|
>(
|
|
|
|
|
"SELECT i.state, i.member_count, i.staging_origin_attempt_id,
|
|
|
|
|
i.target_prefix, j.target_prefix
|
|
|
|
|
FROM result_inventories i
|
|
|
|
|
JOIN attempts a ON a.attempt_id = i.attempt_id
|
|
|
|
|
JOIN jobs j ON j.job_id = a.job_id
|
|
|
|
|
WHERE i.attempt_id = $1
|
|
|
|
|
FOR UPDATE OF i",
|
|
|
|
|
)
|
|
|
|
|
.bind(owner)
|
|
|
|
|
.fetch_optional(tx.as_mut())
|
|
|
|
|
.await?
|
|
|
|
|
else {
|
|
|
|
|
return Ok(None);
|
|
|
|
|
};
|
|
|
|
|
if inventory_origin.as_deref() != Some(staging_origin.as_str())
|
|
|
|
|
|| inventory_prefix.as_deref() != Some(target_prefix.0.join("/").as_str())
|
|
|
|
|
{
|
|
|
|
|
return Ok(None);
|
|
|
|
|
}
|
|
|
|
|
let validated_members =
|
|
|
|
|
i64::try_from(checkpoint.validated_members).map_err(DurableError::effect)?;
|
|
|
|
|
let validated_bytes =
|
|
|
|
|
i64::try_from(checkpoint.validated_bytes).map_err(DurableError::effect)?;
|
|
|
|
|
if state != "open" || member_count < validated_members {
|
|
|
|
|
return Ok(None);
|
|
|
|
|
}
|
|
|
|
|
let (retained_members, retained_bytes) = sqlx::query_as::<_, (i64, i64)>(
|
|
|
|
|
"SELECT COUNT(*), COALESCE(SUM(size), 0)::BIGINT
|
|
|
|
|
FROM (
|
|
|
|
|
SELECT size
|
|
|
|
|
FROM result_inventory_members
|
|
|
|
|
WHERE attempt_id = $1
|
|
|
|
|
ORDER BY path COLLATE \"C\", member_id COLLATE \"C\"
|
|
|
|
|
LIMIT $2
|
|
|
|
|
) retained",
|
|
|
|
|
)
|
|
|
|
|
.bind(owner)
|
|
|
|
|
.bind(validated_members)
|
|
|
|
|
.fetch_one(tx.as_mut())
|
|
|
|
|
.await?;
|
|
|
|
|
if retained_members != validated_members || retained_bytes > validated_bytes {
|
|
|
|
|
return Ok(None);
|
|
|
|
|
}
|
|
|
|
|
let partial = retained_bytes < validated_bytes;
|
|
|
|
|
if partial != checkpoint.staging_multipart.is_some() {
|
|
|
|
|
return Ok(None);
|
|
|
|
|
}
|
|
|
|
|
let cleanup_started = sqlx::query_scalar::<_, bool>(
|
|
|
|
|
"WITH retained AS (
|
|
|
|
|
SELECT object_store, object_bucket, object_key, object_version_id
|
|
|
|
|
FROM result_inventory_members
|
|
|
|
|
WHERE attempt_id = $1
|
|
|
|
|
ORDER BY path COLLATE \"C\", member_id COLLATE \"C\"
|
|
|
|
|
LIMIT $2
|
|
|
|
|
)
|
|
|
|
|
SELECT EXISTS (
|
|
|
|
|
SELECT 1
|
|
|
|
|
FROM attempt_staging_cleanup c
|
|
|
|
|
JOIN retained r
|
|
|
|
|
ON r.object_store = c.target_store
|
|
|
|
|
AND r.object_bucket = c.bucket
|
|
|
|
|
AND r.object_key = c.object_key
|
|
|
|
|
AND r.object_version_id IS NOT DISTINCT FROM c.version_id
|
|
|
|
|
WHERE c.attempt_id = $1
|
|
|
|
|
AND c.kind = 'object'
|
|
|
|
|
AND c.state <> 'pending'
|
|
|
|
|
)",
|
|
|
|
|
)
|
|
|
|
|
.bind(owner)
|
|
|
|
|
.bind(validated_members)
|
|
|
|
|
.fetch_one(tx.as_mut())
|
|
|
|
|
.await?;
|
|
|
|
|
if cleanup_started {
|
|
|
|
|
return Ok(None);
|
|
|
|
|
}
|
|
|
|
|
if let Some(multipart) = &checkpoint.staging_multipart {
|
|
|
|
|
let available = sqlx::query_scalar::<_, bool>(
|
|
|
|
|
"SELECT EXISTS (
|
|
|
|
|
SELECT 1 FROM attempt_staging_cleanup
|
|
|
|
|
WHERE attempt_id = $1 AND kind = 'multipart'
|
|
|
|
|
AND target_store = $2 AND bucket = $3
|
|
|
|
|
AND object_key = $4 AND upload_id = $5
|
|
|
|
|
AND state = 'pending'
|
|
|
|
|
)",
|
|
|
|
|
)
|
|
|
|
|
.bind(owner)
|
|
|
|
|
.bind(&multipart.target_store)
|
|
|
|
|
.bind(&multipart.bucket)
|
|
|
|
|
.bind(&multipart.key)
|
|
|
|
|
.bind(&multipart.upload_id)
|
|
|
|
|
.fetch_one(tx.as_mut())
|
|
|
|
|
.await?;
|
|
|
|
|
if !available {
|
|
|
|
|
return Ok(None);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
Ok(Some(CheckpointStage {
|
|
|
|
|
owner_attempt_id: OpaqueId::parse(owner).map_err(DurableError::effect)?,
|
|
|
|
|
staging_origin_attempt_id: OpaqueId::parse(staging_origin).map_err(DurableError::effect)?,
|
|
|
|
|
validated_bytes: checkpoint.validated_bytes,
|
|
|
|
|
validated_members: checkpoint.validated_members,
|
|
|
|
|
inventory_bytes: u64::try_from(retained_bytes).map_err(DurableError::effect)?,
|
|
|
|
|
staging_multipart: checkpoint.staging_multipart.clone(),
|
|
|
|
|
}))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async fn transfer_checkpoint_stage(
|
|
|
|
|
tx: &mut remora_durable::PgTx<'_>,
|
|
|
|
|
stage: &CheckpointStage,
|
|
|
|
|
replacement: &OpaqueId,
|
|
|
|
|
) -> Result<(), DurableError> {
|
|
|
|
|
let retained_members = i64::try_from(stage.validated_members).map_err(DurableError::effect)?;
|
|
|
|
|
let retained_bytes = i64::try_from(stage.inventory_bytes).map_err(DurableError::effect)?;
|
|
|
|
|
let now = OffsetDateTime::now_utc();
|
|
|
|
|
sqlx::query(
|
|
|
|
|
"WITH ranked AS (
|
|
|
|
|
SELECT *, ROW_NUMBER() OVER (
|
|
|
|
|
ORDER BY path COLLATE \"C\", member_id COLLATE \"C\"
|
|
|
|
|
) AS ordinal
|
|
|
|
|
FROM result_inventory_members
|
|
|
|
|
WHERE attempt_id = $1
|
|
|
|
|
)
|
|
|
|
|
INSERT INTO attempt_staging_cleanup (
|
|
|
|
|
attempt_id, kind, target_store, bucket, object_key, version_id, created_at
|
|
|
|
|
)
|
|
|
|
|
SELECT $1, 'object', object_store, object_bucket, object_key,
|
|
|
|
|
object_version_id, $3
|
|
|
|
|
FROM ranked
|
|
|
|
|
WHERE ordinal > $2
|
|
|
|
|
ON CONFLICT DO NOTHING",
|
|
|
|
|
)
|
|
|
|
|
.bind(stage.owner_attempt_id.as_str())
|
|
|
|
|
.bind(retained_members)
|
|
|
|
|
.bind(now)
|
|
|
|
|
.execute(tx.as_mut())
|
|
|
|
|
.await?;
|
|
|
|
|
sqlx::query(
|
|
|
|
|
"WITH retained AS (
|
|
|
|
|
SELECT object_store, object_bucket, object_key, object_version_id
|
|
|
|
|
FROM result_inventory_members
|
|
|
|
|
WHERE attempt_id = $1
|
|
|
|
|
ORDER BY path COLLATE \"C\", member_id COLLATE \"C\"
|
|
|
|
|
LIMIT $3
|
|
|
|
|
)
|
|
|
|
|
UPDATE attempt_staging_cleanup c
|
|
|
|
|
SET attempt_id = $2
|
|
|
|
|
FROM retained r
|
|
|
|
|
WHERE c.attempt_id = $1
|
|
|
|
|
AND c.kind = 'object'
|
|
|
|
|
AND c.state = 'pending'
|
|
|
|
|
AND r.object_store = c.target_store
|
|
|
|
|
AND r.object_bucket = c.bucket
|
|
|
|
|
AND r.object_key = c.object_key
|
|
|
|
|
AND r.object_version_id IS NOT DISTINCT FROM c.version_id",
|
|
|
|
|
)
|
|
|
|
|
.bind(stage.owner_attempt_id.as_str())
|
|
|
|
|
.bind(replacement.as_str())
|
|
|
|
|
.bind(retained_members)
|
|
|
|
|
.execute(tx.as_mut())
|
|
|
|
|
.await?;
|
|
|
|
|
if let Some(multipart) = &stage.staging_multipart {
|
|
|
|
|
let changed = sqlx::query(
|
|
|
|
|
"UPDATE attempt_staging_cleanup
|
|
|
|
|
SET attempt_id = $2
|
|
|
|
|
WHERE attempt_id = $1 AND state = 'pending'
|
|
|
|
|
AND (
|
|
|
|
|
(kind = 'multipart' AND target_store = $3 AND bucket = $4
|
|
|
|
|
AND object_key = $5 AND upload_id = $6)
|
|
|
|
|
OR (kind = 'write_intent' AND target_store = $3 AND bucket = $4
|
|
|
|
|
AND object_key = $5)
|
|
|
|
|
)",
|
|
|
|
|
)
|
|
|
|
|
.bind(stage.owner_attempt_id.as_str())
|
|
|
|
|
.bind(replacement.as_str())
|
|
|
|
|
.bind(&multipart.target_store)
|
|
|
|
|
.bind(&multipart.bucket)
|
|
|
|
|
.bind(&multipart.key)
|
|
|
|
|
.bind(&multipart.upload_id)
|
|
|
|
|
.execute(tx.as_mut())
|
|
|
|
|
.await?;
|
|
|
|
|
if changed.rows_affected() == 0 {
|
|
|
|
|
return Err(DurableError::effect(
|
|
|
|
|
"checkpoint multipart ownership transfer failed",
|
|
|
|
|
));
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
sqlx::query(
|
|
|
|
|
"WITH extras AS (
|
|
|
|
|
SELECT attempt_id, member_id
|
|
|
|
|
FROM (
|
|
|
|
|
SELECT attempt_id, member_id, ROW_NUMBER() OVER (
|
|
|
|
|
ORDER BY path COLLATE \"C\", member_id COLLATE \"C\"
|
|
|
|
|
) AS ordinal
|
|
|
|
|
FROM result_inventory_members
|
|
|
|
|
WHERE attempt_id = $1
|
|
|
|
|
) ranked
|
|
|
|
|
WHERE ordinal > $2
|
|
|
|
|
)
|
|
|
|
|
DELETE FROM result_inventory_members m
|
|
|
|
|
USING extras
|
|
|
|
|
WHERE m.attempt_id = extras.attempt_id
|
|
|
|
|
AND m.member_id = extras.member_id",
|
|
|
|
|
)
|
|
|
|
|
.bind(stage.owner_attempt_id.as_str())
|
|
|
|
|
.bind(retained_members)
|
|
|
|
|
.execute(tx.as_mut())
|
|
|
|
|
.await?;
|
|
|
|
|
let changed = sqlx::query(
|
|
|
|
|
"UPDATE result_inventories
|
|
|
|
|
SET attempt_id = $2, member_count = $3, total_bytes = $4
|
|
|
|
|
WHERE attempt_id = $1 AND state = 'open'",
|
|
|
|
|
)
|
|
|
|
|
.bind(stage.owner_attempt_id.as_str())
|
|
|
|
|
.bind(replacement.as_str())
|
|
|
|
|
.bind(retained_members)
|
|
|
|
|
.bind(retained_bytes)
|
|
|
|
|
.execute(tx.as_mut())
|
|
|
|
|
.await?;
|
|
|
|
|
if changed.rows_affected() != 1 {
|
|
|
|
|
return Err(DurableError::effect(
|
|
|
|
|
"checkpoint staging inventory transfer failed",
|
|
|
|
|
));
|
|
|
|
|
}
|
|
|
|
|
Ok(())
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-17 09:16:46 +09:00
|
|
|
async fn load_execution_envelope_record(
|
|
|
|
|
tx: &mut remora_durable::PgTx<'_>,
|
|
|
|
|
job_id: &OpaqueId,
|
|
|
|
|
attempt_id: &OpaqueId,
|
|
|
|
|
fence: u64,
|
|
|
|
|
worker: &OpaqueId,
|
2026-07-17 18:51:44 +09:00
|
|
|
staging_origin_attempt_id: &OpaqueId,
|
2026-07-17 09:16:46 +09:00
|
|
|
) -> Result<ExecutionEnvelopeRecord, DurableError> {
|
|
|
|
|
type Row = (
|
|
|
|
|
Json<serde_json::Value>,
|
|
|
|
|
String,
|
|
|
|
|
String,
|
|
|
|
|
String,
|
|
|
|
|
Json<Vec<String>>,
|
|
|
|
|
String,
|
|
|
|
|
Option<String>,
|
|
|
|
|
Option<String>,
|
|
|
|
|
Option<Json<crate::DurableDescriptor>>,
|
|
|
|
|
Option<Json<CheckpointProjection>>,
|
|
|
|
|
);
|
|
|
|
|
let row = sqlx::query_as::<_, Row>(
|
|
|
|
|
"SELECT j.source_projection, j.sensitive_source_ref, j.target_store,
|
|
|
|
|
j.target_bucket, j.target_prefix, j.execution_grant_id,
|
|
|
|
|
g.credential_id, g.credential_version_id, j.resolved_source, j.checkpoint
|
|
|
|
|
FROM jobs j
|
|
|
|
|
JOIN execution_grants g ON g.grant_id = j.execution_grant_id
|
|
|
|
|
WHERE j.job_id = $1",
|
|
|
|
|
)
|
|
|
|
|
.bind(job_id.as_str())
|
|
|
|
|
.fetch_one(tx.as_mut())
|
|
|
|
|
.await?;
|
|
|
|
|
let grant_id = OpaqueId::parse(row.5).map_err(DurableError::effect)?;
|
|
|
|
|
Ok(ExecutionEnvelopeRecord {
|
|
|
|
|
id: grant_id.clone(),
|
|
|
|
|
job_id: job_id.clone(),
|
|
|
|
|
attempt_id: attempt_id.clone(),
|
|
|
|
|
fence,
|
|
|
|
|
worker_incarnation: worker.clone(),
|
|
|
|
|
source: row.0.0,
|
|
|
|
|
sensitive_source_ref: OpaqueId::parse(row.1).map_err(DurableError::effect)?,
|
|
|
|
|
credential_id: row
|
|
|
|
|
.6
|
|
|
|
|
.map(OpaqueId::parse)
|
|
|
|
|
.transpose()
|
|
|
|
|
.map_err(DurableError::effect)?,
|
|
|
|
|
credential_version_id: row
|
|
|
|
|
.7
|
|
|
|
|
.map(OpaqueId::parse)
|
|
|
|
|
.transpose()
|
|
|
|
|
.map_err(DurableError::effect)?,
|
|
|
|
|
resolved_source: row.8.map(|value| value.0),
|
|
|
|
|
target_store: row.2,
|
|
|
|
|
target_bucket: row.3,
|
|
|
|
|
target_prefix: row.4.0,
|
2026-07-17 18:51:44 +09:00
|
|
|
staging_origin_attempt_id: staging_origin_attempt_id.clone(),
|
2026-07-17 09:16:46 +09:00
|
|
|
execution_grant_id: grant_id,
|
|
|
|
|
checkpoint: row.9.map(|value| value.0),
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-17 07:07:43 +09:00
|
|
|
async fn activate_attempt(
|
|
|
|
|
tx: &mut remora_durable::PgTx<'_>,
|
|
|
|
|
coordination: &CoordinationStore,
|
|
|
|
|
authority: &AttemptAuthority,
|
|
|
|
|
faults: &dyn AttemptFaultInjector,
|
2026-07-17 09:16:46 +09:00
|
|
|
) -> Result<Option<LeaseProjection>, DurableError> {
|
2026-07-17 07:07:43 +09:00
|
|
|
let Some(row) = lock_authority(tx, &authority.attempt_id).await? else {
|
2026-07-17 09:16:46 +09:00
|
|
|
return Ok(None);
|
2026-07-17 07:07:43 +09:00
|
|
|
};
|
|
|
|
|
if !row.matches(
|
|
|
|
|
&authority.job_id,
|
|
|
|
|
authority.fence,
|
|
|
|
|
&authority.worker_incarnation,
|
|
|
|
|
) {
|
2026-07-17 09:16:46 +09:00
|
|
|
return Ok(None);
|
2026-07-17 07:07:43 +09:00
|
|
|
}
|
|
|
|
|
if row.state == AttemptState::Executing {
|
|
|
|
|
let live = coordination
|
|
|
|
|
.get_live_lease(authority.job_id.as_str())
|
|
|
|
|
.await
|
|
|
|
|
.map_err(DurableError::effect)?;
|
|
|
|
|
let expected = lease_owner(authority).map_err(DurableError::effect)?;
|
|
|
|
|
if !live.is_some_and(|lease| lease.record.owner() == expected) {
|
2026-07-17 09:16:46 +09:00
|
|
|
return Ok(None);
|
2026-07-17 07:07:43 +09:00
|
|
|
}
|
2026-07-17 09:16:46 +09:00
|
|
|
if !coordination
|
2026-07-17 07:07:43 +09:00
|
|
|
.renew(authority.job_id.as_str(), &expected)
|
|
|
|
|
.await
|
|
|
|
|
.map_err(DurableError::effect)?
|
2026-07-17 09:16:46 +09:00
|
|
|
.ok()
|
|
|
|
|
{
|
|
|
|
|
return Ok(None);
|
|
|
|
|
}
|
|
|
|
|
return Ok(coordination
|
|
|
|
|
.get_live_lease(authority.job_id.as_str())
|
|
|
|
|
.await
|
|
|
|
|
.map_err(DurableError::effect)?
|
|
|
|
|
.filter(|lease| lease.record.owner() == expected)
|
|
|
|
|
.map(|lease| LeaseProjection {
|
|
|
|
|
expires_at_ms: lease.record.expires_at,
|
|
|
|
|
observed_at_ms: lease.now_ms,
|
|
|
|
|
}));
|
2026-07-17 07:07:43 +09:00
|
|
|
}
|
|
|
|
|
if row.state != AttemptState::Authorized {
|
2026-07-17 09:16:46 +09:00
|
|
|
return Ok(None);
|
2026-07-17 07:07:43 +09:00
|
|
|
}
|
|
|
|
|
faults.hit(AttemptFaultPoint::ActivationBeforeLease);
|
|
|
|
|
let owner = lease_owner(authority).map_err(DurableError::effect)?;
|
|
|
|
|
let coordinated = coordination
|
|
|
|
|
.activate(
|
|
|
|
|
authority.job_id.as_str(),
|
|
|
|
|
&owner,
|
|
|
|
|
ActivateOptions {
|
|
|
|
|
seed_progress: true,
|
|
|
|
|
require_reserve: true,
|
|
|
|
|
},
|
|
|
|
|
)
|
|
|
|
|
.await
|
|
|
|
|
.map_err(DurableError::effect)?;
|
|
|
|
|
if !coordinated.ok() {
|
2026-07-17 09:16:46 +09:00
|
|
|
return Ok(None);
|
2026-07-17 07:07:43 +09:00
|
|
|
}
|
|
|
|
|
faults.hit(AttemptFaultPoint::ActivationAfterLease);
|
|
|
|
|
let live = coordination
|
|
|
|
|
.get_live_lease(authority.job_id.as_str())
|
|
|
|
|
.await
|
|
|
|
|
.map_err(DurableError::effect)?;
|
|
|
|
|
let expected = lease_owner(authority).map_err(DurableError::effect)?;
|
2026-07-17 09:16:46 +09:00
|
|
|
let Some(live) = live.filter(|lease| lease.record.owner() == expected) else {
|
|
|
|
|
return Ok(None);
|
|
|
|
|
};
|
2026-07-17 07:07:43 +09:00
|
|
|
let now = OffsetDateTime::now_utc();
|
|
|
|
|
sqlx::query(
|
|
|
|
|
"UPDATE attempts SET state = 'executing', executing_at = $2
|
|
|
|
|
WHERE attempt_id = $1 AND state = 'authorized'",
|
|
|
|
|
)
|
|
|
|
|
.bind(authority.attempt_id.as_str())
|
|
|
|
|
.bind(now)
|
|
|
|
|
.execute(tx.as_mut())
|
|
|
|
|
.await?;
|
2026-07-17 09:16:46 +09:00
|
|
|
Ok(Some(LeaseProjection {
|
|
|
|
|
expires_at_ms: live.record.expires_at,
|
|
|
|
|
observed_at_ms: live.now_ms,
|
|
|
|
|
}))
|
2026-07-17 07:07:43 +09:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async fn lock_authority(
|
|
|
|
|
tx: &mut remora_durable::PgTx<'_>,
|
|
|
|
|
attempt_id: &OpaqueId,
|
|
|
|
|
) -> Result<Option<AuthorityRow>, DurableError> {
|
|
|
|
|
let row = sqlx::query_as::<_, (String, i64, String, String)>(
|
|
|
|
|
"SELECT a.job_id, a.fence, a.worker_incarnation, a.state
|
|
|
|
|
FROM attempts a
|
|
|
|
|
JOIN jobs j ON j.job_id = a.job_id
|
|
|
|
|
WHERE a.attempt_id = $1
|
|
|
|
|
AND j.authority_attempt_id = a.attempt_id
|
|
|
|
|
AND j.current_fence = a.fence
|
|
|
|
|
AND j.phase = 'running'
|
|
|
|
|
FOR UPDATE OF a, j",
|
|
|
|
|
)
|
|
|
|
|
.bind(attempt_id.as_str())
|
|
|
|
|
.fetch_optional(tx.as_mut())
|
|
|
|
|
.await?;
|
|
|
|
|
row.map(authority_row).transpose()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async fn load_authority(
|
|
|
|
|
pool: &PgPool,
|
|
|
|
|
attempt_id: &OpaqueId,
|
|
|
|
|
) -> Result<Option<AttemptAuthority>, DurableError> {
|
|
|
|
|
let row = sqlx::query_as::<_, (String, String, i64, String, String)>(
|
|
|
|
|
"SELECT a.job_id, a.attempt_id, a.fence, a.worker_incarnation, a.state
|
|
|
|
|
FROM attempts a
|
|
|
|
|
JOIN jobs j ON j.job_id = a.job_id
|
|
|
|
|
WHERE a.attempt_id = $1
|
|
|
|
|
AND j.authority_attempt_id = a.attempt_id
|
|
|
|
|
AND j.current_fence = a.fence
|
|
|
|
|
AND j.phase = 'running'",
|
|
|
|
|
)
|
|
|
|
|
.bind(attempt_id.as_str())
|
|
|
|
|
.fetch_optional(pool)
|
|
|
|
|
.await?;
|
2026-07-17 09:16:46 +09:00
|
|
|
row.map(|(job, attempt, fence, worker, state)| {
|
|
|
|
|
Ok(AttemptAuthority {
|
|
|
|
|
job_id: OpaqueId::parse(job).map_err(DurableError::effect)?,
|
|
|
|
|
attempt_id: OpaqueId::parse(attempt).map_err(DurableError::effect)?,
|
|
|
|
|
fence: u64::try_from(fence).map_err(DurableError::effect)?,
|
|
|
|
|
worker_incarnation: OpaqueId::parse(worker).map_err(DurableError::effect)?,
|
|
|
|
|
state: AttemptState::parse(&state)
|
|
|
|
|
.ok_or_else(|| DurableError::Config(format!("unknown attempt state '{state}'")))?,
|
|
|
|
|
})
|
|
|
|
|
})
|
|
|
|
|
.transpose()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async fn load_historical_authority(
|
|
|
|
|
pool: &PgPool,
|
|
|
|
|
attempt_id: &OpaqueId,
|
|
|
|
|
) -> Result<Option<AttemptAuthority>, DurableError> {
|
|
|
|
|
let row = sqlx::query_as::<_, (String, String, i64, String, String)>(
|
|
|
|
|
"SELECT job_id, attempt_id, fence, worker_incarnation, state
|
|
|
|
|
FROM attempts WHERE attempt_id = $1",
|
|
|
|
|
)
|
|
|
|
|
.bind(attempt_id.as_str())
|
|
|
|
|
.fetch_optional(pool)
|
|
|
|
|
.await?;
|
2026-07-17 07:07:43 +09:00
|
|
|
row.map(|(job, attempt, fence, worker, state)| {
|
|
|
|
|
Ok(AttemptAuthority {
|
|
|
|
|
job_id: OpaqueId::parse(job).map_err(DurableError::effect)?,
|
|
|
|
|
attempt_id: OpaqueId::parse(attempt).map_err(DurableError::effect)?,
|
|
|
|
|
fence: u64::try_from(fence).map_err(DurableError::effect)?,
|
|
|
|
|
worker_incarnation: OpaqueId::parse(worker).map_err(DurableError::effect)?,
|
|
|
|
|
state: AttemptState::parse(&state)
|
|
|
|
|
.ok_or_else(|| DurableError::Config(format!("unknown attempt state '{state}'")))?,
|
|
|
|
|
})
|
|
|
|
|
})
|
|
|
|
|
.transpose()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn authority_row(row: (String, i64, String, String)) -> Result<AuthorityRow, DurableError> {
|
|
|
|
|
Ok(AuthorityRow {
|
|
|
|
|
job_id: OpaqueId::parse(row.0).map_err(DurableError::effect)?,
|
|
|
|
|
fence: u64::try_from(row.1).map_err(DurableError::effect)?,
|
|
|
|
|
worker: OpaqueId::parse(row.2).map_err(DurableError::effect)?,
|
|
|
|
|
state: AttemptState::parse(&row.3)
|
|
|
|
|
.ok_or_else(|| DurableError::Config(format!("unknown attempt state '{}'", row.3)))?,
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn validate_authority(authority: &AttemptAuthority) -> Result<(), AttemptError> {
|
|
|
|
|
if authority.fence == 0 || authority.fence > MAX_SAFE_INTEGER {
|
|
|
|
|
return Err(AttemptError::Invalid("invalid fencing token".into()));
|
|
|
|
|
}
|
|
|
|
|
Ok(())
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn lease_owner(authority: &AttemptAuthority) -> Result<LeaseOwner, AttemptError> {
|
|
|
|
|
LeaseOwner::new(
|
|
|
|
|
authority.attempt_id.as_str(),
|
|
|
|
|
authority.fence,
|
|
|
|
|
authority.worker_incarnation.as_str(),
|
|
|
|
|
)
|
|
|
|
|
.map_err(Into::into)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn authority_binding(authority: &AttemptAuthority) -> Vec<u8> {
|
|
|
|
|
format!(
|
|
|
|
|
"job={}|attempt={}|fence={}|worker={}",
|
|
|
|
|
authority.job_id, authority.attempt_id, authority.fence, authority.worker_incarnation
|
|
|
|
|
)
|
|
|
|
|
.into_bytes()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn claim_rejected(reason: ClaimRejection) -> ClaimOutcome {
|
|
|
|
|
ClaimOutcome::Rejected {
|
|
|
|
|
reason,
|
|
|
|
|
disposition: None,
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-17 08:36:39 +09:00
|
|
|
pub(crate) fn new_id(prefix: char) -> Result<OpaqueId, AttemptError> {
|
2026-07-17 07:07:43 +09:00
|
|
|
let mut bytes = [0u8; 16];
|
|
|
|
|
getrandom::fill(&mut bytes)
|
|
|
|
|
.map_err(|error| AttemptError::Invalid(format!("identifier entropy failed: {error}")))?;
|
|
|
|
|
let mut value = String::with_capacity(33);
|
|
|
|
|
value.push(prefix);
|
|
|
|
|
for byte in bytes {
|
|
|
|
|
use std::fmt::Write as _;
|
|
|
|
|
write!(value, "{byte:02x}").expect("writing to String cannot fail");
|
|
|
|
|
}
|
|
|
|
|
OpaqueId::parse(value).map_err(|error| AttemptError::Invalid(error.to_string()))
|
|
|
|
|
}
|