feat: clean up terminal backend job workers
This commit is contained in:
@@ -284,6 +284,38 @@ pub struct BackendJobRecord {
|
||||
pub completed_at: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum BackendJobWorkerCleanupState {
|
||||
Pending,
|
||||
Executing,
|
||||
Completed,
|
||||
Failed,
|
||||
}
|
||||
|
||||
impl BackendJobWorkerCleanupState {
|
||||
pub(crate) fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::Pending => "pending",
|
||||
Self::Executing => "executing",
|
||||
Self::Completed => "completed",
|
||||
Self::Failed => "failed",
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn parse(value: &str) -> Result<Self> {
|
||||
match value {
|
||||
"pending" => Ok(Self::Pending),
|
||||
"executing" => Ok(Self::Executing),
|
||||
"completed" => Ok(Self::Completed),
|
||||
"failed" => Ok(Self::Failed),
|
||||
other => Err(Error::Store(format!(
|
||||
"unknown Backend Job Worker cleanup state `{other}`"
|
||||
))),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
pub struct BackendJobAttemptRecord {
|
||||
pub workspace_id: String,
|
||||
@@ -300,6 +332,11 @@ pub struct BackendJobAttemptRecord {
|
||||
pub result_digest: Option<String>,
|
||||
pub failure_category: Option<String>,
|
||||
pub failure_detail: Option<String>,
|
||||
pub worker_cleanup_state: Option<BackendJobWorkerCleanupState>,
|
||||
pub worker_cleanup_failure_category: Option<String>,
|
||||
pub worker_cleanup_failure_detail: Option<String>,
|
||||
pub worker_cleanup_updated_at: Option<String>,
|
||||
pub worker_cleanup_completed_at: Option<String>,
|
||||
pub created_at: String,
|
||||
pub updated_at: String,
|
||||
pub completed_at: Option<String>,
|
||||
|
||||
@@ -875,6 +875,13 @@ CREATE TABLE backend_job_attempts (
|
||||
result_digest TEXT,
|
||||
failure_category TEXT,
|
||||
failure_detail TEXT,
|
||||
worker_cleanup_state TEXT CHECK (
|
||||
worker_cleanup_state IN ('pending', 'executing', 'completed', 'failed')
|
||||
),
|
||||
worker_cleanup_failure_category TEXT,
|
||||
worker_cleanup_failure_detail TEXT,
|
||||
worker_cleanup_updated_at TEXT,
|
||||
worker_cleanup_completed_at TEXT,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
completed_at TEXT,
|
||||
@@ -889,6 +896,8 @@ CREATE TABLE backend_job_attempts (
|
||||
);
|
||||
CREATE INDEX backend_job_attempts_recovery
|
||||
ON backend_job_attempts(workspace_id, state, deadline_at);
|
||||
CREATE INDEX backend_job_attempts_worker_cleanup
|
||||
ON backend_job_attempts(workspace_id, worker_cleanup_state, updated_at);
|
||||
CREATE TABLE backend_job_deliveries (
|
||||
workspace_id TEXT NOT NULL,
|
||||
delivery_id TEXT NOT NULL,
|
||||
|
||||
@@ -144,9 +144,10 @@ use crate::authority::{
|
||||
SqliteWorkspaceAuthority, TicketAuthority, TicketMergeRevisionSource, merge_request_summary,
|
||||
};
|
||||
use crate::backend_job::{
|
||||
ABSOLUTE_MAX_CONCURRENT_JOBS, BackendJobAttemptState, BackendJobDeliveryState,
|
||||
BackendJobRequest, BackendJobReservation, BackendJobResultAcceptance,
|
||||
BackendJobResultSubmission, BackendJobState, MAX_JOB_DELIVERY_BYTES, allocation_key,
|
||||
ABSOLUTE_MAX_CONCURRENT_JOBS, BackendJobAttemptRecord, BackendJobAttemptState,
|
||||
BackendJobDeliveryState, BackendJobRequest, BackendJobReservation, BackendJobResultAcceptance,
|
||||
BackendJobResultSubmission, BackendJobState, BackendJobWorkerCleanupState,
|
||||
MAX_JOB_DELIVERY_BYTES, allocation_key,
|
||||
};
|
||||
use crate::companion::{
|
||||
CompanionCancelRequest, CompanionConsole, CompanionMessageRequest, CompanionMessageResponse,
|
||||
@@ -3417,14 +3418,12 @@ impl WorkspaceApi {
|
||||
Ok(spawned) => spawned,
|
||||
Err(error) => {
|
||||
let detail = error.error.to_string();
|
||||
self.store.finish_backend_job_attempt(
|
||||
&self.config.workspace_id,
|
||||
self.finish_backend_job_attempt(
|
||||
&request.job_id,
|
||||
&reservation.attempt.attempt_id,
|
||||
BackendJobAttemptState::Failed,
|
||||
"dispatch_error",
|
||||
&detail,
|
||||
&Utc::now().to_rfc3339_opts(SecondsFormat::Nanos, true),
|
||||
)?;
|
||||
return Err(error.error);
|
||||
}
|
||||
@@ -3437,14 +3436,12 @@ impl WorkspaceApi {
|
||||
.first()
|
||||
.map(|diagnostic| diagnostic.message.as_str())
|
||||
.unwrap_or("embedded Runtime rejected Backend Job Worker dispatch");
|
||||
self.store.finish_backend_job_attempt(
|
||||
&self.config.workspace_id,
|
||||
self.finish_backend_job_attempt(
|
||||
&request.job_id,
|
||||
&reservation.attempt.attempt_id,
|
||||
BackendJobAttemptState::Failed,
|
||||
"dispatch_rejected",
|
||||
detail,
|
||||
&Utc::now().to_rfc3339_opts(SecondsFormat::Nanos, true),
|
||||
)?;
|
||||
return Err(Error::RuntimeOperationFailed {
|
||||
runtime_id: EMBEDDED_WORKER_RUNTIME_ID.to_string(),
|
||||
@@ -3505,14 +3502,12 @@ impl WorkspaceApi {
|
||||
Ok(submitted) => submitted,
|
||||
Err(error) => {
|
||||
let detail = error.message();
|
||||
self.store.finish_backend_job_attempt(
|
||||
&self.config.workspace_id,
|
||||
self.finish_backend_job_attempt(
|
||||
&request.job_id,
|
||||
&reservation.attempt.attempt_id,
|
||||
BackendJobAttemptState::Unknown,
|
||||
"input_outcome_unknown",
|
||||
&detail,
|
||||
&Utc::now().to_rfc3339_opts(SecondsFormat::Nanos, true),
|
||||
)?;
|
||||
return Err(error.into_error());
|
||||
}
|
||||
@@ -3531,14 +3526,12 @@ impl WorkspaceApi {
|
||||
(BackendJobAttemptState::Failed, "input_rejected")
|
||||
}
|
||||
};
|
||||
self.store.finish_backend_job_attempt(
|
||||
&self.config.workspace_id,
|
||||
self.finish_backend_job_attempt(
|
||||
&request.job_id,
|
||||
&reservation.attempt.attempt_id,
|
||||
state,
|
||||
category,
|
||||
detail,
|
||||
&Utc::now().to_rfc3339_opts(SecondsFormat::Nanos, true),
|
||||
)?;
|
||||
return Err(Error::RuntimeOperationFailed {
|
||||
runtime_id: EMBEDDED_WORKER_RUNTIME_ID.to_string(),
|
||||
@@ -3547,14 +3540,12 @@ impl WorkspaceApi {
|
||||
});
|
||||
}
|
||||
if submitted.runtime_run_id.as_deref() != Some(runtime_run_id.as_str()) {
|
||||
self.store.finish_backend_job_attempt(
|
||||
&self.config.workspace_id,
|
||||
self.finish_backend_job_attempt(
|
||||
&request.job_id,
|
||||
&reservation.attempt.attempt_id,
|
||||
BackendJobAttemptState::Unknown,
|
||||
"runtime_run_mismatch",
|
||||
"embedded Runtime accepted Backend Job input without matching tracked run evidence",
|
||||
&Utc::now().to_rfc3339_opts(SecondsFormat::Nanos, true),
|
||||
)?;
|
||||
return Err(Error::RuntimeOperationFailed {
|
||||
runtime_id: EMBEDDED_WORKER_RUNTIME_ID.to_string(),
|
||||
@@ -3672,6 +3663,267 @@ impl WorkspaceApi {
|
||||
}
|
||||
}
|
||||
|
||||
fn finish_backend_job_attempt(
|
||||
&self,
|
||||
job_id: &str,
|
||||
attempt_id: &str,
|
||||
state: BackendJobAttemptState,
|
||||
failure_category: &str,
|
||||
failure_detail: &str,
|
||||
) -> Result<BackendJobAttemptRecord> {
|
||||
let attempt = self.store.finish_backend_job_attempt(
|
||||
&self.config.workspace_id,
|
||||
job_id,
|
||||
attempt_id,
|
||||
state,
|
||||
failure_category,
|
||||
failure_detail,
|
||||
&Utc::now().to_rfc3339_opts(SecondsFormat::Nanos, true),
|
||||
)?;
|
||||
let _ = self.schedule_backend_job_worker_cleanup(&attempt);
|
||||
Ok(attempt)
|
||||
}
|
||||
|
||||
fn schedule_backend_job_worker_cleanup(&self, attempt: &BackendJobAttemptRecord) -> bool {
|
||||
if !attempt.state.terminal()
|
||||
|| attempt.worker.is_none()
|
||||
|| !matches!(
|
||||
attempt.worker_cleanup_state,
|
||||
Some(BackendJobWorkerCleanupState::Pending | BackendJobWorkerCleanupState::Failed)
|
||||
)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
let Ok(handle) = tokio::runtime::Handle::try_current() else {
|
||||
tracing::warn!(
|
||||
job_id = %attempt.job_id,
|
||||
attempt_id = %attempt.attempt_id,
|
||||
"Backend Job Worker cleanup remains durable because no Tokio runtime is available"
|
||||
);
|
||||
return false;
|
||||
};
|
||||
let (attempt, claimed) = match self.store.claim_backend_job_worker_cleanup(
|
||||
&self.config.workspace_id,
|
||||
&attempt.job_id,
|
||||
&attempt.attempt_id,
|
||||
&Utc::now().to_rfc3339_opts(SecondsFormat::Nanos, true),
|
||||
) {
|
||||
Ok(claim) => claim,
|
||||
Err(error) => {
|
||||
tracing::warn!(
|
||||
%error,
|
||||
job_id = %attempt.job_id,
|
||||
attempt_id = %attempt.attempt_id,
|
||||
"failed to claim durable Backend Job Worker cleanup"
|
||||
);
|
||||
return false;
|
||||
}
|
||||
};
|
||||
if !claimed {
|
||||
return false;
|
||||
}
|
||||
let api = self.clone();
|
||||
let job_id = attempt.job_id.clone();
|
||||
let attempt_id = attempt.attempt_id.clone();
|
||||
handle.spawn_blocking(move || {
|
||||
let cleanup = || -> Result<()> {
|
||||
tokio::runtime::Builder::new_current_thread()
|
||||
.enable_all()
|
||||
.build()
|
||||
.map_err(|error| Error::Store(error.to_string()))?
|
||||
.block_on(async {
|
||||
// Never run stop/removal inline with structured result
|
||||
// acceptance. A separate scheduler thread lets the HTTP
|
||||
// response complete while canonical stop waits for the
|
||||
// Worker Run teardown.
|
||||
tokio::task::yield_now().await;
|
||||
api.cleanup_backend_job_attempt_worker(attempt).await
|
||||
})
|
||||
};
|
||||
if let Err(error) = cleanup() {
|
||||
tracing::warn!(%error, %job_id, %attempt_id, "Backend Job Worker cleanup failed");
|
||||
}
|
||||
});
|
||||
true
|
||||
}
|
||||
|
||||
fn recover_backend_job_worker_cleanups(&self) -> Result<()> {
|
||||
let page_limit = usize::from(ABSOLUTE_MAX_CONCURRENT_JOBS);
|
||||
let mut attempts = self.store.recover_backend_job_worker_cleanups(
|
||||
&self.config.workspace_id,
|
||||
&Utc::now().to_rfc3339_opts(SecondsFormat::Nanos, true),
|
||||
page_limit,
|
||||
)?;
|
||||
loop {
|
||||
if attempts.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
let scheduled = attempts
|
||||
.iter()
|
||||
.filter(|attempt| self.schedule_backend_job_worker_cleanup(attempt))
|
||||
.count();
|
||||
if attempts.len() < page_limit || scheduled == 0 {
|
||||
return Ok(());
|
||||
}
|
||||
attempts = self
|
||||
.store
|
||||
.list_backend_job_worker_cleanups(&self.config.workspace_id, page_limit)?;
|
||||
}
|
||||
}
|
||||
|
||||
async fn cleanup_backend_job_attempt_worker(
|
||||
&self,
|
||||
attempt: BackendJobAttemptRecord,
|
||||
) -> Result<()> {
|
||||
let job_id = attempt.job_id.as_str();
|
||||
let attempt_id = attempt.attempt_id.as_str();
|
||||
let worker = attempt.worker.clone().ok_or_else(|| {
|
||||
Error::Store("claimed Backend Job Worker cleanup lost its binding".to_string())
|
||||
})?;
|
||||
|
||||
let fail = |category: &str, detail: String| -> Result<()> {
|
||||
self.store.finish_backend_job_worker_cleanup(
|
||||
&self.config.workspace_id,
|
||||
job_id,
|
||||
attempt_id,
|
||||
BackendJobWorkerCleanupState::Failed,
|
||||
Some(category),
|
||||
Some(&detail),
|
||||
&Utc::now().to_rfc3339_opts(SecondsFormat::Nanos, true),
|
||||
)?;
|
||||
Err(Error::RuntimeOperationFailed {
|
||||
runtime_id: worker.runtime_id.clone(),
|
||||
code: category.to_string(),
|
||||
message: detail,
|
||||
})
|
||||
};
|
||||
let complete = || -> Result<()> {
|
||||
self.store.finish_backend_job_worker_cleanup(
|
||||
&self.config.workspace_id,
|
||||
job_id,
|
||||
attempt_id,
|
||||
BackendJobWorkerCleanupState::Completed,
|
||||
None,
|
||||
None,
|
||||
&Utc::now().to_rfc3339_opts(SecondsFormat::Nanos, true),
|
||||
)?;
|
||||
Ok(())
|
||||
};
|
||||
|
||||
let detail = match self.runtime.worker(&worker) {
|
||||
Ok(detail) => detail,
|
||||
Err(RuntimeRegistryError::UnknownWorker { .. }) => {
|
||||
if self
|
||||
.store
|
||||
.get_worker_registry(&self.config.workspace_id, &worker)?
|
||||
.is_none()
|
||||
{
|
||||
return complete();
|
||||
}
|
||||
// A canonical removal may have deleted the Runtime object before
|
||||
// its catalog commit. Let the existing durable removal operation
|
||||
// recover that midpoint; without such an operation this remains
|
||||
// a diagnosable retryable failure rather than a direct DB delete.
|
||||
let response = WorkerRemovalService::new(self)
|
||||
.execute_target_removal(
|
||||
self.runtime.as_ref(),
|
||||
&worker,
|
||||
&format!("Terminal Backend Job attempt {attempt_id}"),
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.map_err(|message| Error::RuntimeOperationFailed {
|
||||
runtime_id: worker.runtime_id.clone(),
|
||||
code: "backend_job_cleanup_removal_failed".to_string(),
|
||||
message,
|
||||
});
|
||||
return match response {
|
||||
Ok(response) if response.status == StatusCode::OK.as_u16() => complete(),
|
||||
Ok(response) => {
|
||||
fail("backend_job_cleanup_runtime_worker_missing", response.body)
|
||||
}
|
||||
Err(error) => fail("backend_job_cleanup_removal_failed", error.to_string()),
|
||||
};
|
||||
}
|
||||
Err(error) => {
|
||||
return fail("backend_job_cleanup_runtime_unavailable", error.message());
|
||||
}
|
||||
};
|
||||
if !detail.state.eq_ignore_ascii_case("stopped") {
|
||||
match self.runtime.stop_worker(
|
||||
&worker,
|
||||
WorkerLifecycleRequest {
|
||||
reason: Some(format!(
|
||||
"Backend Job attempt {attempt_id} reached terminal state"
|
||||
)),
|
||||
ticket_assignment: None,
|
||||
},
|
||||
) {
|
||||
Ok(result) if result.state == InternalWorkerOperationState::Accepted => {}
|
||||
Ok(result) => {
|
||||
return fail(
|
||||
"backend_job_cleanup_stop_rejected",
|
||||
runtime_diagnostics_message(&result.diagnostics),
|
||||
);
|
||||
}
|
||||
Err(error) => {
|
||||
return fail("backend_job_cleanup_stop_failed", error.message());
|
||||
}
|
||||
}
|
||||
}
|
||||
match self.runtime.worker(&worker) {
|
||||
Ok(detail) if detail.state.eq_ignore_ascii_case("stopped") => {}
|
||||
Ok(detail) => {
|
||||
return fail(
|
||||
"backend_job_cleanup_stop_unconfirmed",
|
||||
format!(
|
||||
"Runtime reported Worker state `{}` after accepting stop",
|
||||
detail.state
|
||||
),
|
||||
);
|
||||
}
|
||||
Err(error) => {
|
||||
return fail(
|
||||
"backend_job_cleanup_stop_confirmation_failed",
|
||||
error.message(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
let response = WorkerRemovalService::new(self)
|
||||
.execute_target_removal(
|
||||
self.runtime.as_ref(),
|
||||
&worker,
|
||||
&format!("Terminal Backend Job attempt {attempt_id}"),
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.map_err(|message| Error::RuntimeOperationFailed {
|
||||
runtime_id: worker.runtime_id.clone(),
|
||||
code: "backend_job_cleanup_removal_failed".to_string(),
|
||||
message,
|
||||
});
|
||||
let response = match response {
|
||||
Ok(response) => response,
|
||||
Err(error) => return fail("backend_job_cleanup_removal_failed", error.to_string()),
|
||||
};
|
||||
if response.status == StatusCode::OK.as_u16() {
|
||||
return complete();
|
||||
}
|
||||
let runtime_absent = matches!(
|
||||
self.runtime.worker(&worker),
|
||||
Err(RuntimeRegistryError::UnknownWorker { .. })
|
||||
);
|
||||
let catalog_absent = self
|
||||
.store
|
||||
.get_worker_registry(&self.config.workspace_id, &worker)?
|
||||
.is_none();
|
||||
if runtime_absent && catalog_absent {
|
||||
return complete();
|
||||
}
|
||||
fail("backend_job_cleanup_removal_rejected", response.body)
|
||||
}
|
||||
|
||||
/// Reconcile durable non-terminal attempts after Backend restart. Reserved
|
||||
/// attempts are replay-dispatched through their deterministic allocation;
|
||||
/// dispatched attempts keep running only while their bound Worker remains
|
||||
@@ -3701,26 +3953,22 @@ impl WorkspaceApi {
|
||||
}
|
||||
}
|
||||
BackendJobAttemptState::Dispatching => {
|
||||
self.store.finish_backend_job_attempt(
|
||||
&self.config.workspace_id,
|
||||
self.finish_backend_job_attempt(
|
||||
&attempt.job_id,
|
||||
&attempt.attempt_id,
|
||||
BackendJobAttemptState::Unknown,
|
||||
"recovery_dispatch_claim_unknown",
|
||||
"a Backend Job dispatch claim could not be safely replayed after restart",
|
||||
&Utc::now().to_rfc3339_opts(SecondsFormat::Nanos, true),
|
||||
)?;
|
||||
}
|
||||
BackendJobAttemptState::Dispatched => {
|
||||
if attempt.runtime_run_id.is_none() {
|
||||
self.store.finish_backend_job_attempt(
|
||||
&self.config.workspace_id,
|
||||
self.finish_backend_job_attempt(
|
||||
&attempt.job_id,
|
||||
&attempt.attempt_id,
|
||||
BackendJobAttemptState::Unknown,
|
||||
"recovery_runtime_run_unknown",
|
||||
"the Runtime accepted Worker dispatch without durable run evidence",
|
||||
&Utc::now().to_rfc3339_opts(SecondsFormat::Nanos, true),
|
||||
)?;
|
||||
continue;
|
||||
}
|
||||
@@ -3729,14 +3977,12 @@ impl WorkspaceApi {
|
||||
.as_ref()
|
||||
.is_some_and(|worker| self.runtime.worker(worker).is_ok());
|
||||
if !worker_known {
|
||||
self.store.finish_backend_job_attempt(
|
||||
&self.config.workspace_id,
|
||||
self.finish_backend_job_attempt(
|
||||
&attempt.job_id,
|
||||
&attempt.attempt_id,
|
||||
BackendJobAttemptState::Unknown,
|
||||
"recovery_worker_unknown",
|
||||
"the bound Runtime Worker was not recoverable after Backend restart",
|
||||
&Utc::now().to_rfc3339_opts(SecondsFormat::Nanos, true),
|
||||
)?;
|
||||
} else {
|
||||
self.schedule_backend_job_timeout(
|
||||
@@ -3751,6 +3997,7 @@ impl WorkspaceApi {
|
||||
| BackendJobAttemptState::Unknown => {}
|
||||
}
|
||||
}
|
||||
self.recover_backend_job_worker_cleanups()?;
|
||||
self.drain_reserved_backend_jobs();
|
||||
Ok(())
|
||||
}
|
||||
@@ -3766,15 +4013,13 @@ impl WorkspaceApi {
|
||||
return;
|
||||
};
|
||||
let api = self.clone();
|
||||
let store = api.store.clone();
|
||||
let runtime = api.runtime.clone();
|
||||
let workspace_id = api.config.workspace_id.clone();
|
||||
let job_id = job_id.to_string();
|
||||
let attempt_id = attempt_id.to_string();
|
||||
handle.spawn(async move {
|
||||
tokio::time::sleep(delay).await;
|
||||
let Ok(Some(attempt)) =
|
||||
store.get_backend_job_attempt(&workspace_id, &job_id, &attempt_id)
|
||||
api.store
|
||||
.get_backend_job_attempt(&api.config.workspace_id, &job_id, &attempt_id)
|
||||
else {
|
||||
return;
|
||||
};
|
||||
@@ -3783,27 +4028,16 @@ impl WorkspaceApi {
|
||||
}
|
||||
// Fence result acceptance before stopping the Worker so a late
|
||||
// completion cannot turn a timed-out attempt into success.
|
||||
if store
|
||||
if api
|
||||
.finish_backend_job_attempt(
|
||||
&workspace_id,
|
||||
&job_id,
|
||||
&attempt_id,
|
||||
BackendJobAttemptState::Failed,
|
||||
"timeout",
|
||||
"Backend Job attempt exceeded its durable deadline",
|
||||
&Utc::now().to_rfc3339_opts(SecondsFormat::Nanos, true),
|
||||
)
|
||||
.is_ok()
|
||||
{
|
||||
if let Some(worker) = attempt.worker {
|
||||
let _ = runtime.stop_worker(
|
||||
&worker,
|
||||
WorkerLifecycleRequest {
|
||||
reason: Some("Backend Job attempt timed out".to_string()),
|
||||
ticket_assignment: None,
|
||||
},
|
||||
);
|
||||
}
|
||||
api.drain_reserved_backend_jobs();
|
||||
}
|
||||
});
|
||||
@@ -3848,14 +4082,12 @@ impl WorkspaceApi {
|
||||
{
|
||||
let detail = error.to_string();
|
||||
if attempt.state == BackendJobAttemptState::Dispatched {
|
||||
self.store.finish_backend_job_attempt(
|
||||
&self.config.workspace_id,
|
||||
self.finish_backend_job_attempt(
|
||||
&submission.job_id,
|
||||
&submission.attempt_id,
|
||||
BackendJobAttemptState::Failed,
|
||||
"invalid_result",
|
||||
&detail,
|
||||
&accepted_at,
|
||||
)?;
|
||||
self.drain_reserved_backend_jobs();
|
||||
}
|
||||
@@ -3871,6 +4103,7 @@ impl WorkspaceApi {
|
||||
if let Some(target) = acceptance.job.request.notification_target.as_ref() {
|
||||
self.deliver_backend_job_result(&acceptance, target);
|
||||
}
|
||||
let _ = self.schedule_backend_job_worker_cleanup(&acceptance.attempt);
|
||||
self.drain_reserved_backend_jobs();
|
||||
Ok(acceptance)
|
||||
}
|
||||
@@ -41627,6 +41860,27 @@ mod tests {
|
||||
config
|
||||
}
|
||||
|
||||
fn seed_worker_session_for_cleanup(root: &Path, worker: &RuntimeWorkerRef) {
|
||||
let worker_root = root
|
||||
.join(".test-embedded-runtime-store/workers")
|
||||
.join(&worker.worker_id);
|
||||
fs::create_dir_all(worker_root.join("session/segments")).unwrap();
|
||||
fs::write(
|
||||
worker_root.join("session/session.json"),
|
||||
serde_json::to_vec_pretty(&json!({
|
||||
"schema_version": 1,
|
||||
"session_id": format!("backend-job-cleanup-{}", worker.worker_id)
|
||||
}))
|
||||
.unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
fs::write(
|
||||
worker_root.join("session/segments/segment-a.jsonl"),
|
||||
b"backend job retained session evidence\n",
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
fn spawn_ticket_check_source(api: &WorkspaceApi, name: &str) -> RuntimeWorkerRef {
|
||||
api.spawn_workspace_worker(
|
||||
EMBEDDED_WORKER_RUNTIME_ID,
|
||||
@@ -41711,6 +41965,30 @@ mod tests {
|
||||
panic!("Ticket item checker attempt remained reserved")
|
||||
}
|
||||
|
||||
async fn wait_for_backend_job_worker_cleanup(
|
||||
api: &WorkspaceApi,
|
||||
job_id: &str,
|
||||
attempt_id: &str,
|
||||
expected: BackendJobWorkerCleanupState,
|
||||
) -> crate::backend_job::BackendJobAttemptRecord {
|
||||
let mut last = None;
|
||||
for _ in 0..500 {
|
||||
let attempt = api
|
||||
.store
|
||||
.get_backend_job_attempt(TEST_WORKSPACE_ID, job_id, attempt_id)
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
if attempt.worker_cleanup_state == Some(expected) {
|
||||
return attempt;
|
||||
}
|
||||
last = Some(attempt);
|
||||
tokio::time::sleep(std::time::Duration::from_millis(10)).await;
|
||||
}
|
||||
panic!(
|
||||
"Backend Job Worker cleanup for {job_id}/{attempt_id} did not reach {expected:?}: {last:?}"
|
||||
)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn worker_ticket_create_returns_before_checker_and_delivers_grounded_advisory() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
@@ -42472,6 +42750,286 @@ mod tests {
|
||||
assert_eq!(accepted.job.state, BackendJobState::Completed);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn terminal_backend_job_cleanup_removes_only_bound_worker_and_retains_result_delivery() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let (api, execution) = test_api_with_recording_backend(temp.path()).await;
|
||||
let notification_target = spawn_ticket_check_source(&api, "job-cleanup-target");
|
||||
let request = BackendJobRequest {
|
||||
job_id: "runner-cleanup-success-1".to_string(),
|
||||
purpose: "cleanup_contract_check".to_string(),
|
||||
input_revision: "revision-1".to_string(),
|
||||
input_ref: "test://runner/cleanup/1".to_string(),
|
||||
input: serde_json::json!({"value": "immutable"}),
|
||||
instruction: "Return a structured check result.".to_string(),
|
||||
profile: "builtin:backend-job".to_string(),
|
||||
source_worker: Some(notification_target.clone()),
|
||||
notification_target: Some(notification_target.clone()),
|
||||
limits: crate::backend_job::BackendJobLimits::default(),
|
||||
};
|
||||
let dispatched = api.dispatch_backend_job(&request).unwrap();
|
||||
execution.take_inputs();
|
||||
execution.reject_inputs("notification unavailable");
|
||||
let worker = dispatched.attempt.worker.clone().unwrap();
|
||||
seed_worker_session_for_cleanup(temp.path(), &worker);
|
||||
let mut subscriber = api.worker_projection.subscribe_ordered(10).unwrap();
|
||||
let _ = subscriber.take_snapshot();
|
||||
let submission = BackendJobResultSubmission {
|
||||
job_id: request.job_id.clone(),
|
||||
attempt_id: dispatched.attempt.attempt_id.clone(),
|
||||
input_revision: request.input_revision.clone(),
|
||||
result: serde_json::json!({"valid": true}),
|
||||
};
|
||||
|
||||
let accepted = api.accept_backend_job_result(&worker, &submission).unwrap();
|
||||
assert_eq!(accepted.job.state, BackendJobState::Completed);
|
||||
let cleaned = wait_for_backend_job_worker_cleanup(
|
||||
&api,
|
||||
&request.job_id,
|
||||
&dispatched.attempt.attempt_id,
|
||||
BackendJobWorkerCleanupState::Completed,
|
||||
)
|
||||
.await;
|
||||
assert_eq!(cleaned.result, Some(serde_json::json!({"valid": true})));
|
||||
assert!(matches!(
|
||||
api.runtime.worker(&worker),
|
||||
Err(RuntimeRegistryError::UnknownWorker { .. })
|
||||
));
|
||||
assert!(
|
||||
!temp
|
||||
.path()
|
||||
.join(".test-embedded-runtime-store/workers")
|
||||
.join(&worker.worker_id)
|
||||
.exists(),
|
||||
"Runtime Worker and Session storage must be removed"
|
||||
);
|
||||
assert!(
|
||||
api.store
|
||||
.get_worker_registry(TEST_WORKSPACE_ID, &worker)
|
||||
.unwrap()
|
||||
.is_none(),
|
||||
"canonical removal must delete the Backend catalog projection"
|
||||
);
|
||||
tokio::time::timeout(std::time::Duration::from_secs(1), async {
|
||||
loop {
|
||||
let event = subscriber.recv().await.expect("projection event");
|
||||
if event.changes.iter().any(|change| {
|
||||
matches!(change, crate::store::WorkerCatalogChange::Removed(removed) if removed == &worker)
|
||||
}) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
})
|
||||
.await
|
||||
.expect("Backend Job cleanup must publish canonical Worker removal");
|
||||
assert!(
|
||||
api.runtime.worker(¬ification_target).is_ok(),
|
||||
"origin/notification Workers are not cleanup targets"
|
||||
);
|
||||
assert!(
|
||||
api.store
|
||||
.get_worker_registry(TEST_WORKSPACE_ID, ¬ification_target)
|
||||
.unwrap()
|
||||
.is_some()
|
||||
);
|
||||
let replay = api.accept_backend_job_result(&worker, &submission).unwrap();
|
||||
assert!(
|
||||
replay.replayed,
|
||||
"duplicate result remains idempotent after cleanup"
|
||||
);
|
||||
let delivery_id =
|
||||
crate::backend_job::delivery_id(&request.job_id, &dispatched.attempt.attempt_id);
|
||||
let (delivery, claimed) = api
|
||||
.store
|
||||
.reserve_backend_job_delivery(
|
||||
TEST_WORKSPACE_ID,
|
||||
&delivery_id,
|
||||
&request.job_id,
|
||||
¬ification_target,
|
||||
&Utc::now().to_rfc3339_opts(SecondsFormat::Nanos, true),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(!claimed);
|
||||
assert_eq!(delivery.state, BackendJobDeliveryState::Unknown);
|
||||
assert_eq!(
|
||||
delivery.failure_category.as_deref(),
|
||||
Some("notification_outcome_unknown")
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn backend_job_cleanup_guard_failure_retries_old_attempt_without_touching_new_worker() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let (api, _execution) = test_api_with_recording_backend(temp.path()).await;
|
||||
let request = BackendJobRequest {
|
||||
job_id: "runner-cleanup-retry-1".to_string(),
|
||||
purpose: "cleanup_retry_contract_check".to_string(),
|
||||
input_revision: "revision-1".to_string(),
|
||||
input_ref: "test://runner/cleanup-retry/1".to_string(),
|
||||
input: serde_json::json!({"value": "immutable"}),
|
||||
instruction: "Return a structured check result.".to_string(),
|
||||
profile: "builtin:backend-job".to_string(),
|
||||
source_worker: None,
|
||||
notification_target: None,
|
||||
limits: crate::backend_job::BackendJobLimits::default(),
|
||||
};
|
||||
let first = api.dispatch_backend_job(&request).unwrap();
|
||||
let first_worker = first.attempt.worker.clone().unwrap();
|
||||
seed_worker_session_for_cleanup(temp.path(), &first_worker);
|
||||
let _ = set_worker_retention(
|
||||
api.clone(),
|
||||
first_worker.runtime_id.clone(),
|
||||
first_worker.worker_id.clone(),
|
||||
true,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
api.finish_backend_job_attempt(
|
||||
&request.job_id,
|
||||
&first.attempt.attempt_id,
|
||||
BackendJobAttemptState::Failed,
|
||||
"model_failure",
|
||||
"model rejected the request",
|
||||
)
|
||||
.unwrap();
|
||||
let blocked = wait_for_backend_job_worker_cleanup(
|
||||
&api,
|
||||
&request.job_id,
|
||||
&first.attempt.attempt_id,
|
||||
BackendJobWorkerCleanupState::Failed,
|
||||
)
|
||||
.await;
|
||||
assert_eq!(blocked.failure_category.as_deref(), Some("model_failure"));
|
||||
assert_eq!(
|
||||
blocked.worker_cleanup_failure_category.as_deref(),
|
||||
Some("backend_job_cleanup_removal_rejected")
|
||||
);
|
||||
|
||||
let second = api.retry_backend_job(&request.job_id).unwrap();
|
||||
let second_worker = second.attempt.worker.clone().unwrap();
|
||||
seed_worker_session_for_cleanup(temp.path(), &second_worker);
|
||||
assert_ne!(first_worker, second_worker);
|
||||
let _ = set_worker_retention(
|
||||
api.clone(),
|
||||
first_worker.runtime_id.clone(),
|
||||
first_worker.worker_id.clone(),
|
||||
false,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
api.recover_backend_jobs().unwrap();
|
||||
wait_for_backend_job_worker_cleanup(
|
||||
&api,
|
||||
&request.job_id,
|
||||
&first.attempt.attempt_id,
|
||||
BackendJobWorkerCleanupState::Completed,
|
||||
)
|
||||
.await;
|
||||
assert!(api.runtime.worker(&second_worker).is_ok());
|
||||
|
||||
api.accept_backend_job_result(
|
||||
&second_worker,
|
||||
&BackendJobResultSubmission {
|
||||
job_id: request.job_id.clone(),
|
||||
attempt_id: second.attempt.attempt_id.clone(),
|
||||
input_revision: request.input_revision.clone(),
|
||||
result: serde_json::json!({"retried": true}),
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
wait_for_backend_job_worker_cleanup(
|
||||
&api,
|
||||
&request.job_id,
|
||||
&second.attempt.attempt_id,
|
||||
BackendJobWorkerCleanupState::Completed,
|
||||
)
|
||||
.await;
|
||||
assert_eq!(
|
||||
api.store
|
||||
.get_backend_job_attempt(
|
||||
TEST_WORKSPACE_ID,
|
||||
&request.job_id,
|
||||
&first.attempt.attempt_id,
|
||||
)
|
||||
.unwrap()
|
||||
.unwrap()
|
||||
.failure_detail
|
||||
.as_deref(),
|
||||
Some("model rejected the request")
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn backend_job_cleanup_restart_recovery_converges_interrupted_and_removed_workers() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let (api, _execution) = test_api_with_recording_backend(temp.path()).await;
|
||||
let request = BackendJobRequest {
|
||||
job_id: "runner-cleanup-restart-1".to_string(),
|
||||
purpose: "cleanup_restart_contract_check".to_string(),
|
||||
input_revision: "revision-1".to_string(),
|
||||
input_ref: "test://runner/cleanup-restart/1".to_string(),
|
||||
input: serde_json::json!({"value": "immutable"}),
|
||||
instruction: "Return a structured check result.".to_string(),
|
||||
profile: "builtin:backend-job".to_string(),
|
||||
source_worker: None,
|
||||
notification_target: None,
|
||||
limits: crate::backend_job::BackendJobLimits::default(),
|
||||
};
|
||||
let dispatched = api.dispatch_backend_job(&request).unwrap();
|
||||
let worker = dispatched.attempt.worker.clone().unwrap();
|
||||
seed_worker_session_for_cleanup(temp.path(), &worker);
|
||||
api.store
|
||||
.accept_backend_job_result(
|
||||
TEST_WORKSPACE_ID,
|
||||
&worker,
|
||||
&BackendJobResultSubmission {
|
||||
job_id: request.job_id.clone(),
|
||||
attempt_id: dispatched.attempt.attempt_id.clone(),
|
||||
input_revision: request.input_revision.clone(),
|
||||
result: serde_json::json!({"persisted": true}),
|
||||
},
|
||||
&Utc::now().to_rfc3339_opts(SecondsFormat::Nanos, true),
|
||||
)
|
||||
.unwrap();
|
||||
let (claimed, did_claim) = api
|
||||
.store
|
||||
.claim_backend_job_worker_cleanup(
|
||||
TEST_WORKSPACE_ID,
|
||||
&request.job_id,
|
||||
&dispatched.attempt.attempt_id,
|
||||
&Utc::now().to_rfc3339_opts(SecondsFormat::Nanos, true),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(did_claim);
|
||||
assert_eq!(
|
||||
claimed.worker_cleanup_state,
|
||||
Some(BackendJobWorkerCleanupState::Executing)
|
||||
);
|
||||
|
||||
api.recover_backend_jobs().unwrap();
|
||||
let cleaned = wait_for_backend_job_worker_cleanup(
|
||||
&api,
|
||||
&request.job_id,
|
||||
&dispatched.attempt.attempt_id,
|
||||
BackendJobWorkerCleanupState::Completed,
|
||||
)
|
||||
.await;
|
||||
assert_eq!(cleaned.result, Some(serde_json::json!({"persisted": true})));
|
||||
api.recover_backend_jobs().unwrap();
|
||||
assert_eq!(
|
||||
api.store
|
||||
.get_backend_job_attempt(
|
||||
TEST_WORKSPACE_ID,
|
||||
&request.job_id,
|
||||
&dispatched.attempt.attempt_id,
|
||||
)
|
||||
.unwrap()
|
||||
.unwrap()
|
||||
.worker_cleanup_state,
|
||||
Some(BackendJobWorkerCleanupState::Completed)
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn backend_job_fast_result_is_accepted_after_prebound_tracked_run() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
@@ -42492,9 +43050,11 @@ mod tests {
|
||||
let hook_api = api.clone();
|
||||
let hook_request = request.clone();
|
||||
let hook_observed = acceptance_observed.clone();
|
||||
let hook_root = temp.path().to_path_buf();
|
||||
execution.before_input_returns(move |worker| {
|
||||
let source =
|
||||
RuntimeWorkerRef::new(EMBEDDED_WORKER_RUNTIME_ID, worker.worker_id.to_string());
|
||||
seed_worker_session_for_cleanup(&hook_root, &source);
|
||||
let accepted = hook_api
|
||||
.accept_backend_job_result(
|
||||
&source,
|
||||
@@ -42518,6 +43078,18 @@ mod tests {
|
||||
dispatched.attempt.runtime_run_id.as_deref(),
|
||||
Some(expected_run_id.as_str())
|
||||
);
|
||||
let worker = dispatched.attempt.worker.clone().unwrap();
|
||||
wait_for_backend_job_worker_cleanup(
|
||||
&api,
|
||||
&request.job_id,
|
||||
&dispatched.attempt.attempt_id,
|
||||
BackendJobWorkerCleanupState::Completed,
|
||||
)
|
||||
.await;
|
||||
assert!(matches!(
|
||||
api.runtime.worker(&worker),
|
||||
Err(RuntimeRegistryError::UnknownWorker { .. })
|
||||
));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -42675,6 +43247,12 @@ mod tests {
|
||||
async fn backend_job_input_error_is_durable_unknown_and_not_automatically_retried() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let (api, execution) = test_api_with_recording_backend(temp.path()).await;
|
||||
let cleanup_root = temp.path().to_path_buf();
|
||||
execution.before_input_returns(move |worker| {
|
||||
let worker =
|
||||
RuntimeWorkerRef::new(EMBEDDED_WORKER_RUNTIME_ID, worker.worker_id.to_string());
|
||||
seed_worker_session_for_cleanup(&cleanup_root, &worker);
|
||||
});
|
||||
execution.reject_inputs("model transport unavailable");
|
||||
let request = BackendJobRequest {
|
||||
job_id: "runner-input-failure-1".to_string(),
|
||||
@@ -42704,6 +43282,18 @@ mod tests {
|
||||
failed.failure_category.as_deref(),
|
||||
Some("input_outcome_unknown")
|
||||
);
|
||||
let worker = failed.worker.clone().unwrap();
|
||||
wait_for_backend_job_worker_cleanup(
|
||||
&api,
|
||||
&request.job_id,
|
||||
&failed.attempt_id,
|
||||
BackendJobWorkerCleanupState::Completed,
|
||||
)
|
||||
.await;
|
||||
assert!(matches!(
|
||||
api.runtime.worker(&worker),
|
||||
Err(RuntimeRegistryError::UnknownWorker { .. })
|
||||
));
|
||||
assert!(
|
||||
api.retry_backend_job(&request.job_id).is_err(),
|
||||
"an ambiguous input outcome must not be retried automatically"
|
||||
@@ -42924,6 +43514,7 @@ mod tests {
|
||||
};
|
||||
let dispatched = api.dispatch_backend_job(&request).unwrap();
|
||||
let worker = dispatched.attempt.worker.clone().unwrap();
|
||||
seed_worker_session_for_cleanup(temp.path(), &worker);
|
||||
tokio::time::sleep(std::time::Duration::from_millis(1_200)).await;
|
||||
let attempt = api
|
||||
.store
|
||||
@@ -42936,6 +43527,17 @@ mod tests {
|
||||
.unwrap();
|
||||
assert_eq!(attempt.state, BackendJobAttemptState::Failed);
|
||||
assert_eq!(attempt.failure_category.as_deref(), Some("timeout"));
|
||||
wait_for_backend_job_worker_cleanup(
|
||||
&api,
|
||||
&request.job_id,
|
||||
&dispatched.attempt.attempt_id,
|
||||
BackendJobWorkerCleanupState::Completed,
|
||||
)
|
||||
.await;
|
||||
assert!(matches!(
|
||||
api.runtime.worker(&worker),
|
||||
Err(RuntimeRegistryError::UnknownWorker { .. })
|
||||
));
|
||||
assert!(
|
||||
api.accept_backend_job_result(
|
||||
&worker,
|
||||
|
||||
@@ -21,13 +21,14 @@ use crate::backend_job::{
|
||||
BackendJobAttemptRecord, BackendJobAttemptState, BackendJobDeliveryRecord,
|
||||
BackendJobDeliveryState, BackendJobRecord, BackendJobRequest, BackendJobReservation,
|
||||
BackendJobResultAcceptance, BackendJobResultSubmission, BackendJobState,
|
||||
BackendJobWorkerBinding, attempt_id, bounded_failure_detail, result_digest,
|
||||
BackendJobWorkerBinding, BackendJobWorkerCleanupState, attempt_id, bounded_failure_detail,
|
||||
result_digest,
|
||||
};
|
||||
use crate::workspace_deletion::WorkspaceDeletionStore;
|
||||
use crate::{Error, Result};
|
||||
|
||||
const OLDEST_SCHEMA_VERSION: i64 = 50;
|
||||
const LATEST_SCHEMA_VERSION: i64 = 76;
|
||||
const LATEST_SCHEMA_VERSION: i64 = 77;
|
||||
const SCHEMA_BASELINE_NAME: &str = "workspace schema baseline";
|
||||
const WORKSPACE_RUNTIME_BINDINGS_MIGRATION_NAME: &str = "workspace runtime bindings";
|
||||
const RUNTIME_BINDING_AUDIT_MIGRATION_NAME: &str = "workspace Runtime binding revision and audit";
|
||||
@@ -74,6 +75,8 @@ const RUNTIME_SCOPED_WORKER_IDENTITY_MIGRATION_NAME: &str =
|
||||
"Runtime-scoped durable Worker catalog identity";
|
||||
const ARCHIVE_OBSERVE_GRANTS_MIGRATION_NAME: &str =
|
||||
"preserve explicit observe grants for committed Worker Session archives";
|
||||
const BACKEND_JOB_WORKER_CLEANUP_MIGRATION_NAME: &str =
|
||||
"durable terminal Backend Job Worker cleanup";
|
||||
const TICKET_SCHEMA_VERSION_WITH_TARGETS: i64 = 7;
|
||||
const TICKET_SCHEMA_BASELINE_NAME: &str = "ticket schema baseline";
|
||||
const WORKER_REGISTRY_PROJECTION_SCHEMA: &str = r#"
|
||||
@@ -251,6 +254,11 @@ const MIGRATIONS: &[Migration] = &[
|
||||
name: ARCHIVE_OBSERVE_GRANTS_MIGRATION_NAME,
|
||||
apply: migrate_archive_observe_grants_v75_to_v76,
|
||||
},
|
||||
Migration {
|
||||
version: 77,
|
||||
name: BACKEND_JOB_WORKER_CLEANUP_MIGRATION_NAME,
|
||||
apply: migrate_backend_job_worker_cleanup_v76_to_v77,
|
||||
},
|
||||
];
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
@@ -1592,6 +1600,34 @@ pub trait ControlPlaneStore: Send + Sync + WorkspaceDeletionStore {
|
||||
failure_detail: &str,
|
||||
now: &str,
|
||||
) -> Result<BackendJobAttemptRecord>;
|
||||
fn recover_backend_job_worker_cleanups(
|
||||
&self,
|
||||
workspace_id: &str,
|
||||
now: &str,
|
||||
limit: usize,
|
||||
) -> Result<Vec<BackendJobAttemptRecord>>;
|
||||
fn list_backend_job_worker_cleanups(
|
||||
&self,
|
||||
workspace_id: &str,
|
||||
limit: usize,
|
||||
) -> Result<Vec<BackendJobAttemptRecord>>;
|
||||
fn claim_backend_job_worker_cleanup(
|
||||
&self,
|
||||
workspace_id: &str,
|
||||
job_id: &str,
|
||||
attempt_id: &str,
|
||||
now: &str,
|
||||
) -> Result<(BackendJobAttemptRecord, bool)>;
|
||||
fn finish_backend_job_worker_cleanup(
|
||||
&self,
|
||||
workspace_id: &str,
|
||||
job_id: &str,
|
||||
attempt_id: &str,
|
||||
state: BackendJobWorkerCleanupState,
|
||||
failure_category: Option<&str>,
|
||||
failure_detail: Option<&str>,
|
||||
now: &str,
|
||||
) -> Result<BackendJobAttemptRecord>;
|
||||
fn reserve_backend_job_delivery(
|
||||
&self,
|
||||
workspace_id: &str,
|
||||
@@ -6648,7 +6684,7 @@ impl ControlPlaneStore for SqliteWorkspaceStore {
|
||||
));
|
||||
}
|
||||
let attempt_changed = tx.execute(
|
||||
"UPDATE backend_job_attempts SET state = 'completed', result_json = ?4, result_digest = ?5, failure_category = NULL, failure_detail = NULL, completed_at = ?6, updated_at = ?6 WHERE workspace_id = ?1 AND job_id = ?2 AND attempt_id = ?3 AND state = 'dispatched'",
|
||||
"UPDATE backend_job_attempts SET state = 'completed', result_json = ?4, result_digest = ?5, failure_category = NULL, failure_detail = NULL, worker_cleanup_state = 'pending', worker_cleanup_failure_category = NULL, worker_cleanup_failure_detail = NULL, worker_cleanup_updated_at = ?6, worker_cleanup_completed_at = NULL, completed_at = ?6, updated_at = ?6 WHERE workspace_id = ?1 AND job_id = ?2 AND attempt_id = ?3 AND state = 'dispatched'",
|
||||
params![workspace_id, submission.job_id, submission.attempt_id, result_json, digest, now],
|
||||
)?;
|
||||
let job_changed = tx.execute(
|
||||
@@ -6707,7 +6743,7 @@ impl ControlPlaneStore for SqliteWorkspaceStore {
|
||||
return Ok(attempt);
|
||||
}
|
||||
tx.execute(
|
||||
"UPDATE backend_job_attempts SET state = ?4, failure_category = ?5, failure_detail = ?6, completed_at = ?7, updated_at = ?7 WHERE workspace_id = ?1 AND job_id = ?2 AND attempt_id = ?3 AND state IN ('reserved', 'dispatching', 'dispatched')",
|
||||
"UPDATE backend_job_attempts SET state = ?4, failure_category = ?5, failure_detail = ?6, worker_cleanup_state = CASE WHEN worker_id IS NULL THEN 'completed' ELSE 'pending' END, worker_cleanup_failure_category = NULL, worker_cleanup_failure_detail = NULL, worker_cleanup_updated_at = ?7, worker_cleanup_completed_at = CASE WHEN worker_id IS NULL THEN ?7 ELSE NULL END, completed_at = ?7, updated_at = ?7 WHERE workspace_id = ?1 AND job_id = ?2 AND attempt_id = ?3 AND state IN ('reserved', 'dispatching', 'dispatched')",
|
||||
params![workspace_id, job_id, attempt_id, state.as_str(), failure_category, detail, now],
|
||||
)?;
|
||||
if attempt.attempt == job.current_attempt && job.state == BackendJobState::Pending {
|
||||
@@ -6728,6 +6764,154 @@ impl ControlPlaneStore for SqliteWorkspaceStore {
|
||||
})
|
||||
}
|
||||
|
||||
fn recover_backend_job_worker_cleanups(
|
||||
&self,
|
||||
workspace_id: &str,
|
||||
now: &str,
|
||||
limit: usize,
|
||||
) -> Result<Vec<BackendJobAttemptRecord>> {
|
||||
self.with_conn_mut(|conn| {
|
||||
let tx = conn.transaction_with_behavior(TransactionBehavior::Immediate)?;
|
||||
tx.execute(
|
||||
"UPDATE backend_job_attempts SET worker_cleanup_state = 'pending', worker_cleanup_failure_category = 'cleanup_interrupted', worker_cleanup_failure_detail = 'Backend restart interrupted Worker cleanup; retrying canonical removal', worker_cleanup_updated_at = ?2 WHERE workspace_id = ?1 AND worker_cleanup_state = 'executing'",
|
||||
params![workspace_id, now],
|
||||
)?;
|
||||
let mut statement = tx.prepare(
|
||||
"SELECT job_id, attempt_id FROM backend_job_attempts WHERE workspace_id = ?1 AND state IN ('completed', 'failed', 'unknown') AND runtime_id IS NOT NULL AND worker_cleanup_state IN ('pending', 'failed') ORDER BY worker_cleanup_updated_at, created_at LIMIT ?2",
|
||||
)?;
|
||||
let identities = statement
|
||||
.query_map(params![workspace_id, limit as i64], |row| {
|
||||
Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
|
||||
})?
|
||||
.collect::<rusqlite::Result<Vec<_>>>()?;
|
||||
drop(statement);
|
||||
let attempts = identities
|
||||
.into_iter()
|
||||
.map(|(job_id, attempt_id)| {
|
||||
read_backend_job_attempt(&tx, workspace_id, &job_id, &attempt_id)?.ok_or_else(
|
||||
|| Error::Store("recoverable Backend Job Worker cleanup disappeared".to_string()),
|
||||
)
|
||||
})
|
||||
.collect::<Result<Vec<_>>>()?;
|
||||
tx.commit()?;
|
||||
Ok(attempts)
|
||||
})
|
||||
}
|
||||
|
||||
fn list_backend_job_worker_cleanups(
|
||||
&self,
|
||||
workspace_id: &str,
|
||||
limit: usize,
|
||||
) -> Result<Vec<BackendJobAttemptRecord>> {
|
||||
self.with_conn(|conn| {
|
||||
let mut statement = conn.prepare(
|
||||
"SELECT job_id, attempt_id FROM backend_job_attempts WHERE workspace_id = ?1 AND state IN ('completed', 'failed', 'unknown') AND runtime_id IS NOT NULL AND worker_cleanup_state IN ('pending', 'failed') ORDER BY worker_cleanup_updated_at, created_at LIMIT ?2",
|
||||
)?;
|
||||
let identities = statement
|
||||
.query_map(params![workspace_id, limit as i64], |row| {
|
||||
Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
|
||||
})?
|
||||
.collect::<rusqlite::Result<Vec<_>>>()?;
|
||||
identities
|
||||
.into_iter()
|
||||
.map(|(job_id, attempt_id)| {
|
||||
read_backend_job_attempt(conn, workspace_id, &job_id, &attempt_id)?.ok_or_else(
|
||||
|| Error::Store("recoverable Backend Job Worker cleanup disappeared".to_string()),
|
||||
)
|
||||
})
|
||||
.collect()
|
||||
})
|
||||
}
|
||||
|
||||
fn claim_backend_job_worker_cleanup(
|
||||
&self,
|
||||
workspace_id: &str,
|
||||
job_id: &str,
|
||||
attempt_id: &str,
|
||||
now: &str,
|
||||
) -> Result<(BackendJobAttemptRecord, bool)> {
|
||||
self.with_conn_mut(|conn| {
|
||||
let tx = conn.transaction_with_behavior(TransactionBehavior::Immediate)?;
|
||||
let attempt = read_backend_job_attempt(&tx, workspace_id, job_id, attempt_id)?
|
||||
.ok_or_else(|| Error::InvalidInput(format!("unknown Backend Job attempt `{attempt_id}`")))?;
|
||||
if !attempt.state.terminal() || attempt.worker.is_none() {
|
||||
return Err(Error::InvalidInput(
|
||||
"Backend Job Worker cleanup requires a terminal bound attempt".to_string(),
|
||||
));
|
||||
}
|
||||
if !matches!(
|
||||
attempt.worker_cleanup_state,
|
||||
Some(BackendJobWorkerCleanupState::Pending | BackendJobWorkerCleanupState::Failed)
|
||||
) {
|
||||
tx.commit()?;
|
||||
return Ok((attempt, false));
|
||||
}
|
||||
let changed = tx.execute(
|
||||
"UPDATE backend_job_attempts SET worker_cleanup_state = 'executing', worker_cleanup_failure_category = NULL, worker_cleanup_failure_detail = NULL, worker_cleanup_updated_at = ?4 WHERE workspace_id = ?1 AND job_id = ?2 AND attempt_id = ?3 AND worker_cleanup_state IN ('pending', 'failed')",
|
||||
params![workspace_id, job_id, attempt_id, now],
|
||||
)?;
|
||||
if changed != 1 {
|
||||
return Err(Error::Store(
|
||||
"Backend Job Worker cleanup claim compare-and-set failed".to_string(),
|
||||
));
|
||||
}
|
||||
let attempt = read_backend_job_attempt(&tx, workspace_id, job_id, attempt_id)?
|
||||
.ok_or_else(|| Error::Store("claimed Backend Job Worker cleanup disappeared".to_string()))?;
|
||||
tx.commit()?;
|
||||
Ok((attempt, true))
|
||||
})
|
||||
}
|
||||
|
||||
fn finish_backend_job_worker_cleanup(
|
||||
&self,
|
||||
workspace_id: &str,
|
||||
job_id: &str,
|
||||
attempt_id: &str,
|
||||
state: BackendJobWorkerCleanupState,
|
||||
failure_category: Option<&str>,
|
||||
failure_detail: Option<&str>,
|
||||
now: &str,
|
||||
) -> Result<BackendJobAttemptRecord> {
|
||||
if !matches!(
|
||||
state,
|
||||
BackendJobWorkerCleanupState::Completed | BackendJobWorkerCleanupState::Failed
|
||||
) {
|
||||
return Err(Error::InvalidInput(
|
||||
"Backend Job Worker cleanup completion requires completed or failed state"
|
||||
.to_string(),
|
||||
));
|
||||
}
|
||||
let failure_detail = failure_detail.map(bounded_failure_detail);
|
||||
self.with_conn_mut(|conn| {
|
||||
let tx = conn.transaction_with_behavior(TransactionBehavior::Immediate)?;
|
||||
let current = read_backend_job_attempt(&tx, workspace_id, job_id, attempt_id)?
|
||||
.ok_or_else(|| Error::InvalidInput(format!("unknown Backend Job attempt `{attempt_id}`")))?;
|
||||
if current.worker_cleanup_state == Some(state) {
|
||||
tx.commit()?;
|
||||
return Ok(current);
|
||||
}
|
||||
if current.worker_cleanup_state != Some(BackendJobWorkerCleanupState::Executing) {
|
||||
return Err(Error::InvalidInput(
|
||||
"Backend Job Worker cleanup is not executing".to_string(),
|
||||
));
|
||||
}
|
||||
let completed_at = (state == BackendJobWorkerCleanupState::Completed).then_some(now);
|
||||
let changed = tx.execute(
|
||||
"UPDATE backend_job_attempts SET worker_cleanup_state = ?4, worker_cleanup_failure_category = ?5, worker_cleanup_failure_detail = ?6, worker_cleanup_updated_at = ?7, worker_cleanup_completed_at = ?8 WHERE workspace_id = ?1 AND job_id = ?2 AND attempt_id = ?3 AND worker_cleanup_state = 'executing'",
|
||||
params![workspace_id, job_id, attempt_id, state.as_str(), failure_category, failure_detail, now, completed_at],
|
||||
)?;
|
||||
if changed != 1 {
|
||||
return Err(Error::Store(
|
||||
"Backend Job Worker cleanup completion compare-and-set failed".to_string(),
|
||||
));
|
||||
}
|
||||
let attempt = read_backend_job_attempt(&tx, workspace_id, job_id, attempt_id)?
|
||||
.ok_or_else(|| Error::Store("finished Backend Job Worker cleanup disappeared".to_string()))?;
|
||||
tx.commit()?;
|
||||
Ok(attempt)
|
||||
})
|
||||
}
|
||||
|
||||
fn reserve_backend_job_delivery(
|
||||
&self,
|
||||
workspace_id: &str,
|
||||
@@ -10457,14 +10641,16 @@ fn read_backend_job_attempt(
|
||||
attempt_id: &str,
|
||||
) -> Result<Option<BackendJobAttemptRecord>> {
|
||||
let row = conn.query_row(
|
||||
"SELECT attempt, input_revision, state, runtime_id, worker_id, runtime_run_id, dispatched_at, deadline_at, result_json, result_digest, failure_category, failure_detail, created_at, updated_at, completed_at FROM backend_job_attempts WHERE workspace_id = ?1 AND job_id = ?2 AND attempt_id = ?3",
|
||||
"SELECT attempt, input_revision, state, runtime_id, worker_id, runtime_run_id, dispatched_at, deadline_at, result_json, result_digest, failure_category, failure_detail, worker_cleanup_state, worker_cleanup_failure_category, worker_cleanup_failure_detail, worker_cleanup_updated_at, worker_cleanup_completed_at, created_at, updated_at, completed_at FROM backend_job_attempts WHERE workspace_id = ?1 AND job_id = ?2 AND attempt_id = ?3",
|
||||
params![workspace_id, job_id, attempt_id],
|
||||
|row| Ok((
|
||||
row.get::<_, i64>(0)?, row.get::<_, String>(1)?, row.get::<_, String>(2)?,
|
||||
row.get::<_, Option<String>>(3)?, row.get::<_, Option<String>>(4)?, row.get::<_, Option<String>>(5)?,
|
||||
row.get::<_, Option<String>>(6)?, row.get::<_, String>(7)?, row.get::<_, Option<String>>(8)?,
|
||||
row.get::<_, Option<String>>(9)?, row.get::<_, Option<String>>(10)?, row.get::<_, Option<String>>(11)?,
|
||||
row.get::<_, String>(12)?, row.get::<_, String>(13)?, row.get::<_, Option<String>>(14)?,
|
||||
row.get::<_, Option<String>>(12)?, row.get::<_, Option<String>>(13)?, row.get::<_, Option<String>>(14)?,
|
||||
row.get::<_, Option<String>>(15)?, row.get::<_, Option<String>>(16)?, row.get::<_, String>(17)?,
|
||||
row.get::<_, String>(18)?, row.get::<_, Option<String>>(19)?,
|
||||
)),
|
||||
).optional()?;
|
||||
let Some((
|
||||
@@ -10480,6 +10666,11 @@ fn read_backend_job_attempt(
|
||||
result_digest,
|
||||
failure_category,
|
||||
failure_detail,
|
||||
worker_cleanup_state,
|
||||
worker_cleanup_failure_category,
|
||||
worker_cleanup_failure_detail,
|
||||
worker_cleanup_updated_at,
|
||||
worker_cleanup_completed_at,
|
||||
created_at,
|
||||
updated_at,
|
||||
completed_at,
|
||||
@@ -10521,6 +10712,14 @@ fn read_backend_job_attempt(
|
||||
result_digest,
|
||||
failure_category,
|
||||
failure_detail,
|
||||
worker_cleanup_state: worker_cleanup_state
|
||||
.as_deref()
|
||||
.map(BackendJobWorkerCleanupState::parse)
|
||||
.transpose()?,
|
||||
worker_cleanup_failure_category,
|
||||
worker_cleanup_failure_detail,
|
||||
worker_cleanup_updated_at,
|
||||
worker_cleanup_completed_at,
|
||||
created_at,
|
||||
updated_at,
|
||||
completed_at,
|
||||
@@ -14248,6 +14447,45 @@ fn migrate_archive_observe_grants_v75_to_v76(conn: &Connection) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn migrate_backend_job_worker_cleanup_v76_to_v77(conn: &Connection) -> Result<()> {
|
||||
let current = current_schema_version(conn)?;
|
||||
if current != 76 {
|
||||
return Err(Error::Store(format!(
|
||||
"expected schema version 76 before {BACKEND_JOB_WORKER_CLEANUP_MIGRATION_NAME} migration, found {current}"
|
||||
)));
|
||||
}
|
||||
let tx = rusqlite::Transaction::new_unchecked(conn, TransactionBehavior::Exclusive)?;
|
||||
tx.execute_batch(
|
||||
r#"
|
||||
ALTER TABLE backend_job_attempts ADD COLUMN worker_cleanup_state TEXT
|
||||
CHECK (worker_cleanup_state IN ('pending', 'executing', 'completed', 'failed'));
|
||||
ALTER TABLE backend_job_attempts ADD COLUMN worker_cleanup_failure_category TEXT;
|
||||
ALTER TABLE backend_job_attempts ADD COLUMN worker_cleanup_failure_detail TEXT;
|
||||
ALTER TABLE backend_job_attempts ADD COLUMN worker_cleanup_updated_at TEXT;
|
||||
ALTER TABLE backend_job_attempts ADD COLUMN worker_cleanup_completed_at TEXT;
|
||||
UPDATE backend_job_attempts
|
||||
SET worker_cleanup_state = CASE
|
||||
WHEN runtime_id IS NULL THEN 'completed'
|
||||
ELSE 'pending'
|
||||
END,
|
||||
worker_cleanup_updated_at = updated_at,
|
||||
worker_cleanup_completed_at = CASE
|
||||
WHEN runtime_id IS NULL THEN updated_at
|
||||
ELSE NULL
|
||||
END
|
||||
WHERE state IN ('completed', 'failed', 'unknown');
|
||||
CREATE INDEX backend_job_attempts_worker_cleanup
|
||||
ON backend_job_attempts(workspace_id, worker_cleanup_state, updated_at);
|
||||
"#,
|
||||
)?;
|
||||
tx.execute(
|
||||
"INSERT INTO __yoi_schema_migrations (version, name) VALUES (?1, ?2)",
|
||||
params![77_i64, BACKEND_JOB_WORKER_CLEANUP_MIGRATION_NAME],
|
||||
)?;
|
||||
tx.commit()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn create_latest_workspace_schema(conn: &Connection) -> Result<()> {
|
||||
conn.execute_batch("DROP TABLE IF EXISTS typed_ticket_targets;")?;
|
||||
conn.execute_batch(include_str!("latest_schema.sql"))?;
|
||||
@@ -16810,6 +17048,10 @@ mod tests {
|
||||
version: 76,
|
||||
name: ARCHIVE_OBSERVE_GRANTS_MIGRATION_NAME.to_string(),
|
||||
},
|
||||
WorkspaceSchemaMigrationStep {
|
||||
version: 77,
|
||||
name: BACKEND_JOB_WORKER_CLEANUP_MIGRATION_NAME.to_string(),
|
||||
},
|
||||
]
|
||||
);
|
||||
|
||||
@@ -16893,6 +17135,10 @@ mod tests {
|
||||
RUNTIME_SCOPED_WORKER_IDENTITY_MIGRATION_NAME.to_string(),
|
||||
),
|
||||
(76, ARCHIVE_OBSERVE_GRANTS_MIGRATION_NAME.to_string()),
|
||||
(
|
||||
77,
|
||||
BACKEND_JOB_WORKER_CLEANUP_MIGRATION_NAME.to_string(),
|
||||
),
|
||||
]
|
||||
);
|
||||
assert!(!table_exists(conn, "trusted_runtime_records")?);
|
||||
@@ -17160,6 +17406,71 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn backend_job_worker_cleanup_migration_queues_legacy_terminal_workers() {
|
||||
let conn = Connection::open_in_memory().unwrap();
|
||||
conn.execute_batch(
|
||||
r#"
|
||||
CREATE TABLE __yoi_schema_migrations (
|
||||
version INTEGER PRIMARY KEY,
|
||||
name TEXT NOT NULL
|
||||
);
|
||||
INSERT INTO __yoi_schema_migrations(version, name)
|
||||
VALUES (76, 'workspace schema baseline');
|
||||
CREATE TABLE backend_job_attempts (
|
||||
workspace_id TEXT NOT NULL,
|
||||
job_id TEXT NOT NULL,
|
||||
attempt_id TEXT NOT NULL,
|
||||
state TEXT NOT NULL,
|
||||
runtime_id TEXT,
|
||||
worker_id TEXT,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
INSERT INTO backend_job_attempts VALUES
|
||||
('w', 'terminal-bound', 'a1', 'completed', 'runtime', 'worker', '1', '2'),
|
||||
('w', 'terminal-unbound', 'a2', 'failed', NULL, NULL, '1', '3'),
|
||||
('w', 'running', 'a3', 'dispatched', 'runtime', 'running-worker', '1', '4');
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
migrate_backend_job_worker_cleanup_v76_to_v77(&conn).unwrap();
|
||||
|
||||
let states = conn
|
||||
.prepare(
|
||||
"SELECT job_id, worker_cleanup_state, worker_cleanup_completed_at FROM backend_job_attempts ORDER BY job_id",
|
||||
)
|
||||
.unwrap()
|
||||
.query_map([], |row| {
|
||||
Ok((
|
||||
row.get::<_, String>(0)?,
|
||||
row.get::<_, Option<String>>(1)?,
|
||||
row.get::<_, Option<String>>(2)?,
|
||||
))
|
||||
})
|
||||
.unwrap()
|
||||
.collect::<rusqlite::Result<Vec<_>>>()
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
states,
|
||||
vec![
|
||||
("running".to_string(), None, None,),
|
||||
(
|
||||
"terminal-bound".to_string(),
|
||||
Some("pending".to_string()),
|
||||
None,
|
||||
),
|
||||
(
|
||||
"terminal-unbound".to_string(),
|
||||
Some("completed".to_string()),
|
||||
Some("3".to_string()),
|
||||
),
|
||||
]
|
||||
);
|
||||
assert_eq!(current_schema_version(&conn).unwrap(), 77);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn migration_resumes_from_a_valid_partially_applied_chain() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
@@ -17179,7 +17490,7 @@ mod tests {
|
||||
.collect::<Vec<_>>(),
|
||||
vec![
|
||||
52, 53, 54, 55, 56, 57, 58, 59, 60, 61, 62, 63, 64, 65, 66, 67, 68, 69, 70, 71, 72,
|
||||
73, 74, 75, 76
|
||||
73, 74, 75, 76, 77
|
||||
]
|
||||
);
|
||||
SqliteWorkspaceStore::migrate_database(&path).unwrap();
|
||||
@@ -17188,7 +17499,7 @@ mod tests {
|
||||
current_schema_version(&conn).unwrap(),
|
||||
LATEST_SCHEMA_VERSION
|
||||
);
|
||||
assert_eq!(workspace_schema_migration_history(&conn).unwrap().len(), 27);
|
||||
assert_eq!(workspace_schema_migration_history(&conn).unwrap().len(), 28);
|
||||
assert!(column_exists(&conn, "worker_workdir_links", "alias").unwrap());
|
||||
assert!(column_exists(&conn, "worker_workdir_links", "capabilities").unwrap());
|
||||
assert!(!column_exists(&conn, "worker_workdir_links", "role").unwrap());
|
||||
|
||||
Reference in New Issue
Block a user