882 lines
31 KiB
Rust
882 lines
31 KiB
Rust
use super::common::TestDatabase;
|
|
|
|
use std::{
|
|
fs,
|
|
os::unix::fs::PermissionsExt,
|
|
path::PathBuf,
|
|
sync::atomic::{AtomicU64, Ordering},
|
|
time::{Duration as StdDuration, SystemTime},
|
|
};
|
|
|
|
use crank_artifacts::{
|
|
ArtifactRef, ArtifactStore, ReconciliationMutation, ReconciliationPresence,
|
|
ReconciliationRegistration, RegisteredArtifact,
|
|
};
|
|
use crank_core::{Workspace, WorkspaceId, WorkspaceStatus};
|
|
use crank_registry::{
|
|
ArtifactClaimFinalization, ArtifactClaimFinalizeOutcome, ArtifactClaimOutcome,
|
|
ArtifactClaimRecheckOutcome, ArtifactClaimToken, ArtifactSourceId, ArtifactSourceLifecycle,
|
|
ArtifactSourceSensitivity, ClaimArtifactReconciliationRequest,
|
|
ClaimExpiredArtifactReconciliationRequest, CreateArtifactSourceRequest, CreateWorkspaceRequest,
|
|
DetachArtifactSourceRequest, ListArtifactSourcesQuery, RegistryError,
|
|
};
|
|
use serde_json::json;
|
|
use time::{OffsetDateTime, format_description::well_known::Rfc3339};
|
|
|
|
static NEXT_ROOT: AtomicU64 = AtomicU64::new(0);
|
|
|
|
pub(super) struct TestRoot(pub(super) PathBuf);
|
|
|
|
impl TestRoot {
|
|
pub(super) fn new(name: &str) -> Self {
|
|
let path = std::env::temp_dir().join(format!(
|
|
"crank-registry-artifacts-{name}-{}-{}",
|
|
std::process::id(),
|
|
NEXT_ROOT.fetch_add(1, Ordering::Relaxed)
|
|
));
|
|
fs::create_dir(&path).unwrap();
|
|
fs::set_permissions(&path, fs::Permissions::from_mode(0o700)).unwrap();
|
|
Self(path)
|
|
}
|
|
}
|
|
|
|
impl Drop for TestRoot {
|
|
fn drop(&mut self) {
|
|
let _ = fs::set_permissions(&self.0, fs::Permissions::from_mode(0o700));
|
|
let _ = fs::remove_dir_all(&self.0);
|
|
}
|
|
}
|
|
|
|
pub(super) fn timestamp(value: &str) -> OffsetDateTime {
|
|
OffsetDateTime::parse(value, &Rfc3339).unwrap()
|
|
}
|
|
|
|
fn reconciliation_candidate_for(
|
|
store: &ArtifactStore,
|
|
artifact: &RegisteredArtifact,
|
|
) -> crank_artifacts::ReconciliationCandidate {
|
|
let (_, candidates) = store.scan_reconciliation(None, 4096, 32).unwrap();
|
|
candidates
|
|
.into_iter()
|
|
.find(|candidate| {
|
|
matches!(
|
|
store.register_reconciliation(candidate, StdDuration::ZERO, SystemTime::now()),
|
|
Ok(ReconciliationRegistration::Registered(found)) if found == *artifact
|
|
)
|
|
})
|
|
.expect("registered reconciliation candidate")
|
|
}
|
|
|
|
async fn create_workspace(registry: &crank_registry::PostgresRegistry, id: &str) -> WorkspaceId {
|
|
let workspace_id = WorkspaceId::new(id);
|
|
let created_at = timestamp("2026-08-26T10:00:00Z");
|
|
registry
|
|
.create_workspace(CreateWorkspaceRequest {
|
|
workspace: &Workspace {
|
|
id: workspace_id.clone(),
|
|
slug: id.replace('_', "-"),
|
|
display_name: id.to_owned(),
|
|
status: WorkspaceStatus::Active,
|
|
settings: json!({}),
|
|
created_at,
|
|
updated_at: created_at,
|
|
},
|
|
})
|
|
.await
|
|
.unwrap();
|
|
workspace_id
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn source_relations_are_scoped_replayable_pageable_and_detachable() {
|
|
let database = TestDatabase::new().await;
|
|
let registry = database.registry().await;
|
|
let workspace_a = create_workspace(®istry, "ws_artifacts_a").await;
|
|
let workspace_b = create_workspace(®istry, "ws_artifacts_b").await;
|
|
let root = TestRoot::new("relations");
|
|
let store = ArtifactStore::open(&root.0).unwrap();
|
|
let registered = store.put_registered(b"openapi: 3.1.0\n").unwrap();
|
|
let source_id = ArtifactSourceId::new("src_shared");
|
|
let created_at = timestamp("2026-08-26T10:01:00Z");
|
|
|
|
let created = registry
|
|
.create_artifact_source(CreateArtifactSourceRequest {
|
|
workspace_id: &workspace_a,
|
|
source_id: &source_id,
|
|
artifact: ®istered,
|
|
mime_type: "application/yaml",
|
|
sensitivity: ArtifactSourceSensitivity::Internal,
|
|
created_at,
|
|
})
|
|
.await
|
|
.unwrap();
|
|
let replayed = registry
|
|
.create_artifact_source(CreateArtifactSourceRequest {
|
|
workspace_id: &workspace_a,
|
|
source_id: &source_id,
|
|
artifact: ®istered,
|
|
mime_type: "application/yaml",
|
|
sensitivity: ArtifactSourceSensitivity::Internal,
|
|
created_at: timestamp("2026-08-26T10:02:00Z"),
|
|
})
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(replayed, created);
|
|
|
|
assert!(matches!(
|
|
registry
|
|
.create_artifact_source(CreateArtifactSourceRequest {
|
|
workspace_id: &workspace_a,
|
|
source_id: &source_id,
|
|
artifact: ®istered,
|
|
mime_type: "application/json",
|
|
sensitivity: ArtifactSourceSensitivity::Internal,
|
|
created_at,
|
|
})
|
|
.await,
|
|
Err(RegistryError::SourceConflict { .. })
|
|
));
|
|
assert!(matches!(
|
|
registry
|
|
.create_artifact_source(CreateArtifactSourceRequest {
|
|
workspace_id: &workspace_a,
|
|
source_id: &source_id,
|
|
artifact: ®istered,
|
|
mime_type: "application/yaml",
|
|
sensitivity: ArtifactSourceSensitivity::Secret,
|
|
created_at,
|
|
})
|
|
.await,
|
|
Err(RegistryError::SourceConflict { .. })
|
|
));
|
|
let different = store.put_registered(b"openapi: 3.0.3\n").unwrap();
|
|
assert!(matches!(
|
|
registry
|
|
.create_artifact_source(CreateArtifactSourceRequest {
|
|
workspace_id: &workspace_a,
|
|
source_id: &source_id,
|
|
artifact: &different,
|
|
mime_type: "application/yaml",
|
|
sensitivity: ArtifactSourceSensitivity::Internal,
|
|
created_at,
|
|
})
|
|
.await,
|
|
Err(RegistryError::SourceConflict { .. })
|
|
));
|
|
assert!(matches!(
|
|
registry.get_artifact_source(&workspace_b, &source_id).await,
|
|
Err(RegistryError::SourceNotFound { .. })
|
|
));
|
|
|
|
let shared_in_b = registry
|
|
.create_artifact_source(CreateArtifactSourceRequest {
|
|
workspace_id: &workspace_b,
|
|
source_id: &source_id,
|
|
artifact: ®istered,
|
|
mime_type: "application/yaml",
|
|
sensitivity: ArtifactSourceSensitivity::Secret,
|
|
created_at,
|
|
})
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(shared_in_b.blob.digest, created.blob.digest);
|
|
assert_ne!(shared_in_b.sensitivity, created.sensitivity);
|
|
let raw_pool = database.raw_pool().await;
|
|
let blob_count =
|
|
sqlx::query_scalar::<_, i64>("select count(*) from artifact_blobs where digest = $1")
|
|
.bind(registered.artifact_ref().digest_hex())
|
|
.fetch_one(&raw_pool)
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(blob_count, 1);
|
|
let total_blob_count: i64 = sqlx::query_scalar("select count(*) from artifact_blobs")
|
|
.fetch_one(&raw_pool)
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(
|
|
total_blob_count, 1,
|
|
"conflicting replay must roll back blob metadata"
|
|
);
|
|
|
|
for id in ["src_page_a", "src_page_b"] {
|
|
registry
|
|
.create_artifact_source(CreateArtifactSourceRequest {
|
|
workspace_id: &workspace_a,
|
|
source_id: &ArtifactSourceId::new(id),
|
|
artifact: ®istered,
|
|
mime_type: "application/yaml",
|
|
sensitivity: ArtifactSourceSensitivity::Public,
|
|
created_at,
|
|
})
|
|
.await
|
|
.unwrap();
|
|
}
|
|
let first = registry
|
|
.list_artifact_sources(ListArtifactSourcesQuery {
|
|
workspace_id: &workspace_a,
|
|
cursor: None,
|
|
limit: 2,
|
|
})
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(first.items.len(), 2);
|
|
let second = registry
|
|
.list_artifact_sources(ListArtifactSourcesQuery {
|
|
workspace_id: &workspace_a,
|
|
cursor: first.next_cursor.as_ref(),
|
|
limit: 2,
|
|
})
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(second.items.len(), 1);
|
|
assert!(second.next_cursor.is_none());
|
|
let listed_ids = first
|
|
.items
|
|
.iter()
|
|
.chain(second.items.iter())
|
|
.map(|source| source.source_id.as_str())
|
|
.collect::<Vec<_>>();
|
|
assert_eq!(listed_ids, vec!["src_page_a", "src_page_b", "src_shared"]);
|
|
assert!(
|
|
first
|
|
.items
|
|
.iter()
|
|
.chain(second.items.iter())
|
|
.all(|source| source.workspace_id == workspace_a)
|
|
);
|
|
assert!(matches!(
|
|
registry
|
|
.list_artifact_sources(ListArtifactSourcesQuery {
|
|
workspace_id: &workspace_b,
|
|
cursor: first.next_cursor.as_ref(),
|
|
limit: 2,
|
|
})
|
|
.await,
|
|
Err(RegistryError::InvalidArtifactSource { field: "cursor" })
|
|
));
|
|
|
|
sqlx::query(
|
|
"insert into artifact_sources
|
|
(workspace_id, source_id, blob_digest, mime_type, sensitivity, lifecycle,
|
|
created_at, updated_at)
|
|
select $1, 'src_bulk_' || lpad(value::text, 3, '0'), $2,
|
|
'application/yaml', 'public', 'active', $3, $3
|
|
from generate_series(0, 100) as value",
|
|
)
|
|
.bind(workspace_a.as_str())
|
|
.bind(registered.artifact_ref().digest_hex())
|
|
.bind(created_at)
|
|
.execute(&raw_pool)
|
|
.await
|
|
.unwrap();
|
|
let capped = registry
|
|
.list_artifact_sources(ListArtifactSourcesQuery {
|
|
workspace_id: &workspace_a,
|
|
cursor: None,
|
|
limit: u32::MAX,
|
|
})
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(capped.items.len(), 100);
|
|
let tail = registry
|
|
.list_artifact_sources(ListArtifactSourcesQuery {
|
|
workspace_id: &workspace_a,
|
|
cursor: capped.next_cursor.as_ref(),
|
|
limit: u32::MAX,
|
|
})
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(tail.items.len(), 4);
|
|
assert!(tail.next_cursor.is_none());
|
|
|
|
let invalid_id = ArtifactSourceId::new(format!("src_{}", "x".repeat(129)));
|
|
assert!(matches!(
|
|
registry
|
|
.get_artifact_source(&workspace_a, &invalid_id)
|
|
.await,
|
|
Err(RegistryError::InvalidArtifactSource { field: "source_id" })
|
|
));
|
|
assert!(matches!(
|
|
registry
|
|
.detach_artifact_source(DetachArtifactSourceRequest {
|
|
workspace_id: &workspace_a,
|
|
source_id: &invalid_id,
|
|
expected_updated_at: None,
|
|
detached_at: created_at,
|
|
})
|
|
.await,
|
|
Err(RegistryError::InvalidArtifactSource { field: "source_id" })
|
|
));
|
|
|
|
let detached_at = timestamp("2026-08-26T10:03:00Z");
|
|
let detached = registry
|
|
.detach_artifact_source(DetachArtifactSourceRequest {
|
|
workspace_id: &workspace_a,
|
|
source_id: &source_id,
|
|
expected_updated_at: Some(created.updated_at),
|
|
detached_at,
|
|
})
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(detached.lifecycle, ArtifactSourceLifecycle::Detached);
|
|
let retry = registry
|
|
.detach_artifact_source(DetachArtifactSourceRequest {
|
|
workspace_id: &workspace_a,
|
|
source_id: &source_id,
|
|
expected_updated_at: Some(created.updated_at),
|
|
detached_at,
|
|
})
|
|
.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 {
|
|
workspace_id: &workspace_a,
|
|
source_id: &source_id,
|
|
expected_updated_at: Some(created.updated_at),
|
|
detached_at: timestamp("2026-08-26T10:04:00Z"),
|
|
})
|
|
.await,
|
|
Err(RegistryError::SourceConflict { .. })
|
|
));
|
|
assert_eq!(
|
|
registry
|
|
.get_artifact_source(&workspace_a, &source_id)
|
|
.await
|
|
.unwrap()
|
|
.lifecycle,
|
|
ArtifactSourceLifecycle::Detached
|
|
);
|
|
assert!(matches!(
|
|
registry
|
|
.read_artifact_source(std::sync::Arc::new(store.clone()), &workspace_a, &source_id)
|
|
.await,
|
|
Err(RegistryError::SourceUnavailable)
|
|
));
|
|
assert_eq!(
|
|
store.read(registered.artifact_ref()).unwrap(),
|
|
b"openapi: 3.1.0\n"
|
|
);
|
|
|
|
let concurrent_id = ArtifactSourceId::new("src_concurrent");
|
|
let concurrent = registry
|
|
.create_artifact_source(CreateArtifactSourceRequest {
|
|
workspace_id: &workspace_a,
|
|
source_id: &concurrent_id,
|
|
artifact: ®istered,
|
|
mime_type: "application/yaml",
|
|
sensitivity: ArtifactSourceSensitivity::Internal,
|
|
created_at,
|
|
})
|
|
.await
|
|
.unwrap();
|
|
let first_registry = registry.clone();
|
|
let second_registry = registry.clone();
|
|
let first_workspace = workspace_a.clone();
|
|
let second_workspace = workspace_a.clone();
|
|
let first_id = concurrent_id.clone();
|
|
let second_id = concurrent_id.clone();
|
|
let (first_detach, second_detach) = tokio::join!(
|
|
async move {
|
|
first_registry
|
|
.detach_artifact_source(DetachArtifactSourceRequest {
|
|
workspace_id: &first_workspace,
|
|
source_id: &first_id,
|
|
expected_updated_at: Some(concurrent.updated_at),
|
|
detached_at: timestamp("2026-08-26T10:05:00Z"),
|
|
})
|
|
.await
|
|
},
|
|
async move {
|
|
second_registry
|
|
.detach_artifact_source(DetachArtifactSourceRequest {
|
|
workspace_id: &second_workspace,
|
|
source_id: &second_id,
|
|
expected_updated_at: Some(concurrent.updated_at),
|
|
detached_at: timestamp("2026-08-26T10:06:00Z"),
|
|
})
|
|
.await
|
|
}
|
|
);
|
|
assert_eq!(
|
|
usize::from(first_detach.is_ok()) + usize::from(second_detach.is_ok()),
|
|
1
|
|
);
|
|
assert_eq!(
|
|
usize::from(matches!(
|
|
first_detach,
|
|
Err(RegistryError::SourceConflict { .. })
|
|
)) + usize::from(matches!(
|
|
second_detach,
|
|
Err(RegistryError::SourceConflict { .. })
|
|
)),
|
|
1
|
|
);
|
|
|
|
database.cleanup().await;
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn verified_read_returns_only_digest_and_size_verified_bytes() {
|
|
let database = TestDatabase::new().await;
|
|
let registry = database.registry().await;
|
|
let workspace_id = create_workspace(®istry, "ws_verified_read").await;
|
|
let root = TestRoot::new("verified-read");
|
|
let store = ArtifactStore::open(&root.0).unwrap();
|
|
let bytes = b"openapi: 3.1.0\ninfo: {}\n";
|
|
let registered = store.put_registered(bytes).unwrap();
|
|
let valid_id = ArtifactSourceId::new("src_valid");
|
|
registry
|
|
.create_artifact_source(CreateArtifactSourceRequest {
|
|
workspace_id: &workspace_id,
|
|
source_id: &valid_id,
|
|
artifact: ®istered,
|
|
mime_type: "application/yaml",
|
|
sensitivity: ArtifactSourceSensitivity::Secret,
|
|
created_at: timestamp("2026-08-26T11:00:00Z"),
|
|
})
|
|
.await
|
|
.unwrap();
|
|
let verified = registry
|
|
.read_artifact_source(std::sync::Arc::new(store.clone()), &workspace_id, &valid_id)
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(verified.bytes, bytes);
|
|
|
|
let raw_pool = database.raw_pool().await;
|
|
let missing_ref = ArtifactRef::from_digest_hex(&"f".repeat(64)).unwrap();
|
|
let missing_id = ArtifactSourceId::new("src_missing");
|
|
sqlx::query(
|
|
"insert into artifact_blobs
|
|
(digest, artifact_ref, size_bytes, storage_lifecycle, created_at, updated_at)
|
|
values ($1, $2, 12, 'available', $3, $3)",
|
|
)
|
|
.bind(missing_ref.digest_hex())
|
|
.bind(missing_ref.as_str())
|
|
.bind(timestamp("2026-08-26T11:01:00Z"))
|
|
.execute(&raw_pool)
|
|
.await
|
|
.unwrap();
|
|
sqlx::query(
|
|
"insert into artifact_sources
|
|
(workspace_id, source_id, blob_digest, mime_type, sensitivity, lifecycle,
|
|
created_at, updated_at)
|
|
values ($1, $2, $3, 'application/yaml', 'internal', 'active', $4, $4)",
|
|
)
|
|
.bind(workspace_id.as_str())
|
|
.bind(missing_id.as_str())
|
|
.bind(missing_ref.digest_hex())
|
|
.bind(timestamp("2026-08-26T11:01:00Z"))
|
|
.execute(&raw_pool)
|
|
.await
|
|
.unwrap();
|
|
assert!(matches!(
|
|
registry
|
|
.read_artifact_source(
|
|
std::sync::Arc::new(store.clone()),
|
|
&workspace_id,
|
|
&missing_id
|
|
)
|
|
.await,
|
|
Err(RegistryError::SourceUnavailable)
|
|
));
|
|
assert!(
|
|
registry
|
|
.get_artifact_source(&workspace_id, &missing_id)
|
|
.await
|
|
.is_ok()
|
|
);
|
|
|
|
let wrong_size_id = ArtifactSourceId::new("src_wrong_size");
|
|
let wrong_size = store.put_registered(b"different source").unwrap();
|
|
registry
|
|
.create_artifact_source(CreateArtifactSourceRequest {
|
|
workspace_id: &workspace_id,
|
|
source_id: &wrong_size_id,
|
|
artifact: &wrong_size,
|
|
mime_type: "application/yaml",
|
|
sensitivity: ArtifactSourceSensitivity::Internal,
|
|
created_at: timestamp("2026-08-26T11:02:00Z"),
|
|
})
|
|
.await
|
|
.unwrap();
|
|
sqlx::query("update artifact_blobs set size_bytes = size_bytes + 1 where digest = $1")
|
|
.bind(wrong_size.artifact_ref().digest_hex())
|
|
.execute(&raw_pool)
|
|
.await
|
|
.unwrap();
|
|
assert!(matches!(
|
|
registry
|
|
.read_artifact_source(
|
|
std::sync::Arc::new(store.clone()),
|
|
&workspace_id,
|
|
&wrong_size_id
|
|
)
|
|
.await,
|
|
Err(RegistryError::SourceIntegrity)
|
|
));
|
|
|
|
let unavailable = store.put_registered(b"temporarily unavailable").unwrap();
|
|
let unavailable_id = ArtifactSourceId::new("src_unavailable");
|
|
registry
|
|
.create_artifact_source(CreateArtifactSourceRequest {
|
|
workspace_id: &workspace_id,
|
|
source_id: &unavailable_id,
|
|
artifact: &unavailable,
|
|
mime_type: "text/plain",
|
|
sensitivity: ArtifactSourceSensitivity::Internal,
|
|
created_at: timestamp("2026-08-26T11:03:00Z"),
|
|
})
|
|
.await
|
|
.unwrap();
|
|
sqlx::query("update artifact_blobs set storage_lifecycle = 'unavailable' where digest = $1")
|
|
.bind(unavailable.artifact_ref().digest_hex())
|
|
.execute(&raw_pool)
|
|
.await
|
|
.unwrap();
|
|
assert!(matches!(
|
|
registry
|
|
.read_artifact_source(
|
|
std::sync::Arc::new(store.clone()),
|
|
&workspace_id,
|
|
&unavailable_id
|
|
)
|
|
.await,
|
|
Err(RegistryError::SourceUnavailable)
|
|
));
|
|
|
|
let tampered = root
|
|
.0
|
|
.join("sha256")
|
|
.join(registered.artifact_ref().digest_hex().get(..2).unwrap())
|
|
.join(registered.artifact_ref().digest_hex());
|
|
fs::set_permissions(&tampered, fs::Permissions::from_mode(0o600)).unwrap();
|
|
fs::write(&tampered, vec![b'x'; bytes.len()]).unwrap();
|
|
assert!(matches!(
|
|
registry
|
|
.read_artifact_source(std::sync::Arc::new(store.clone()), &workspace_id, &valid_id)
|
|
.await,
|
|
Err(RegistryError::SourceIntegrity)
|
|
));
|
|
assert!(
|
|
registry
|
|
.get_artifact_source(&workspace_id, &valid_id)
|
|
.await
|
|
.is_ok()
|
|
);
|
|
|
|
database.cleanup().await;
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn reconciliation_claims_are_fenced_global_and_allow_verified_revival() {
|
|
let database = TestDatabase::new().await;
|
|
let registry = database.registry().await;
|
|
let raw_pool = database.raw_pool().await;
|
|
let workspace_a = create_workspace(®istry, "ws_claim_a").await;
|
|
let workspace_b = create_workspace(®istry, "ws_claim_b").await;
|
|
let root = TestRoot::new("claims");
|
|
let store = ArtifactStore::open(&root.0).unwrap();
|
|
let unreferenced = store.put_registered(b"unreferenced\n").unwrap();
|
|
let started = timestamp("2026-08-27T10:00:00Z");
|
|
let lease = timestamp("2026-08-27T10:05:00Z");
|
|
let token = ArtifactClaimToken::generate();
|
|
assert_eq!(format!("{token:?}"), "ArtifactClaimToken(..)");
|
|
|
|
let request = ClaimArtifactReconciliationRequest {
|
|
artifact: &unreferenced,
|
|
token: token.clone(),
|
|
claimed_at: started,
|
|
lease_expires_at: lease,
|
|
detached_grace: time::Duration::hours(24),
|
|
};
|
|
assert_eq!(
|
|
format!("{request:?}"),
|
|
"ClaimArtifactReconciliationRequest(..)"
|
|
);
|
|
let claim = match registry
|
|
.claim_artifact_reconciliation(request)
|
|
.await
|
|
.unwrap()
|
|
{
|
|
ArtifactClaimOutcome::Claimed(claim) => claim,
|
|
outcome => panic!("unexpected claim outcome: {outcome:?}"),
|
|
};
|
|
assert_eq!(format!("{claim:?}"), "ArtifactReconciliationClaim(..)");
|
|
assert!(matches!(
|
|
registry
|
|
.claim_artifact_reconciliation(ClaimArtifactReconciliationRequest {
|
|
artifact: &unreferenced,
|
|
token,
|
|
claimed_at: timestamp("2026-08-27T10:01:00Z"),
|
|
lease_expires_at: lease,
|
|
detached_grace: time::Duration::hours(24),
|
|
})
|
|
.await
|
|
.unwrap(),
|
|
ArtifactClaimOutcome::Claimed(_)
|
|
));
|
|
assert_eq!(
|
|
registry
|
|
.recheck_artifact_reconciliation_claim(&claim, started, time::Duration::hours(24))
|
|
.await
|
|
.unwrap(),
|
|
ArtifactClaimRecheckOutcome::Mutate
|
|
);
|
|
assert!(matches!(
|
|
registry
|
|
.create_artifact_source(CreateArtifactSourceRequest {
|
|
workspace_id: &workspace_a,
|
|
source_id: &ArtifactSourceId::new("src_blocked"),
|
|
artifact: &unreferenced,
|
|
mime_type: "text/plain",
|
|
sensitivity: ArtifactSourceSensitivity::Internal,
|
|
created_at: started,
|
|
})
|
|
.await,
|
|
Err(RegistryError::ArtifactClaimInProgress)
|
|
));
|
|
assert_eq!(
|
|
registry
|
|
.finalize_artifact_reconciliation_claim(
|
|
&claim,
|
|
ArtifactClaimFinalization::Quarantined,
|
|
timestamp("2026-08-27T10:01:00Z"),
|
|
)
|
|
.await
|
|
.unwrap(),
|
|
ArtifactClaimFinalizeOutcome::Retained
|
|
);
|
|
|
|
let final_candidate = reconciliation_candidate_for(&store, &unreferenced);
|
|
assert!(matches!(
|
|
store.quarantine_reconciliation(final_candidate),
|
|
Ok(ReconciliationMutation::Quarantined | ReconciliationMutation::AlreadyQuarantined)
|
|
));
|
|
let quarantined_candidate = reconciliation_candidate_for(&store, &unreferenced);
|
|
assert!(matches!(
|
|
store.delete_quarantined_reconciliation(quarantined_candidate),
|
|
Ok(ReconciliationMutation::Deleted | ReconciliationMutation::AlreadyAbsent)
|
|
));
|
|
assert_eq!(
|
|
store
|
|
.reconciliation_presence(unreferenced.artifact_ref())
|
|
.unwrap(),
|
|
ReconciliationPresence::Absent
|
|
);
|
|
|
|
sqlx::query(
|
|
"update artifact_blobs
|
|
set claim_expires_at = clock_timestamp() - interval '1 second'
|
|
where digest = $1",
|
|
)
|
|
.bind(unreferenced.artifact_ref().digest_hex())
|
|
.execute(&raw_pool)
|
|
.await
|
|
.unwrap();
|
|
let recovery_now = registry.artifact_reconciliation_now().await.unwrap();
|
|
let probes = registry
|
|
.list_expired_artifact_reconciliation_probes(recovery_now, 1)
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(
|
|
format!("{:?}", probes.first().expect("expired claim probe")),
|
|
"ArtifactExpiredClaimProbe(..)"
|
|
);
|
|
let winner = registry
|
|
.claim_expired_artifact_reconciliation(
|
|
probes.first().expect("expired claim probe"),
|
|
ClaimExpiredArtifactReconciliationRequest {
|
|
token: ArtifactClaimToken::generate(),
|
|
claimed_at: recovery_now,
|
|
lease_expires_at: recovery_now + time::Duration::minutes(5),
|
|
},
|
|
)
|
|
.await
|
|
.unwrap()
|
|
.expect("expired claim was not recovered");
|
|
assert_eq!(
|
|
registry
|
|
.finalize_artifact_reconciliation_claim(
|
|
&claim,
|
|
ArtifactClaimFinalization::Deleted,
|
|
timestamp("2026-08-27T10:05:01Z"),
|
|
)
|
|
.await
|
|
.unwrap(),
|
|
ArtifactClaimFinalizeOutcome::Stale
|
|
);
|
|
assert_eq!(
|
|
registry
|
|
.recover_artifact_reconciliation_claim(
|
|
&winner,
|
|
ArtifactClaimFinalization::AlreadyAbsent,
|
|
timestamp("2026-08-27T10:05:02Z"),
|
|
)
|
|
.await
|
|
.unwrap(),
|
|
ArtifactClaimFinalizeOutcome::Unavailable
|
|
);
|
|
let republished = store.put_registered(b"unreferenced\n").unwrap();
|
|
let revived = registry
|
|
.create_artifact_source(CreateArtifactSourceRequest {
|
|
workspace_id: &workspace_a,
|
|
source_id: &ArtifactSourceId::new("src_revived"),
|
|
artifact: &republished,
|
|
mime_type: "text/plain",
|
|
sensitivity: ArtifactSourceSensitivity::Internal,
|
|
created_at: timestamp("2026-08-27T10:06:00Z"),
|
|
})
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(
|
|
revived.blob.lifecycle,
|
|
crank_registry::ArtifactBlobLifecycle::Available
|
|
);
|
|
|
|
let referenced = store.put_registered(b"referenced\n").unwrap();
|
|
let active_id = ArtifactSourceId::new("src_active");
|
|
let active = registry
|
|
.create_artifact_source(CreateArtifactSourceRequest {
|
|
workspace_id: &workspace_b,
|
|
source_id: &active_id,
|
|
artifact: &referenced,
|
|
mime_type: "text/plain",
|
|
sensitivity: ArtifactSourceSensitivity::Internal,
|
|
created_at: started,
|
|
})
|
|
.await
|
|
.unwrap();
|
|
assert!(matches!(
|
|
registry
|
|
.claim_artifact_reconciliation(ClaimArtifactReconciliationRequest {
|
|
artifact: &referenced,
|
|
token: ArtifactClaimToken::generate(),
|
|
claimed_at: timestamp("2026-08-27T10:01:00Z"),
|
|
lease_expires_at: lease,
|
|
detached_grace: time::Duration::hours(24),
|
|
})
|
|
.await
|
|
.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,
|
|
})
|
|
.await
|
|
.unwrap();
|
|
assert!(matches!(
|
|
registry
|
|
.claim_artifact_reconciliation(ClaimArtifactReconciliationRequest {
|
|
artifact: &referenced,
|
|
token: ArtifactClaimToken::generate(),
|
|
claimed_at: timestamp("2026-08-27T10:03:00Z"),
|
|
lease_expires_at: lease,
|
|
detached_grace: time::Duration::hours(24),
|
|
})
|
|
.await
|
|
.unwrap(),
|
|
ArtifactClaimOutcome::DetachedReferenceInGrace
|
|
));
|
|
|
|
let concurrent = store.put_registered(b"concurrent claim\n").unwrap();
|
|
let first_registry = registry.clone();
|
|
let second_registry = registry.clone();
|
|
let first_artifact = concurrent.clone();
|
|
let second_artifact = concurrent.clone();
|
|
let (first, second) = tokio::join!(
|
|
async move {
|
|
first_registry
|
|
.claim_artifact_reconciliation(ClaimArtifactReconciliationRequest {
|
|
artifact: &first_artifact,
|
|
token: ArtifactClaimToken::generate(),
|
|
claimed_at: timestamp("2026-08-27T11:00:00Z"),
|
|
lease_expires_at: timestamp("2026-08-27T11:05:00Z"),
|
|
detached_grace: time::Duration::hours(24),
|
|
})
|
|
.await
|
|
},
|
|
async move {
|
|
second_registry
|
|
.claim_artifact_reconciliation(ClaimArtifactReconciliationRequest {
|
|
artifact: &second_artifact,
|
|
token: ArtifactClaimToken::generate(),
|
|
claimed_at: timestamp("2026-08-27T11:00:00Z"),
|
|
lease_expires_at: timestamp("2026-08-27T11:05:00Z"),
|
|
detached_grace: time::Duration::hours(24),
|
|
})
|
|
.await
|
|
}
|
|
);
|
|
let first = first.unwrap();
|
|
let second = second.unwrap();
|
|
assert_eq!(
|
|
usize::from(matches!(first, ArtifactClaimOutcome::Claimed(_)))
|
|
+ usize::from(matches!(second, ArtifactClaimOutcome::Claimed(_))),
|
|
1
|
|
);
|
|
assert_eq!(
|
|
usize::from(matches!(first, ArtifactClaimOutcome::HeldByOther))
|
|
+ usize::from(matches!(second, ArtifactClaimOutcome::HeldByOther)),
|
|
1
|
|
);
|
|
|
|
let attach_race = store.put_registered(b"attach race\n").unwrap();
|
|
let claim_registry = registry.clone();
|
|
let attach_registry = registry.clone();
|
|
let claim_artifact = attach_race.clone();
|
|
let attach_artifact = attach_race.clone();
|
|
let attach_workspace = workspace_a.clone();
|
|
let (claim_race, attach_race) = tokio::join!(
|
|
async move {
|
|
claim_registry
|
|
.claim_artifact_reconciliation(ClaimArtifactReconciliationRequest {
|
|
artifact: &claim_artifact,
|
|
token: ArtifactClaimToken::generate(),
|
|
claimed_at: timestamp("2026-08-27T12:00:00Z"),
|
|
lease_expires_at: timestamp("2026-08-27T12:05:00Z"),
|
|
detached_grace: time::Duration::hours(24),
|
|
})
|
|
.await
|
|
},
|
|
async move {
|
|
attach_registry
|
|
.create_artifact_source(CreateArtifactSourceRequest {
|
|
workspace_id: &attach_workspace,
|
|
source_id: &ArtifactSourceId::new("src_attach_race"),
|
|
artifact: &attach_artifact,
|
|
mime_type: "text/plain",
|
|
sensitivity: ArtifactSourceSensitivity::Internal,
|
|
created_at: timestamp("2026-08-27T12:00:00Z"),
|
|
})
|
|
.await
|
|
}
|
|
);
|
|
match (claim_race.unwrap(), attach_race) {
|
|
(ArtifactClaimOutcome::ActiveReference, Ok(_)) => {}
|
|
(ArtifactClaimOutcome::Claimed(_), Err(RegistryError::ArtifactClaimInProgress)) => {}
|
|
(claim, attach) => panic!("unsafe claim/attach race outcome: {claim:?}, {attach:?}"),
|
|
}
|
|
|
|
database.cleanup().await;
|
|
}
|