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
+26 -26
View File
@@ -20,20 +20,20 @@ pub mod records {
ArtifactReconciliationClaim, ArtifactSourceCursor, ArtifactSourceId,
ArtifactSourceLifecycle, ArtifactSourcePage, ArtifactSourceRecord,
ArtifactSourceSensitivity, AuthUserRecord, DescriptorKind, DescriptorMetadata, ImportJob,
ImportJobApplyResult, ImportJobId, ImportJobKind, ImportJobSourceEnvelope, ImportJobStatus,
InvitationRecord, InvocationHistoryLoss, InvocationHistoryLossCategory,
InvocationHistoryWriteOutcome, InvocationLogRecord, InvocationRetentionOutcome,
InvocationRetentionPolicy, InvocationRetentionStatus, MasterKeyIdentityRecord,
MasterKeyRotationRecord, MasterKeyRotationStatus, MembershipRecord,
OnboardingMilestoneResult, OnboardingPresentationMilestone, OperationAgentRef,
OperationSampleMetadata, OperationSummary, OperationUsageSummary, OperationVersionRecord,
Page, PlatformApiKeyRecord, ProductEventRecord, PublishedAgentCatalog, PublishedAgentTool,
RegistryOperation, SampleKind, SecretRecord, SecretVersionRecord, SessionRecord,
SkippedImportOperation, UsageAgentBreakdown, UsageBucket, UsageOperationBreakdown,
UsageOutcomeBreakdown, UsageOutcomeGroup, UsageRollupRecord, UsageSummary,
UsageTimelinePoint, VerifiedArtifactSource, WorkspaceMembershipRecord, WorkspaceRecord,
WorkspaceUpstream, WorkspaceUpstreamId, YamlImportJob, YamlImportJobCompletion,
YamlImportJobId, YamlImportJobStatus,
ImportJobApplyResult, ImportJobCleanupReport, ImportJobId, ImportJobKind,
ImportJobSourceEnvelope, ImportJobStatus, InvitationRecord, InvocationHistoryLoss,
InvocationHistoryLossCategory, InvocationHistoryWriteOutcome, InvocationLogRecord,
InvocationRetentionOutcome, InvocationRetentionPolicy, InvocationRetentionStatus,
MasterKeyIdentityRecord, MasterKeyRotationRecord, MasterKeyRotationStatus,
MembershipRecord, OnboardingMilestoneResult, OnboardingPresentationMilestone,
OperationAgentRef, OperationSampleMetadata, OperationSummary, OperationUsageSummary,
OperationVersionRecord, Page, PlatformApiKeyRecord, ProductEventRecord,
PublishedAgentCatalog, PublishedAgentTool, RegistryOperation, SampleKind, SecretRecord,
SecretVersionRecord, SessionRecord, SkippedImportOperation, UsageAgentBreakdown,
UsageBucket, UsageOperationBreakdown, UsageOutcomeBreakdown, UsageOutcomeGroup,
UsageRollupRecord, UsageSummary, UsageTimelinePoint, VerifiedArtifactSource,
WorkspaceMembershipRecord, WorkspaceRecord, WorkspaceUpstream, WorkspaceUpstreamId,
YamlImportJob, YamlImportJobCompletion, YamlImportJobId, YamlImportJobStatus,
};
}
@@ -83,18 +83,18 @@ pub use model::{
CreateVersionRequest, CreateWorkspaceRequest, CreateYamlImportJobRequest,
DecideApprovalRequest, DescriptorKind, DescriptorMetadata, DetachArtifactSourceRequest,
ExpireApprovalRequest, FinishApprovalRequest, FinishImportJobRequest, ImportConflictMode,
ImportJob, ImportJobApplyResult, ImportJobId, ImportJobKind, ImportJobSourceEnvelope,
ImportJobStatus, ImportOperationDraft, InvitationRecord, InvocationHistoryLoss,
InvocationHistoryLossCategory, InvocationHistoryWriteOutcome, InvocationLogRecord,
InvocationRetentionOutcome, InvocationRetentionPolicy, InvocationRetentionStatus,
ListApprovalRequestsQuery, ListArtifactSourcesQuery, ListInvocationLogsQuery,
ListProductEventsQuery, MASTER_KEY_CIPHER_CONTRACT, MAX_ARTIFACT_CLAIM_RECOVERY_BATCH,
MAX_ARTIFACT_SOURCE_PAGE_SIZE, MasterKeyIdentityCandidate, MasterKeyIdentityRecord,
MasterKeyRotationRecord, MasterKeyRotationStatus, MembershipRecord, OnboardingMilestoneResult,
OnboardingPresentationMilestone, OperationAgentRef, OperationSampleMetadata,
OperationStateExpectation, OperationSummary, OperationUsageSummary, OperationVersionRecord,
Page, PlatformApiKeyRecord, ProductEventRecord, PublishAgentRequest, PublishRequest,
PublishedAgentCatalog, PublishedAgentTool, RecordOnboardingCompletionRequest,
ImportJob, ImportJobApplyResult, ImportJobCleanupReport, ImportJobId, ImportJobKind,
ImportJobSourceEnvelope, ImportJobStatus, ImportOperationDraft, InvitationRecord,
InvocationHistoryLoss, InvocationHistoryLossCategory, InvocationHistoryWriteOutcome,
InvocationLogRecord, InvocationRetentionOutcome, InvocationRetentionPolicy,
InvocationRetentionStatus, ListApprovalRequestsQuery, ListArtifactSourcesQuery,
ListInvocationLogsQuery, ListProductEventsQuery, MASTER_KEY_CIPHER_CONTRACT,
MAX_ARTIFACT_CLAIM_RECOVERY_BATCH, MAX_ARTIFACT_SOURCE_PAGE_SIZE, MasterKeyIdentityCandidate,
MasterKeyIdentityRecord, MasterKeyRotationRecord, MasterKeyRotationStatus, MembershipRecord,
OnboardingMilestoneResult, OnboardingPresentationMilestone, OperationAgentRef,
OperationSampleMetadata, OperationStateExpectation, OperationSummary, OperationUsageSummary,
OperationVersionRecord, Page, PlatformApiKeyRecord, ProductEventRecord, PublishAgentRequest,
PublishRequest, PublishedAgentCatalog, PublishedAgentTool, RecordOnboardingCompletionRequest,
RecordOnboardingMilestoneRequest, RecoverAdminPasswordRequest, RegistryOperation,
RotateSecretRequest, SampleKind, SaveAgentBindingsRequest, SaveAgentCatalogConfigRequest,
SaveAuthProfileRequest, SaveDescriptorMetadataRequest, SaveSampleMetadataRequest,
+2
View File
@@ -1,6 +1,7 @@
mod admin_auth_lifecycle_v8;
mod agent_catalog_lifecycle_v9;
mod approval_side_effects_v10;
mod artifact_cleanup_indexes_v13;
mod artifact_metadata_v12;
mod authority;
mod baseline_v1;
@@ -13,6 +14,7 @@ mod schema_guard;
mod schema_guard_v10;
mod schema_guard_v11;
mod schema_guard_v12;
mod schema_guard_v13;
mod schema_guard_v7;
mod schema_guard_v8;
mod schema_guard_v9;
@@ -0,0 +1,44 @@
use sqlx::{Postgres, Transaction, query};
use super::authority::{MigrationDescriptor, MigrationError};
pub(super) const SOURCE: &str = include_str!("artifact_cleanup_indexes_v13.sql");
pub(super) const SOURCE_SHA256: &str =
"abe822c967e1b2cc3966ede988e05eb2d9b2a062b539c45aaa8cad677395ab05";
pub(super) async fn apply(
transaction: &mut Transaction<'_, Postgres>,
descriptor: &MigrationDescriptor,
) -> Result<(), MigrationError> {
sqlx::raw_sql(SOURCE)
.execute(&mut **transaction)
.await
.map_err(|_| {
MigrationError::new(
"apply_failed",
"apply.artifact_cleanup_indexes",
Some(13),
"restore_known_good_backup",
)
})?;
query(
"insert into __crank_migrations (version, name, checksum, phase, compatibility)
values ($1, $2, $3, $4, $5)",
)
.bind(descriptor.version)
.bind(descriptor.name)
.bind(&descriptor.checksum)
.bind(descriptor.phase)
.bind(descriptor.compatibility)
.execute(&mut **transaction)
.await
.map_err(|_| {
MigrationError::new(
"apply_failed",
"apply.canonical_ledger",
Some(13),
"restore_known_good_backup",
)
})?;
Ok(())
}
@@ -0,0 +1,19 @@
-- Expand-only performance indexes for bounded artifact and import cleanup.
-- No data rewrite or destructive schema operation is required.
create index artifact_blobs_expired_claim_idx
on artifact_blobs(claim_expires_at, digest)
where claim_token is not null;
create index artifact_sources_blob_lifecycle_detached_idx
on artifact_sources(blob_digest, lifecycle, detached_at);
create index artifact_sources_openapi_dangling_idx
on artifact_sources(created_at, workspace_id, source_id)
where lifecycle = 'active' and left(source_id, 12) = 'src_openapi_';
create index import_jobs_expires_at_idx
on import_jobs(expires_at, id);
create index import_jobs_openapi_source_idx
on import_jobs(workspace_id, ((preview_payload -> 'source' ->> 'source_id')))
where preview_payload ? 'source';
@@ -1,6 +1,7 @@
use super::admin_auth_lifecycle_v8;
use super::agent_catalog_lifecycle_v9;
use super::approval_side_effects_v10;
use super::artifact_cleanup_indexes_v13;
use super::artifact_metadata_v12;
use super::execution_outcome_v5;
use super::master_key_identity_v7;
@@ -21,8 +22,8 @@ mod contract;
use contract::baseline_source_digest;
use contract::validate_descriptors;
const MIGRATION_LOCK_ID: i64 = 0x4352_414E_4B4D_4947;
const CURRENT_VERSION: i64 = 12;
const IMPLEMENTED_VERSIONS: &[i64] = &[1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12];
const CURRENT_VERSION: i64 = 13;
const IMPLEMENTED_VERSIONS: &[i64] = &[1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13];
const BASELINE_SOURCE_SHA256: &str =
"eb1656fc5b4b5be9ee390d237d1d58e4b2274ae5ba9b7ba06a2f3f860dfda675";
const CONSOLIDATION_SOURCE: &str = include_str!("consolidation_v2.sql");
@@ -254,6 +255,12 @@ impl MigrationAuthority {
artifact_metadata_v12::SOURCE_SHA256,
11,
),
expand_descriptor(
13,
"artifact-cleanup-indexes-v13",
artifact_cleanup_indexes_v13::SOURCE_SHA256,
12,
),
]
}
pub fn validate_sequence() -> Result<(), MigrationError> {
@@ -379,6 +386,9 @@ impl MigrationAuthority {
if from < 12 {
artifact_metadata_v12::apply(&mut transaction, &Self::sequence()[11]).await?;
}
if from < 13 {
artifact_cleanup_indexes_v13::apply(&mut transaction, &Self::sequence()[12]).await?;
}
transaction
.commit()
.await
@@ -128,6 +128,8 @@ pub(super) fn validate_descriptors(
!= onboarding_product_events_v11::SOURCE_SHA256
|| sha256_hex(artifact_metadata_v12::SOURCE.as_bytes())
!= artifact_metadata_v12::SOURCE_SHA256
|| sha256_hex(artifact_cleanup_indexes_v13::SOURCE.as_bytes())
!= artifact_cleanup_indexes_v13::SOURCE_SHA256
{
return Err(MigrationError::new(
"invalid_contract",
@@ -624,6 +624,13 @@ pub(super) async fn validate_schema_fingerprint(
} else {
super::schema_guard_v12::validate_v12_artifact_metadata(connection).await?;
}
if current_version < 13 {
if !super::schema_guard_v13::validate_v13_absent(connection).await? {
return Err(schema_error(current_version));
}
} else {
super::schema_guard_v13::validate_v13_artifact_cleanup_indexes(connection).await?;
}
Ok(())
}
@@ -12,6 +12,18 @@ const OWNED_RELATIONS: &[(&str, &str)] = &[
("artifact_sources_workspace_created_idx", "i"),
];
const OWNED_RELATIONS_WITH_V13_INDEXES: &[(&str, &str)] = &[
("artifact_blobs", "r"),
("artifact_blobs_artifact_ref_key", "i"),
("artifact_blobs_expired_claim_idx", "i"),
("artifact_blobs_pkey", "i"),
("artifact_sources", "r"),
("artifact_sources_blob_lifecycle_detached_idx", "i"),
("artifact_sources_openapi_dangling_idx", "i"),
("artifact_sources_pkey", "i"),
("artifact_sources_workspace_created_idx", "i"),
];
const BLOB_COLUMNS: &[(&str, &str, bool, Option<&str>)] = &[
("digest", "text", false, None),
("artifact_ref", "text", false, None),
@@ -141,7 +153,8 @@ pub(super) async fn validate_v12_absent(
pub(super) async fn validate_v12_artifact_metadata(
connection: &mut PgConnection,
) -> Result<(), MigrationError> {
if owned_relations(connection).await? != OWNED_RELATIONS {
let relations = owned_relations(connection).await?;
if relations != OWNED_RELATIONS && relations != OWNED_RELATIONS_WITH_V13_INDEXES {
return Err(schema_error(12));
}
validate_table_properties(connection).await?;
@@ -202,7 +215,7 @@ async fn owned_relations(
let kind = row
.try_get::<String, _>("relkind")
.map_err(|_| MigrationError::storage("preflight.schema"))?;
let expected = OWNED_RELATIONS
let expected = OWNED_RELATIONS_WITH_V13_INDEXES
.iter()
.copied()
.find(|(expected_name, expected_kind)| {
@@ -346,14 +359,22 @@ async fn validate_indexes(connection: &mut PgConnection) -> Result<(), Migration
.iter()
.filter_map(|row| row.try_get::<String, _>("index_name").ok())
.collect::<Vec<_>>();
if names
!= [
"artifact_blobs_artifact_ref_key",
"artifact_blobs_pkey",
"artifact_sources_pkey",
"artifact_sources_workspace_created_idx",
]
{
let base_indexes = [
"artifact_blobs_artifact_ref_key",
"artifact_blobs_pkey",
"artifact_sources_pkey",
"artifact_sources_workspace_created_idx",
];
let expanded_indexes = [
"artifact_blobs_artifact_ref_key",
"artifact_blobs_expired_claim_idx",
"artifact_blobs_pkey",
"artifact_sources_blob_lifecycle_detached_idx",
"artifact_sources_openapi_dangling_idx",
"artifact_sources_pkey",
"artifact_sources_workspace_created_idx",
];
if names != base_indexes && names != expanded_indexes {
return Err(schema_error(12));
}
let row = rows.iter().find(|row| {
@@ -0,0 +1,178 @@
use sqlx::{PgConnection, Row, query};
use super::{
authority::MigrationError,
schema_guard::{normalize_definition, schema_error},
};
struct IndexContract {
name: &'static str,
table: &'static str,
columns: &'static [&'static str],
predicate: Option<&'static str>,
}
const CLEANUP_INDEXES: &[IndexContract] = &[
IndexContract {
name: "artifact_blobs_expired_claim_idx",
table: "artifact_blobs",
columns: &["claim_expires_at", "digest"],
predicate: Some("claim_tokenisnotnull"),
},
IndexContract {
name: "artifact_sources_blob_lifecycle_detached_idx",
table: "artifact_sources",
columns: &["blob_digest", "lifecycle", "detached_at"],
predicate: None,
},
IndexContract {
name: "artifact_sources_openapi_dangling_idx",
table: "artifact_sources",
columns: &["created_at", "workspace_id", "source_id"],
predicate: Some("lifecycle='active'::textand\"left\"source_id,12='src_openapi_'::text"),
},
IndexContract {
name: "import_jobs_expires_at_idx",
table: "import_jobs",
columns: &["expires_at", "id"],
predicate: None,
},
IndexContract {
name: "import_jobs_openapi_source_idx",
table: "import_jobs",
columns: &[
"workspace_id",
"preview_payload->'source'::text->>'source_id'::text",
],
predicate: Some("preview_payload?'source'::text"),
},
];
pub(super) async fn validate_v13_absent(
connection: &mut PgConnection,
) -> Result<bool, MigrationError> {
Ok(cleanup_indexes(connection).await?.is_empty())
}
pub(super) async fn validate_v13_artifact_cleanup_indexes(
connection: &mut PgConnection,
) -> Result<(), MigrationError> {
let rows = query(
"select idx.relname as index_name, t.relname as table_name, am.amname as access_method,
i.indisvalid, i.indisready, i.indisunique, i.indnkeyatts, i.indnatts,
pg_get_indexdef(i.indexrelid, 1, true) as first_column,
pg_get_indexdef(i.indexrelid, 2, true) as second_column,
pg_get_indexdef(i.indexrelid, 3, true) as third_column,
pg_get_expr(i.indpred, i.indrelid) as predicate
from pg_catalog.pg_index i
join pg_catalog.pg_class idx on idx.oid = i.indexrelid
join pg_catalog.pg_class t on t.oid = i.indrelid
join pg_catalog.pg_namespace n on n.oid = t.relnamespace
join pg_catalog.pg_am am on am.oid = idx.relam
where n.nspname = current_schema()
and idx.relname = any($1)
order by idx.relname",
)
.bind(index_names())
.fetch_all(connection)
.await
.map_err(|_| MigrationError::storage("preflight.schema"))?;
if rows.len() != CLEANUP_INDEXES.len() {
return Err(schema_error(13));
}
for row in rows {
let name = row
.try_get::<String, _>("index_name")
.map_err(|_| MigrationError::storage("preflight.schema"))?;
let Some(contract) = CLEANUP_INDEXES.iter().find(|index| index.name == name) else {
return Err(schema_error(13));
};
if !index_matches_contract(&row, contract) {
return Err(schema_error(13));
}
}
Ok(())
}
fn index_matches_contract(row: &sqlx::postgres::PgRow, contract: &IndexContract) -> bool {
let Ok(table_name) = row.try_get::<String, _>("table_name") else {
return false;
};
let Ok(access_method) = row.try_get::<String, _>("access_method") else {
return false;
};
let Ok(indnkeyatts) = row.try_get::<i16, _>("indnkeyatts") else {
return false;
};
let Ok(indnatts) = row.try_get::<i16, _>("indnatts") else {
return false;
};
let columns = [
row.try_get::<Option<String>, _>("first_column"),
row.try_get::<Option<String>, _>("second_column"),
row.try_get::<Option<String>, _>("third_column"),
];
let expected_columns = contract
.columns
.iter()
.map(|column| normalize_definition(column))
.collect::<Vec<_>>();
let actual_columns = columns
.iter()
.take(contract.columns.len())
.map(|column| {
column
.as_ref()
.ok()
.and_then(|column| column.as_deref())
.map(normalize_definition)
})
.collect::<Option<Vec<_>>>();
let trailing_columns_are_absent = columns.iter().skip(contract.columns.len()).all(|column| {
column
.as_ref()
.is_ok_and(|column| column.as_deref().is_none_or(str::is_empty))
});
let predicate = row
.try_get::<Option<String>, _>("predicate")
.ok()
.flatten()
.map(|predicate| normalize_definition(&predicate));
table_name == contract.table
&& access_method == "btree"
&& row.try_get::<bool, _>("indisvalid").ok() == Some(true)
&& row.try_get::<bool, _>("indisready").ok() == Some(true)
&& row.try_get::<bool, _>("indisunique").ok() == Some(false)
&& indnkeyatts == contract.columns.len() as i16
&& indnatts == contract.columns.len() as i16
&& actual_columns.as_ref() == Some(&expected_columns)
&& trailing_columns_are_absent
&& predicate.as_deref() == contract.predicate
}
fn index_names() -> Vec<&'static str> {
CLEANUP_INDEXES.iter().map(|index| index.name).collect()
}
async fn cleanup_indexes(connection: &mut PgConnection) -> Result<Vec<String>, MigrationError> {
query(
"select idx.relname
from pg_catalog.pg_class idx
join pg_catalog.pg_namespace n on n.oid = idx.relnamespace
where n.nspname = current_schema()
and idx.relname = any($1)
order by idx.relname",
)
.bind(index_names())
.fetch_all(connection)
.await
.map_err(|_| MigrationError::storage("preflight.schema"))?
.into_iter()
.map(|row| {
row.try_get("relname")
.map_err(|_| MigrationError::storage("preflight.schema"))
})
.collect()
}
+15
View File
@@ -673,6 +673,17 @@ pub struct ImportJobApplyResult {
pub skipped: Vec<SkippedImportOperation>,
}
/// Result of one bounded import-source maintenance pass.
///
/// The caller may schedule another pass when `more_work` is true. The report
/// intentionally contains only counts so it is safe to use in telemetry.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub struct ImportJobCleanupReport {
pub deleted_jobs: u64,
pub detached_sources: u64,
pub more_work: bool,
}
#[derive(Clone, Debug, PartialEq)]
pub struct ApplyImportJobRequest<'a> {
pub id: &'a ImportJobId,
@@ -680,6 +691,10 @@ pub struct ApplyImportJobRequest<'a> {
pub application_key: &'a str,
pub conflict_mode: ImportConflictMode,
pub operations: &'a [ImportOperationDraft],
/// Outcomes determined before the registry transaction (for example,
/// selected OpenAPI keys absent from the immutable preview). They are
/// persisted with the canonical result so retries remain equivalent.
pub pre_skipped: &'a [SkippedImportOperation],
pub finished_at: &'a OffsetDateTime,
}
@@ -502,8 +502,12 @@ impl PostgresRegistry {
.ok_or_else(|| source_not_found(request.source_id))?;
if recorded.lifecycle == ArtifactSourceLifecycle::Detached {
if recorded.detached_at == Some(request.detached_at)
&& request.expected_updated_at == Some(recorded.created_at)
// `None` is an unconditional detach authority. Retrying it after
// the state transition must remain idempotent; callers that need
// optimistic fencing still supply an exact timestamp below.
if request.expected_updated_at.is_none()
|| (recorded.detached_at == Some(request.detached_at)
&& request.expected_updated_at == Some(recorded.created_at))
{
transaction.commit().await?;
return Ok(recorded);
@@ -544,7 +548,7 @@ impl PostgresRegistry {
pub async fn read_artifact_source(
&self,
store: &ArtifactStore,
store: std::sync::Arc<ArtifactStore>,
workspace_id: &WorkspaceId,
source_id: &ArtifactSourceId,
) -> Result<VerifiedArtifactSource, RegistryError> {
@@ -555,7 +559,6 @@ impl PostgresRegistry {
return Err(RegistryError::SourceUnavailable);
}
let store = store.clone();
let artifact_ref = source.blob.artifact_ref.clone();
let bytes = tokio::task::spawn_blocking(move || store.read(&artifact_ref))
.await
@@ -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(
+2 -2
View File
@@ -45,8 +45,8 @@ use crate::{
CreatePlatformApiKeyRequest, CreateSecretRequest, CreateVersionRequest,
CreateWorkspaceRequest, CreateYamlImportJobRequest, DecideApprovalRequest,
DescriptorMetadata, ExpireApprovalRequest, FinishApprovalRequest, FinishImportJobRequest,
ImportConflictMode, ImportJob, ImportJobApplyResult, ImportJobId, ImportJobStatus,
InvitationRecord, InvocationHistoryLoss, InvocationHistoryLossCategory,
ImportConflictMode, ImportJob, ImportJobApplyResult, ImportJobCleanupReport, ImportJobId,
ImportJobStatus, InvitationRecord, InvocationHistoryLoss, InvocationHistoryLossCategory,
InvocationHistoryWriteOutcome, InvocationLogRecord, InvocationRetentionOutcome,
InvocationRetentionPolicy, InvocationRetentionStatus, ListApprovalRequestsQuery,
ListInvocationLogsQuery, ListProductEventsQuery, MasterKeyIdentityCandidate,
@@ -329,6 +329,16 @@ async fn source_relations_are_scoped_replayable_pageable_and_detachable() {
.await
.unwrap();
assert_eq!(retry, detached);
let unconditional_retry = registry
.detach_artifact_source(DetachArtifactSourceRequest {
workspace_id: &workspace_a,
source_id: &source_id,
expected_updated_at: None,
detached_at: timestamp("2026-08-26T10:05:00Z"),
})
.await
.unwrap();
assert_eq!(unconditional_retry, detached);
assert!(matches!(
registry
.detach_artifact_source(DetachArtifactSourceRequest {
@@ -350,7 +360,7 @@ async fn source_relations_are_scoped_replayable_pageable_and_detachable() {
);
assert!(matches!(
registry
.read_artifact_source(&store, &workspace_a, &source_id)
.read_artifact_source(std::sync::Arc::new(store.clone()), &workspace_a, &source_id)
.await,
Err(RegistryError::SourceUnavailable)
));
@@ -439,7 +449,7 @@ async fn verified_read_returns_only_digest_and_size_verified_bytes() {
.await
.unwrap();
let verified = registry
.read_artifact_source(&store, &workspace_id, &valid_id)
.read_artifact_source(std::sync::Arc::new(store.clone()), &workspace_id, &valid_id)
.await
.unwrap();
assert_eq!(verified.bytes, bytes);
@@ -473,7 +483,11 @@ async fn verified_read_returns_only_digest_and_size_verified_bytes() {
.unwrap();
assert!(matches!(
registry
.read_artifact_source(&store, &workspace_id, &missing_id)
.read_artifact_source(
std::sync::Arc::new(store.clone()),
&workspace_id,
&missing_id
)
.await,
Err(RegistryError::SourceUnavailable)
));
@@ -504,7 +518,11 @@ async fn verified_read_returns_only_digest_and_size_verified_bytes() {
.unwrap();
assert!(matches!(
registry
.read_artifact_source(&store, &workspace_id, &wrong_size_id)
.read_artifact_source(
std::sync::Arc::new(store.clone()),
&workspace_id,
&wrong_size_id
)
.await,
Err(RegistryError::SourceIntegrity)
));
@@ -529,7 +547,11 @@ async fn verified_read_returns_only_digest_and_size_verified_bytes() {
.unwrap();
assert!(matches!(
registry
.read_artifact_source(&store, &workspace_id, &unavailable_id)
.read_artifact_source(
std::sync::Arc::new(store.clone()),
&workspace_id,
&unavailable_id
)
.await,
Err(RegistryError::SourceUnavailable)
));
@@ -543,7 +565,7 @@ async fn verified_read_returns_only_digest_and_size_verified_bytes() {
fs::write(&tampered, vec![b'x'; bytes.len()]).unwrap();
assert!(matches!(
registry
.read_artifact_source(&store, &workspace_id, &valid_id)
.read_artifact_source(std::sync::Arc::new(store.clone()), &workspace_id, &valid_id)
.await,
Err(RegistryError::SourceIntegrity)
));
@@ -749,12 +771,16 @@ async fn reconciliation_claims_are_fenced_global_and_allow_verified_revival() {
.unwrap(),
ArtifactClaimOutcome::ActiveReference
));
let detached_at: OffsetDateTime = sqlx::query_scalar("select clock_timestamp()")
.fetch_one(registry.pool())
.await
.unwrap();
registry
.detach_artifact_source(DetachArtifactSourceRequest {
workspace_id: &workspace_b,
source_id: &active_id,
expected_updated_at: Some(active.updated_at),
detached_at: timestamp("2026-08-27T10:02:00Z"),
detached_at,
})
.await
.unwrap();
@@ -51,6 +51,7 @@ async fn controlled_authority_is_versioned_and_safe_under_concurrent_apply() {
(10, "approval-side-effects-v10"),
(11, "onboarding-product-events-v11"),
(12, "artifact-metadata-v12"),
(13, "artifact-cleanup-indexes-v13"),
];
assert_eq!(rows.len(), expected.len());
for (row, (version, name)) in rows.iter().zip(expected) {
@@ -106,7 +107,7 @@ async fn controlled_authority_is_versioned_and_safe_under_concurrent_apply() {
assert_eq!(artifact_relations.len(), 2);
assert_eq!(
MigrationAuthority::preflight(first.pool()).await.unwrap(),
MigrationPreflight::Current { version: 12 }
MigrationPreflight::Current { version: 13 }
);
}
#[tokio::test]
@@ -229,7 +230,7 @@ async fn legacy_core_baseline_is_consolidated_without_data_loss() {
MigrationAuthority::preflight(&pool).await.unwrap(),
MigrationPreflight::MigrationRequired {
current: 1,
target: 12,
target: 13,
}
);
MigrationAuthority::apply(&pool).await.unwrap();
@@ -308,7 +309,13 @@ async fn future_sequence_fails_closed() {
let database_url = crank_test_support::postgres_schema_url("test_future_sequence").await;
let pool = sqlx::PgPool::connect(&database_url).await.unwrap();
MigrationAuthority::apply(&pool).await.unwrap();
sqlx::query("update __crank_migrations set version = 13 where version = 12")
let current: i64 = sqlx::query_scalar("select max(version) from __crank_migrations")
.fetch_one(&pool)
.await
.unwrap();
sqlx::query("update __crank_migrations set version = $1 where version = $2")
.bind(current + 1)
.bind(current)
.execute(&pool)
.await
.unwrap();
@@ -336,13 +343,13 @@ async fn healthy_v2_is_reported_as_migration_required_and_upgrades_to_v3() {
MigrationAuthority::preflight(&pool).await.unwrap(),
MigrationPreflight::MigrationRequired {
current: 2,
target: 12,
target: 13,
}
);
MigrationAuthority::apply(&pool).await.unwrap();
assert_eq!(
MigrationAuthority::preflight(&pool).await.unwrap(),
MigrationPreflight::Current { version: 12 }
MigrationPreflight::Current { version: 13 }
);
let trace_column: bool = sqlx::query_scalar(
"select exists (
@@ -385,7 +392,7 @@ async fn healthy_v3_upgrades_to_v4_with_honest_legacy_snapshot_provenance() {
MigrationAuthority::preflight(&pool).await.unwrap(),
MigrationPreflight::MigrationRequired {
current: 3,
target: 12,
target: 13,
}
);
MigrationAuthority::apply(&pool).await.unwrap();
@@ -445,7 +452,7 @@ async fn healthy_v4_upgrades_to_v5_without_fabricating_legacy_outcomes() {
MigrationAuthority::preflight(&pool).await.unwrap(),
MigrationPreflight::MigrationRequired {
current: 4,
target: 12,
target: 13,
}
);
MigrationAuthority::apply(&pool).await.unwrap();
@@ -699,7 +706,7 @@ async fn v3_upgrade_ignores_oversized_legacy_request_ids_in_partial_index() {
MigrationAuthority::apply(&pool).await.unwrap();
assert_eq!(
MigrationAuthority::preflight(&pool).await.unwrap(),
MigrationPreflight::Current { version: 12 }
MigrationPreflight::Current { version: 13 }
);
}
async fn remove_v3_schema(pool: &sqlx::PgPool) {
@@ -855,7 +862,13 @@ async fn remove_v11_schema(pool: &sqlx::PgPool) {
}
async fn remove_v12_schema(pool: &sqlx::PgPool) {
sqlx::raw_sql(
"drop table if exists artifact_sources;
"drop index if exists import_jobs_openapi_source_idx;
drop index if exists import_jobs_expires_at_idx;
drop index if exists artifact_sources_openapi_dangling_idx;
drop index if exists artifact_sources_blob_lifecycle_detached_idx;
drop index if exists artifact_blobs_expired_claim_idx;
delete from __crank_migrations where version = 13;
drop table if exists artifact_sources;
drop table if exists artifact_blobs;
delete from __crank_migrations where version = 12;",
)
@@ -1,7 +1,7 @@
use super::*;
#[tokio::test]
async fn healthy_v11_upgrades_to_v12_without_rewriting_prior_ledger() {
async fn healthy_v11_upgrades_to_v13_without_rewriting_prior_ledger() {
let database_url = crank_test_support::postgres_schema_url("test_v11_to_v12_artifacts").await;
let pool = sqlx::PgPool::connect(&database_url).await.unwrap();
MigrationAuthority::apply(&pool).await.unwrap();
@@ -27,7 +27,7 @@ async fn healthy_v11_upgrades_to_v12_without_rewriting_prior_ledger() {
MigrationAuthority::preflight(&pool).await.unwrap(),
MigrationPreflight::MigrationRequired {
current: 11,
target: 12,
target: 13,
}
);
@@ -51,22 +51,22 @@ async fn healthy_v11_upgrades_to_v12_without_rewriting_prior_ledger() {
.collect::<Vec<_>>();
assert_eq!(after, prior);
let v12_applied_at: time::OffsetDateTime =
sqlx::query_scalar("select applied_at from __crank_migrations where version = 12")
let v13_applied_at: time::OffsetDateTime =
sqlx::query_scalar("select applied_at from __crank_migrations where version = 13")
.fetch_one(&pool)
.await
.unwrap();
MigrationAuthority::apply(&pool).await.unwrap();
let replayed_at: time::OffsetDateTime =
sqlx::query_scalar("select applied_at from __crank_migrations where version = 12")
sqlx::query_scalar("select applied_at from __crank_migrations where version = 13")
.fetch_one(&pool)
.await
.unwrap();
assert_eq!(replayed_at, v12_applied_at);
assert_eq!(replayed_at, v13_applied_at);
}
#[tokio::test]
async fn v12_exact_guard_rejects_column_constraint_index_and_relation_drift() {
async fn v12_guard_still_rejects_column_constraint_index_and_relation_drift() {
let database_url = crank_test_support::postgres_schema_url("test_v12_artifact_drift").await;
let pool = sqlx::PgPool::connect(&database_url).await.unwrap();
MigrationAuthority::apply(&pool).await.unwrap();
@@ -158,7 +158,7 @@ async fn v12_exact_guard_rejects_column_constraint_index_and_relation_drift() {
sqlx::raw_sql(restore).execute(&pool).await.unwrap();
assert_eq!(
MigrationAuthority::preflight(&pool).await.unwrap(),
MigrationPreflight::Current { version: 12 },
MigrationPreflight::Current { version: 13 },
"restore: {restore}"
);
}
@@ -175,3 +175,44 @@ async fn v12_exact_guard_rejects_column_constraint_index_and_relation_drift() {
assert_eq!(error.code(), "partial_sequence");
assert_eq!(error.version(), Some(12));
}
#[tokio::test]
async fn v13_guard_rejects_wrong_cleanup_index_order_and_predicate() {
let database_url =
crank_test_support::postgres_schema_url("test_v13_cleanup_index_drift").await;
let pool = sqlx::PgPool::connect(&database_url).await.unwrap();
MigrationAuthority::apply(&pool).await.unwrap();
for (drift, restore) in [
(
"drop index artifact_sources_blob_lifecycle_detached_idx;
create index artifact_sources_blob_lifecycle_detached_idx
on artifact_sources(lifecycle, blob_digest, detached_at);",
"drop index artifact_sources_blob_lifecycle_detached_idx;
create index artifact_sources_blob_lifecycle_detached_idx
on artifact_sources(blob_digest, lifecycle, detached_at);",
),
(
"drop index artifact_sources_openapi_dangling_idx;
create index artifact_sources_openapi_dangling_idx
on artifact_sources(created_at, workspace_id, source_id)
where lifecycle = 'active';",
"drop index artifact_sources_openapi_dangling_idx;
create index artifact_sources_openapi_dangling_idx
on artifact_sources(created_at, workspace_id, source_id)
where lifecycle = 'active' and left(source_id, 12) = 'src_openapi_';",
),
] {
sqlx::raw_sql(drift).execute(&pool).await.unwrap();
let error = MigrationAuthority::preflight(&pool).await.unwrap_err();
assert_eq!(error.code(), "partial_sequence", "drift: {drift}");
assert_eq!(error.version(), Some(13), "drift: {drift}");
sqlx::raw_sql(restore).execute(&pool).await.unwrap();
assert_eq!(
MigrationAuthority::preflight(&pool).await.unwrap(),
MigrationPreflight::Current { version: 13 },
"restore: {restore}"
);
}
}
@@ -63,7 +63,7 @@ async fn failed_artifact_metadata_migration_rolls_back_schema_and_ledger() {
MigrationAuthority::preflight(&pool).await.unwrap(),
MigrationPreflight::MigrationRequired {
current: 11,
target: 12,
target: 13,
}
);
}