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
+174 -13
View File
@@ -1,5 +1,6 @@
use std::{
path::PathBuf,
sync::Arc,
time::{Duration, SystemTime},
};
@@ -7,7 +8,7 @@ use async_trait::async_trait;
use crank_artifacts::{
ArtifactError, ArtifactStore, ReconciliationCursor, ReconciliationMutation,
ReconciliationNamespace, ReconciliationPresence, ReconciliationRegistration,
ReconciliationScanStop,
ReconciliationScanStop, TempScanCursor,
};
use crank_registry::{
ArtifactClaimFinalization, ArtifactClaimFinalizeOutcome, ArtifactClaimOutcome,
@@ -25,6 +26,11 @@ pub const RECONCILIATION_LEASE: Duration = Duration::from_secs(5 * 60);
pub const RECONCILIATION_TRAVERSAL_LIMIT: usize = 4_096;
pub const RECONCILIATION_CANDIDATE_LIMIT: usize = 32;
pub const RECONCILIATION_MUTATION_LIMIT: usize = 32;
pub const RECONCILIATION_TICK_DEADLINE: Duration = Duration::from_secs(30);
pub const TEMP_CLEANUP_GRACE: Duration = Duration::from_secs(60 * 60);
pub const TEMP_CLEANUP_SCAN_LIMIT: usize = 512;
pub const TEMP_CLEANUP_DELETE_LIMIT: usize = 16;
pub const IMPORT_JOB_CLEANUP_LIMIT: u32 = 128;
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
enum Phase {
@@ -191,6 +197,8 @@ pub enum ReconciliationCoordinatorError {
Backend,
#[error("artifact reconciliation backend exceeded its bounded contract")]
Bounds,
#[error("artifact reconciliation backend exceeded its deadline")]
Deadline,
}
#[async_trait]
@@ -215,7 +223,7 @@ struct ReconciliationCoordinator<B: ReconciliationBackend> {
#[derive(Clone)]
struct PostgresReconciliationBackend {
registry: PostgresRegistry,
store: ArtifactStore,
store: Arc<ArtifactStore>,
}
enum PostgresReconciliationCursor {
@@ -230,7 +238,7 @@ impl std::fmt::Debug for PostgresReconciliationCursor {
}
impl PostgresReconciliationBackend {
fn new(registry: PostgresRegistry, store: ArtifactStore) -> Self {
fn new(registry: PostgresRegistry, store: Arc<ArtifactStore>) -> Self {
Self { registry, store }
}
@@ -245,7 +253,7 @@ impl PostgresReconciliationBackend {
&self,
artifact_ref: crank_artifacts::ArtifactRef,
) -> Result<ReconciliationPresence, ReconciliationCoordinatorError> {
let store = self.store.clone();
let store = Arc::clone(&self.store);
let result =
tokio::task::spawn_blocking(move || store.reconciliation_presence(&artifact_ref))
.await
@@ -518,7 +526,7 @@ impl ReconciliationBackend for PostgresReconciliationBackend {
Phase::Recovery => ReconciliationNamespace::Quarantine,
Phase::Sweep => ReconciliationNamespace::Final,
};
let store = self.store.clone();
let store = Arc::clone(&self.store);
let (mut scan, candidates) = tokio::task::spawn_blocking(move || {
store.scan_reconciliation_namespace(
namespace,
@@ -560,7 +568,7 @@ impl ReconciliationBackend for PostgresReconciliationBackend {
Phase::Recovery => Duration::ZERO,
Phase::Sweep => RECONCILIATION_GRACE,
};
let store = self.store.clone();
let store = Arc::clone(&self.store);
let registration = tokio::task::spawn_blocking(move || {
let registration = store.register_reconciliation(
&candidate,
@@ -621,7 +629,7 @@ impl ReconciliationBackend for PostgresReconciliationBackend {
step.mutation_attempts += 1;
step.physical_mutation_possible = true;
let store = self.store.clone();
let store = Arc::clone(&self.store);
let mutation = match tokio::task::spawn_blocking(move || match phase {
Phase::Recovery => store.delete_quarantined_reconciliation(candidate),
Phase::Sweep => store.quarantine_reconciliation(candidate),
@@ -717,8 +725,8 @@ impl ReconciliationBackend for PostgresReconciliationBackend {
/// async executor.
pub async fn open_reconciliation_store(
root: PathBuf,
) -> Result<ArtifactStore, ReconciliationCoordinatorError> {
tokio::task::spawn_blocking(move || ArtifactStore::open(root))
) -> Result<Arc<ArtifactStore>, ReconciliationCoordinatorError> {
tokio::task::spawn_blocking(move || ArtifactStore::open(root).map(Arc::new))
.await
.map_err(|_| ReconciliationCoordinatorError::Backend)?
.map_err(|_| ReconciliationCoordinatorError::Backend)
@@ -726,10 +734,25 @@ pub async fn open_reconciliation_store(
/// Runs the immediate bounded startup cycle, then schedules 15-minute ticks.
/// Recovery remains ahead of the final sweep even when it spans several ticks.
pub async fn spawn_artifact_reconciliation(registry: PostgresRegistry, store: ArtifactStore) {
let backend = PostgresReconciliationBackend::new(registry, store);
pub async fn spawn_artifact_reconciliation(registry: PostgresRegistry, store: Arc<ArtifactStore>) {
let cleanup_registry = registry.clone();
let backend = PostgresReconciliationBackend::new(registry, Arc::clone(&store));
let mut coordinator = ReconciliationCoordinator::new(backend);
observe_tick(coordinator.tick().await);
let mut temp_cursor = None;
let startup_maintenance = async {
observe_import_job_cleanup(&cleanup_registry).await;
observe_tick(run_bounded_reconciliation_tick(&mut coordinator).await);
temp_cursor = observe_stale_temp_cleanup(Arc::clone(&store), None).await;
};
if tokio::time::timeout(RECONCILIATION_TICK_DEADLINE, startup_maintenance)
.await
.is_err()
{
warn!(
name: "admin.artifact_maintenance.startup_deadline",
"startup maintenance exceeded its shared deadline; remaining work will resume in background"
);
}
tokio::spawn(async move {
let mut interval = tokio::time::interval(RECONCILIATION_INTERVAL);
@@ -738,11 +761,148 @@ pub async fn spawn_artifact_reconciliation(registry: PostgresRegistry, store: Ar
interval.tick().await;
loop {
interval.tick().await;
observe_tick(coordinator.tick().await);
observe_import_job_cleanup(&cleanup_registry).await;
observe_tick(run_bounded_reconciliation_tick(&mut coordinator).await);
temp_cursor = observe_stale_temp_cleanup(Arc::clone(&store), temp_cursor).await;
}
});
}
async fn observe_import_job_cleanup(registry: &PostgresRegistry) {
let cleanup = async {
let mut deleted_jobs = 0_u64;
let mut detached_sources = 0_u64;
loop {
let report = registry
.cleanup_expired_import_jobs(IMPORT_JOB_CLEANUP_LIMIT)
.await?;
deleted_jobs = deleted_jobs.saturating_add(report.deleted_jobs);
detached_sources = detached_sources.saturating_add(report.detached_sources);
if !report.more_work {
return Ok::<_, crank_registry::RegistryError>((deleted_jobs, detached_sources));
}
// Each pass commits independently. Yield before requesting the
// next lock batch so normal import traffic can make progress.
tokio::task::yield_now().await;
}
};
match tokio::time::timeout(RECONCILIATION_TICK_DEADLINE, cleanup).await {
Ok(Ok((deleted_jobs, detached_sources))) => info!(
name: "admin.openapi_import_cleanup.tick",
deleted_jobs,
detached_sources,
more_work = false,
"OpenAPI import cleanup tick completed"
),
Ok(Err(_)) => warn!(
name: "admin.openapi_import_cleanup.failed",
error_category = "registry",
"OpenAPI import cleanup tick will be retried"
),
Err(_) => warn!(
name: "admin.openapi_import_cleanup.failed",
error_category = "deadline",
"OpenAPI import cleanup tick exceeded its deadline"
),
}
}
async fn run_bounded_reconciliation_tick<B: ReconciliationBackend>(
coordinator: &mut ReconciliationCoordinator<B>,
) -> Result<ReconciliationTickReport, ReconciliationCoordinatorError> {
tokio::time::timeout(RECONCILIATION_TICK_DEADLINE, coordinator.tick())
.await
.map_err(|_| ReconciliationCoordinatorError::Deadline)?
}
async fn observe_stale_temp_cleanup(
store: Arc<ArtifactStore>,
cursor: Option<TempScanCursor>,
) -> Option<TempScanCursor> {
let progress = Arc::new(std::sync::Mutex::new(cursor));
let cleanup_progress = Arc::clone(&progress);
let cleanup = async move {
let mut scanned = 0_usize;
let mut omitted = 0_usize;
let mut deleted = 0_usize;
let mut retryable = 0_usize;
loop {
let scanner = Arc::clone(&store);
let cursor = cleanup_progress
.lock()
.map_err(|_| ArtifactError::Storage)?
.clone();
let (scan, candidates, next_cursor) = tokio::task::spawn_blocking(move || {
scanner.scan_stale_temps_after(
TEMP_CLEANUP_GRACE,
cursor,
TEMP_CLEANUP_SCAN_LIMIT,
TEMP_CLEANUP_DELETE_LIMIT,
)
})
.await
.map_err(|_| ArtifactError::Storage)??;
scanned = scanned.saturating_add(scan.scanned);
omitted = omitted.saturating_add(scan.omitted);
*cleanup_progress
.lock()
.map_err(|_| ArtifactError::Storage)? = Some(next_cursor);
for candidate in candidates {
let store = Arc::clone(&store);
match tokio::task::spawn_blocking(move || store.delete_stale_temp(candidate)).await
{
Ok(Ok(())) => deleted += 1,
Ok(Err(_)) | Err(_) => retryable += 1,
}
}
if scan.complete {
return Ok::<_, ArtifactError>((
scanned,
omitted,
deleted,
retryable,
cleanup_progress
.lock()
.map_err(|_| ArtifactError::Storage)?
.clone(),
));
}
tokio::task::yield_now().await;
}
};
match tokio::time::timeout(RECONCILIATION_TICK_DEADLINE, cleanup).await {
Ok(Ok((scanned, omitted, deleted, retryable, next_cursor))) => {
info!(
name: "admin.artifact_temp_cleanup.tick",
scanned,
omitted,
deleted,
retryable,
"artifact temporary-file cleanup tick completed"
);
next_cursor
}
Ok(Err(_)) => {
warn!(
name: "admin.artifact_temp_cleanup.failed",
error_category = "backend",
"artifact temporary-file cleanup tick will be retried"
);
progress.lock().ok().and_then(|cursor| cursor.clone())
}
Err(_) => {
warn!(
name: "admin.artifact_temp_cleanup.failed",
error_category = "deadline",
"artifact temporary-file cleanup tick exceeded its deadline"
);
progress.lock().ok().and_then(|cursor| cursor.clone())
}
}
}
fn observe_tick(result: Result<ReconciliationTickReport, ReconciliationCoordinatorError>) {
match result {
Ok(report) => info!(
@@ -772,6 +932,7 @@ fn observe_tick(result: Result<ReconciliationTickReport, ReconciliationCoordinat
error_category = match error {
ReconciliationCoordinatorError::Backend => "backend",
ReconciliationCoordinatorError::Bounds => "bounds",
ReconciliationCoordinatorError::Deadline => "deadline",
},
"artifact reconciliation tick will be retried"
),