feat(openapi): complete upload preview and UI evidence
This commit is contained in:
@@ -1,12 +1,18 @@
|
||||
use super::*;
|
||||
use crate::{ArtifactSourceId, ImportJobSourceEnvelope};
|
||||
use crank_artifacts::ArtifactRef;
|
||||
|
||||
const APPLICATION_RESULT_KEY: &str = "_crank_application_result";
|
||||
const IMPORT_JOB_CLEANUP_BATCH: i64 = 128;
|
||||
const DANGLING_OPENAPI_SOURCE_GRACE: time::Duration = time::Duration::minutes(5);
|
||||
|
||||
impl PostgresRegistry {
|
||||
pub async fn create_import_job(
|
||||
&self,
|
||||
request: CreateImportJobRequest<'_>,
|
||||
) -> Result<(), RegistryError> {
|
||||
validate_source_envelope(request.source, request.preview_payload)?;
|
||||
let mut transaction = self.pool.begin().await?;
|
||||
sqlx::query(
|
||||
"insert into import_jobs (
|
||||
id,
|
||||
@@ -34,8 +40,9 @@ impl PostgresRegistry {
|
||||
.bind(request.preview_payload)
|
||||
.bind(request.created_at)
|
||||
.bind(request.expires_at)
|
||||
.execute(&self.pool)
|
||||
.execute(&mut *transaction)
|
||||
.await?;
|
||||
transaction.commit().await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -137,11 +144,79 @@ impl PostgresRegistry {
|
||||
}
|
||||
|
||||
pub async fn delete_expired_import_jobs(&self) -> Result<u64, RegistryError> {
|
||||
let result = sqlx::query("delete from import_jobs where expires_at < now()")
|
||||
.execute(&self.pool)
|
||||
let mut transaction = self.pool.begin().await?;
|
||||
let now = sqlx::query_scalar::<_, OffsetDateTime>("select now()")
|
||||
.fetch_one(&mut *transaction)
|
||||
.await?;
|
||||
let expired = sqlx::query(
|
||||
"select id, workspace_id, preview_payload
|
||||
from import_jobs
|
||||
where expires_at < now()
|
||||
order by expires_at, id
|
||||
limit $1
|
||||
for update skip locked",
|
||||
)
|
||||
.bind(IMPORT_JOB_CLEANUP_BATCH)
|
||||
.fetch_all(&mut *transaction)
|
||||
.await?;
|
||||
let mut expired_ids = Vec::with_capacity(expired.len());
|
||||
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(
|
||||
&mut transaction,
|
||||
&workspace_id,
|
||||
&source.source_id,
|
||||
now,
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
}
|
||||
let deleted = if expired_ids.is_empty() {
|
||||
0
|
||||
} else {
|
||||
sqlx::query("delete from import_jobs where id = any($1)")
|
||||
.bind(&expired_ids)
|
||||
.execute(&mut *transaction)
|
||||
.await?
|
||||
.rows_affected()
|
||||
};
|
||||
|
||||
Ok(result.rows_affected())
|
||||
let dangling_cutoff = now.checked_sub(DANGLING_OPENAPI_SOURCE_GRACE).ok_or(
|
||||
RegistryError::InvalidArtifactSource {
|
||||
field: "import_job_source",
|
||||
},
|
||||
)?;
|
||||
let dangling = sqlx::query(
|
||||
"select s.workspace_id, s.source_id
|
||||
from artifact_sources s
|
||||
where s.lifecycle = 'active'
|
||||
and left(s.source_id, 12) = 'src_openapi_'
|
||||
and s.created_at < $1
|
||||
and not exists (
|
||||
select 1
|
||||
from import_jobs j
|
||||
where j.workspace_id = s.workspace_id
|
||||
and j.preview_payload -> 'source' ->> 'source_id' = s.source_id
|
||||
)
|
||||
order by s.created_at, s.workspace_id, s.source_id
|
||||
limit $2
|
||||
for update of s skip locked",
|
||||
)
|
||||
.bind(dangling_cutoff)
|
||||
.bind(IMPORT_JOB_CLEANUP_BATCH)
|
||||
.fetch_all(&mut *transaction)
|
||||
.await?;
|
||||
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?;
|
||||
}
|
||||
transaction.commit().await?;
|
||||
|
||||
Ok(deleted)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -164,6 +239,10 @@ async fn apply_import_job_transaction(
|
||||
})?;
|
||||
let status = deserialize_enum_text::<ImportJobStatus>(row.try_get("status")?, "status")?;
|
||||
let mut preview_payload = row.try_get::<Value, _>("preview_payload")?;
|
||||
let source =
|
||||
source_from_payload(&preview_payload)?.ok_or(RegistryError::InvalidArtifactSource {
|
||||
field: "import_job_source",
|
||||
})?;
|
||||
|
||||
if status == ImportJobStatus::Completed
|
||||
&& let Some(result) = stored_application_result(&preview_payload)?
|
||||
@@ -181,6 +260,30 @@ async fn apply_import_job_transaction(
|
||||
.fetch_one(&mut **tx)
|
||||
.await?;
|
||||
|
||||
let recorded_source = sqlx::query(
|
||||
"select s.lifecycle, b.artifact_ref
|
||||
from artifact_sources s
|
||||
join artifact_blobs b on b.digest = s.blob_digest
|
||||
where s.workspace_id = $1 and s.source_id = $2
|
||||
for update",
|
||||
)
|
||||
.bind(request.workspace_id.as_str())
|
||||
.bind(source.source_id.as_str())
|
||||
.fetch_optional(&mut **tx)
|
||||
.await?;
|
||||
let source_is_current = recorded_source
|
||||
.as_ref()
|
||||
.map(|row| {
|
||||
let lifecycle = row.try_get::<String, _>("lifecycle").ok();
|
||||
let artifact_ref = row.try_get::<String, _>("artifact_ref").ok();
|
||||
lifecycle.as_deref() == Some("active")
|
||||
&& artifact_ref.as_deref() == Some(source.digest.as_str())
|
||||
})
|
||||
.unwrap_or(false);
|
||||
if !source_is_current {
|
||||
return Err(RegistryError::SourceUnavailable);
|
||||
}
|
||||
|
||||
let mut result = ImportJobApplyResult {
|
||||
application_key: request.application_key.to_owned(),
|
||||
..ImportJobApplyResult::default()
|
||||
@@ -257,9 +360,91 @@ async fn apply_import_job_transaction(
|
||||
.execute(&mut **tx)
|
||||
.await?;
|
||||
|
||||
detach_source_in_transaction(
|
||||
tx,
|
||||
request.workspace_id,
|
||||
&source.source_id,
|
||||
*request.finished_at,
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
fn validate_source_envelope(
|
||||
source: &ImportJobSourceEnvelope,
|
||||
preview_payload: &Value,
|
||||
) -> Result<(), RegistryError> {
|
||||
if source.source_id.as_str().len() > 132 {
|
||||
return Err(RegistryError::InvalidArtifactSource {
|
||||
field: "import_job_source",
|
||||
});
|
||||
}
|
||||
let parsed =
|
||||
source_from_payload(preview_payload)?.ok_or(RegistryError::InvalidArtifactSource {
|
||||
field: "import_job_source",
|
||||
})?;
|
||||
if parsed != *source {
|
||||
return Err(RegistryError::InvalidArtifactSource {
|
||||
field: "import_job_source",
|
||||
});
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn source_from_payload(payload: &Value) -> Result<Option<ImportJobSourceEnvelope>, RegistryError> {
|
||||
let Some(source) = payload.get("source") else {
|
||||
return Ok(None);
|
||||
};
|
||||
let has_source_id = source.get("source_id").is_some();
|
||||
let has_digest = source.get("digest").is_some();
|
||||
if !has_source_id && !has_digest {
|
||||
// Jobs created before the verified-source envelope used `source` for
|
||||
// OpenAPI format/version metadata. Expiry cleanup must remain able to
|
||||
// delete those jobs during a rolling upgrade.
|
||||
return Ok(None);
|
||||
}
|
||||
let source_id = source
|
||||
.get("source_id")
|
||||
.and_then(Value::as_str)
|
||||
.filter(|value| value.len() <= 132)
|
||||
.ok_or(RegistryError::InvalidArtifactSource {
|
||||
field: "import_job_source",
|
||||
})?;
|
||||
let digest = source.get("digest").and_then(Value::as_str).ok_or(
|
||||
RegistryError::InvalidArtifactSource {
|
||||
field: "import_job_source",
|
||||
},
|
||||
)?;
|
||||
let digest = ArtifactRef::parse(digest).map_err(|_| RegistryError::InvalidArtifactSource {
|
||||
field: "import_job_source",
|
||||
})?;
|
||||
Ok(Some(ImportJobSourceEnvelope {
|
||||
source_id: ArtifactSourceId::new(source_id),
|
||||
digest,
|
||||
}))
|
||||
}
|
||||
|
||||
async fn detach_source_in_transaction(
|
||||
transaction: &mut Transaction<'_, Postgres>,
|
||||
workspace_id: &WorkspaceId,
|
||||
source_id: &ArtifactSourceId,
|
||||
detached_at: OffsetDateTime,
|
||||
) -> Result<(), RegistryError> {
|
||||
sqlx::query(
|
||||
"update artifact_sources
|
||||
set lifecycle = 'detached', updated_at = $1, detached_at = $1
|
||||
where workspace_id = $2 and source_id = $3
|
||||
and lifecycle = 'active'",
|
||||
)
|
||||
.bind(detached_at)
|
||||
.bind(workspace_id.as_str())
|
||||
.bind(source_id.as_str())
|
||||
.execute(&mut **transaction)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn stored_application_result(
|
||||
preview_payload: &Value,
|
||||
) -> Result<Option<ImportJobApplyResult>, RegistryError> {
|
||||
|
||||
Reference in New Issue
Block a user