fix(openapi): harden story 2.1 production lifecycle
CI / Rust Checks (push) Failing after 4m6s
CI / UI Checks (push) Has been skipped
CI / Frontend E2E (push) Has been skipped
CI / Community Image Smoke (push) Has been skipped
CI / Deploy (push) Has been skipped

This commit is contained in:
2026-08-29 00:48:22 +03:00
parent 2c94af6791
commit bc03c33387
46 changed files with 2198 additions and 276 deletions
@@ -3,7 +3,7 @@ use crate::{ArtifactSourceId, ImportJobSourceEnvelope};
use crank_artifacts::ArtifactRef;
const APPLICATION_RESULT_KEY: &str = "_crank_application_result";
const IMPORT_JOB_CLEANUP_BATCH: i64 = 128;
const IMPORT_JOB_CLEANUP_BATCH: u32 = 128;
const DANGLING_OPENAPI_SOURCE_GRACE: time::Duration = time::Duration::minutes(5);
impl PostgresRegistry {
@@ -143,7 +143,16 @@ impl PostgresRegistry {
}
}
pub async fn delete_expired_import_jobs(&self) -> Result<u64, RegistryError> {
/// Runs a single bounded cleanup pass suitable for a periodic worker.
///
/// A corrupt historic payload is not trusted for source detachment, but it
/// must not pin every later cleanup pass: the expired job is deleted and
/// its source is subsequently eligible for the bounded dangling sweep.
pub async fn cleanup_expired_import_jobs(
&self,
limit: u32,
) -> Result<ImportJobCleanupReport, RegistryError> {
let limit = i64::from(limit.clamp(1, IMPORT_JOB_CLEANUP_BATCH));
let mut transaction = self.pool.begin().await?;
let now = sqlx::query_scalar::<_, OffsetDateTime>("select now()")
.fetch_one(&mut *transaction)
@@ -156,22 +165,28 @@ impl PostgresRegistry {
limit $1
for update skip locked",
)
.bind(IMPORT_JOB_CLEANUP_BATCH)
.bind(limit)
.fetch_all(&mut *transaction)
.await?;
let mut expired_ids = Vec::with_capacity(expired.len());
let mut detached_sources = 0;
for row in &expired {
expired_ids.push(row.try_get::<String, _>("id")?);
let workspace_id = WorkspaceId::new(row.try_get::<String, _>("workspace_id")?);
let payload = row.try_get::<Value, _>("preview_payload")?;
if let Some(source) = source_from_payload(&payload)? {
detach_source_in_transaction(
// The job itself is expired regardless of whether a legacy or
// corrupt payload can be decoded. Do not let one bad row roll
// back cleanup for every tenant.
if let Ok(Some(source)) = source_from_payload(&payload)
&& detach_source_in_transaction(
&mut transaction,
&workspace_id,
&source.source_id,
now,
)
.await?;
.await?
{
detached_sources += 1;
}
}
let deleted = if expired_ids.is_empty() {
@@ -206,17 +221,42 @@ impl PostgresRegistry {
for update of s skip locked",
)
.bind(dangling_cutoff)
.bind(IMPORT_JOB_CLEANUP_BATCH)
.bind(limit)
.fetch_all(&mut *transaction)
.await?;
let has_more_dangling = dangling.len() == limit as usize;
for row in dangling {
let workspace_id = WorkspaceId::new(row.try_get::<String, _>("workspace_id")?);
let source_id = ArtifactSourceId::new(row.try_get::<String, _>("source_id")?);
detach_source_in_transaction(&mut transaction, &workspace_id, &source_id, now).await?;
if detach_source_in_transaction(&mut transaction, &workspace_id, &source_id, now)
.await?
{
detached_sources += 1;
}
}
transaction.commit().await?;
Ok(deleted)
Ok(ImportJobCleanupReport {
deleted_jobs: deleted,
detached_sources,
more_work: expired.len() == limit as usize || has_more_dangling,
})
}
/// Exhausts currently visible cleanup work for startup and request paths.
/// Periodic workers should use [`Self::cleanup_expired_import_jobs`] to
/// keep each tick bounded.
pub async fn delete_expired_import_jobs(&self) -> Result<u64, RegistryError> {
let mut deleted_jobs = 0;
loop {
let report = self
.cleanup_expired_import_jobs(IMPORT_JOB_CLEANUP_BATCH)
.await?;
deleted_jobs += report.deleted_jobs;
if !report.more_work {
return Ok(deleted_jobs);
}
}
}
}
@@ -286,6 +326,7 @@ async fn apply_import_job_transaction(
let mut result = ImportJobApplyResult {
application_key: request.application_key.to_owned(),
skipped: request.pre_skipped.to_vec(),
..ImportJobApplyResult::default()
};
for draft in request.operations {
@@ -360,7 +401,7 @@ async fn apply_import_job_transaction(
.execute(&mut **tx)
.await?;
detach_source_in_transaction(
let _ = detach_source_in_transaction(
tx,
request.workspace_id,
&source.source_id,
@@ -430,8 +471,8 @@ async fn detach_source_in_transaction(
workspace_id: &WorkspaceId,
source_id: &ArtifactSourceId,
detached_at: OffsetDateTime,
) -> Result<(), RegistryError> {
sqlx::query(
) -> Result<bool, RegistryError> {
let detached = sqlx::query(
"update artifact_sources
set lifecycle = 'detached', updated_at = $1, detached_at = $1
where workspace_id = $2 and source_id = $3
@@ -441,8 +482,9 @@ async fn detach_source_in_transaction(
.bind(workspace_id.as_str())
.bind(source_id.as_str())
.execute(&mut **transaction)
.await?;
Ok(())
.await?
.rows_affected();
Ok(detached == 1)
}
fn stored_application_result(