fix(openapi): harden story 2.1 production lifecycle
This commit is contained in:
@@ -4,7 +4,7 @@ use std::{
|
||||
time::{Duration, SystemTime, UNIX_EPOCH},
|
||||
};
|
||||
|
||||
use crate::temp_scan::{list_names, valid_temp_name};
|
||||
use crate::temp_scan::{list_names_after, valid_temp_name};
|
||||
use crate::{
|
||||
ArtifactError, ArtifactStore, ReconciliationMutation, ReconciliationNamespace,
|
||||
ReconciliationScan, ReconciliationScanStop,
|
||||
@@ -94,10 +94,33 @@ pub struct StaleTemp {
|
||||
grace: Duration,
|
||||
}
|
||||
|
||||
/// Opaque, inode-bound progress marker for the bounded stale-temp sweeper.
|
||||
///
|
||||
/// It deliberately advances by shard instead of retaining a directory offset:
|
||||
/// directory offsets are invalidated by a concurrent writer, while round-robin
|
||||
/// shard progress prevents a busy low-numbered shard from starving all others.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct TempScanCursor {
|
||||
root_dev: u64,
|
||||
root_ino: u64,
|
||||
next_shard: u8,
|
||||
shard_continuation: Option<TempShardContinuation>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
struct TempShardContinuation {
|
||||
shard: u8,
|
||||
dev: u64,
|
||||
ino: u64,
|
||||
cookie: i64,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
|
||||
pub struct TempScan {
|
||||
pub scanned: usize,
|
||||
pub omitted: usize,
|
||||
/// True when this page completed a full round-robin traversal.
|
||||
pub complete: bool,
|
||||
}
|
||||
|
||||
impl ArtifactStore {
|
||||
@@ -324,27 +347,93 @@ impl ArtifactStore {
|
||||
scan_budget: usize,
|
||||
result_limit: usize,
|
||||
) -> Result<(TempScan, Vec<StaleTemp>), ArtifactError> {
|
||||
self.scan_stale_temps_after(grace, None, scan_budget, result_limit)
|
||||
.map(|(report, candidates, _)| (report, candidates))
|
||||
}
|
||||
|
||||
/// Resumes stale-temp cleanup from an opaque round-robin marker. A
|
||||
/// continuation is valid only for this exact pinned root.
|
||||
pub fn scan_stale_temps_after(
|
||||
&self,
|
||||
grace: Duration,
|
||||
continuation: Option<TempScanCursor>,
|
||||
scan_budget: usize,
|
||||
result_limit: usize,
|
||||
) -> Result<(TempScan, Vec<StaleTemp>, TempScanCursor), ArtifactError> {
|
||||
let root = self.root()?;
|
||||
let _lock = RootLock::shared(root)?;
|
||||
ensure_root_unchanged(root)?;
|
||||
let sha = match open_existing_dir(root.fd.as_raw_fd(), b"sha256") {
|
||||
Ok(fd) => fd,
|
||||
Err(ArtifactError::NotFound) => return Ok((TempScan::default(), Vec::new())),
|
||||
Err(ArtifactError::NotFound) => {
|
||||
return Ok((
|
||||
TempScan {
|
||||
complete: true,
|
||||
..TempScan::default()
|
||||
},
|
||||
Vec::new(),
|
||||
TempScanCursor {
|
||||
root_dev: root.dev,
|
||||
root_ino: root.ino,
|
||||
next_shard: 0,
|
||||
shard_continuation: None,
|
||||
},
|
||||
));
|
||||
}
|
||||
Err(error) => return Err(error),
|
||||
};
|
||||
let (start_shard, mut shard_continuation) = match continuation {
|
||||
Some(cursor) if cursor.root_dev == root.dev && cursor.root_ino == root.ino => {
|
||||
(cursor.next_shard, cursor.shard_continuation)
|
||||
}
|
||||
Some(_) => return Err(ArtifactError::UnsafeRoot),
|
||||
None => (0, None),
|
||||
};
|
||||
let mut report = TempScan::default();
|
||||
let mut remaining = scan_budget;
|
||||
let mut result = Vec::new();
|
||||
for shard in (0_u8..=255).map(|value| format!("{value:02x}")) {
|
||||
if remaining == 0 {
|
||||
return Ok((
|
||||
report,
|
||||
result,
|
||||
TempScanCursor {
|
||||
root_dev: root.dev,
|
||||
root_ino: root.ino,
|
||||
next_shard: start_shard,
|
||||
shard_continuation,
|
||||
},
|
||||
));
|
||||
}
|
||||
let mut next_shard = start_shard;
|
||||
for offset in 0_u16..=255 {
|
||||
if remaining == 0 {
|
||||
break;
|
||||
}
|
||||
let shard_number = start_shard.wrapping_add(offset as u8);
|
||||
next_shard = shard_number.wrapping_add(1);
|
||||
let shard = format!("{shard_number:02x}");
|
||||
let shard_fd = match open_existing_dir(sha.as_raw_fd(), shard.as_bytes()) {
|
||||
Ok(fd) => fd,
|
||||
Err(ArtifactError::NotFound) => continue,
|
||||
Err(ArtifactError::NotFound) => {
|
||||
shard_continuation = None;
|
||||
continue;
|
||||
}
|
||||
Err(error) => return Err(error),
|
||||
};
|
||||
for name in list_names(shard_fd.as_raw_fd(), &mut remaining, &mut report)? {
|
||||
let stat = stat_fd(shard_fd.as_raw_fd())?;
|
||||
let resume = shard_continuation.take().and_then(|continuation| {
|
||||
if continuation.shard == shard_number
|
||||
&& continuation.dev == stat.st_dev
|
||||
&& continuation.ino == stat.st_ino
|
||||
{
|
||||
Some(continuation.cookie)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
});
|
||||
let page = list_names_after(shard_fd.as_raw_fd(), &mut remaining, &mut report, resume)?;
|
||||
for entry in page.names {
|
||||
let name = entry.name;
|
||||
if !valid_temp_name(&name) {
|
||||
continue;
|
||||
}
|
||||
@@ -370,6 +459,23 @@ impl ArtifactStore {
|
||||
{
|
||||
if result.len() == result_limit {
|
||||
report.omitted += 1;
|
||||
if result_limit > 0 {
|
||||
return Ok((
|
||||
report,
|
||||
result,
|
||||
TempScanCursor {
|
||||
root_dev: root.dev,
|
||||
root_ino: root.ino,
|
||||
next_shard: shard_number,
|
||||
shard_continuation: Some(TempShardContinuation {
|
||||
shard: shard_number,
|
||||
dev: stat.st_dev,
|
||||
ino: stat.st_ino,
|
||||
cookie: entry.cookie_before,
|
||||
}),
|
||||
},
|
||||
));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
result.push(StaleTemp {
|
||||
@@ -385,8 +491,35 @@ impl ArtifactStore {
|
||||
});
|
||||
}
|
||||
}
|
||||
if let Some(cookie) = page.continuation {
|
||||
return Ok((
|
||||
report,
|
||||
result,
|
||||
TempScanCursor {
|
||||
root_dev: root.dev,
|
||||
root_ino: root.ino,
|
||||
next_shard: shard_number,
|
||||
shard_continuation: Some(TempShardContinuation {
|
||||
shard: shard_number,
|
||||
dev: stat.st_dev,
|
||||
ino: stat.st_ino,
|
||||
cookie,
|
||||
}),
|
||||
},
|
||||
));
|
||||
}
|
||||
}
|
||||
Ok((report, result))
|
||||
report.complete = true;
|
||||
Ok((
|
||||
report,
|
||||
result,
|
||||
TempScanCursor {
|
||||
root_dev: root.dev,
|
||||
root_ino: root.ino,
|
||||
next_shard,
|
||||
shard_continuation: None,
|
||||
},
|
||||
))
|
||||
}
|
||||
|
||||
/// Deletes a revalidated stale inode under an exclusive lock, then fsyncs.
|
||||
|
||||
@@ -20,7 +20,9 @@ mod temp_scan;
|
||||
pub mod test_support;
|
||||
|
||||
pub use error::ArtifactError;
|
||||
pub use housekeeping::{ReconciliationCandidate, ReconciliationCursor, StaleTemp, TempScan};
|
||||
pub use housekeeping::{
|
||||
ReconciliationCandidate, ReconciliationCursor, StaleTemp, TempScan, TempScanCursor,
|
||||
};
|
||||
pub use model::{
|
||||
ArtifactRef, MAX_ARTIFACT_BYTES, MAX_SOURCE_BYTES, ReconciliationMutation,
|
||||
ReconciliationNamespace, ReconciliationPresence, ReconciliationRegistration,
|
||||
|
||||
@@ -80,6 +80,15 @@ impl ArtifactStore {
|
||||
})
|
||||
}
|
||||
|
||||
/// Verifies that the pinned root is still the same private directory that
|
||||
/// was accepted at startup. This does not open data by pathname and is
|
||||
/// suitable for a process readiness probe.
|
||||
pub fn check_health(&self) -> Result<(), ArtifactError> {
|
||||
let root = self.root()?;
|
||||
let _lock = RootLock::shared(root)?;
|
||||
ensure_root_unchanged(root)
|
||||
}
|
||||
|
||||
/// Compatibility constructor. I/O returns the opening error fail-closed.
|
||||
pub fn new(root: impl AsRef<Path>) -> Self {
|
||||
Self {
|
||||
|
||||
@@ -1,5 +1,19 @@
|
||||
use crate::{ArtifactError, ArtifactStore, TempScan};
|
||||
|
||||
pub(crate) struct NamePage {
|
||||
pub(crate) names: Vec<NameEntry>,
|
||||
/// Opaque `telldir` position immediately after the last consumed entry.
|
||||
/// It is meaningful only for the same opened directory inode.
|
||||
pub(crate) continuation: Option<i64>,
|
||||
}
|
||||
|
||||
pub(crate) struct NameEntry {
|
||||
pub(crate) name: String,
|
||||
/// Directory position immediately before this entry. Resuming here
|
||||
/// guarantees a candidate omitted by a result limit is reconsidered.
|
||||
pub(crate) cookie_before: i64,
|
||||
}
|
||||
|
||||
pub(crate) fn valid_temp_name(name: &str) -> bool {
|
||||
let Some(rest) = name.strip_prefix(ArtifactStore::temp_prefix()) else {
|
||||
return false;
|
||||
@@ -20,11 +34,12 @@ pub(crate) fn valid_temp_name(name: &str) -> bool {
|
||||
&& sequence.bytes().all(|byte| byte.is_ascii_digit())
|
||||
}
|
||||
|
||||
pub(crate) fn list_names(
|
||||
pub(crate) fn list_names_after(
|
||||
parent: i32,
|
||||
remaining: &mut usize,
|
||||
report: &mut TempScan,
|
||||
) -> Result<Vec<String>, ArtifactError> {
|
||||
continuation: Option<i64>,
|
||||
) -> Result<NamePage, ArtifactError> {
|
||||
let dot = std::ffi::CString::new(".").map_err(|_| ArtifactError::Storage)?;
|
||||
let raw = unsafe {
|
||||
libc::openat(
|
||||
@@ -44,13 +59,32 @@ pub(crate) fn list_names(
|
||||
return Err(ArtifactError::Storage);
|
||||
}
|
||||
let mut names = Vec::new();
|
||||
if let Some(cookie) = continuation {
|
||||
unsafe {
|
||||
libc::seekdir(directory, cookie as libc::c_long);
|
||||
}
|
||||
}
|
||||
loop {
|
||||
if *remaining == 0 {
|
||||
break;
|
||||
let continuation = unsafe { libc::telldir(directory) };
|
||||
unsafe {
|
||||
libc::closedir(directory);
|
||||
}
|
||||
return Ok(NamePage {
|
||||
names,
|
||||
continuation: (continuation >= 0).then_some(continuation as i64),
|
||||
});
|
||||
}
|
||||
unsafe {
|
||||
*libc::__errno_location() = 0;
|
||||
}
|
||||
let cookie_before = unsafe { libc::telldir(directory) };
|
||||
if cookie_before < 0 {
|
||||
unsafe {
|
||||
libc::closedir(directory);
|
||||
}
|
||||
return Err(ArtifactError::Storage);
|
||||
}
|
||||
let entry = unsafe { libc::readdir(directory) };
|
||||
if entry.is_null() {
|
||||
if unsafe { *libc::__errno_location() } != 0 {
|
||||
@@ -59,7 +93,13 @@ pub(crate) fn list_names(
|
||||
}
|
||||
return Err(ArtifactError::Storage);
|
||||
}
|
||||
break;
|
||||
unsafe {
|
||||
libc::closedir(directory);
|
||||
}
|
||||
return Ok(NamePage {
|
||||
names,
|
||||
continuation: None,
|
||||
});
|
||||
}
|
||||
let name = unsafe { std::ffi::CStr::from_ptr((*entry).d_name.as_ptr()) }.to_bytes();
|
||||
if name == b"." || name == b".." {
|
||||
@@ -68,11 +108,10 @@ pub(crate) fn list_names(
|
||||
report.scanned += 1;
|
||||
*remaining -= 1;
|
||||
if let Ok(name) = std::str::from_utf8(name) {
|
||||
names.push(name.to_owned());
|
||||
names.push(NameEntry {
|
||||
name: name.to_owned(),
|
||||
cookie_before: cookie_before as i64,
|
||||
});
|
||||
}
|
||||
}
|
||||
unsafe {
|
||||
libc::closedir(directory);
|
||||
}
|
||||
Ok(names)
|
||||
}
|
||||
|
||||
@@ -360,6 +360,119 @@ fn housekeeping_enforces_result_limit_after_stale_validation() {
|
||||
assert!(temp_paths.iter().all(|path| path.exists()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stale_temp_cleanup_round_robins_past_a_busy_early_shard() {
|
||||
let root = TestRoot::new("cleanup-round-robin");
|
||||
let store = ArtifactStore::open(&root.0).unwrap();
|
||||
let sha = root.0.join("sha256");
|
||||
let early = sha.join("00");
|
||||
let late = sha.join("ff");
|
||||
fs::create_dir(&sha).unwrap();
|
||||
fs::create_dir(&early).unwrap();
|
||||
fs::create_dir(&late).unwrap();
|
||||
for directory in [&sha, &early, &late] {
|
||||
fs::set_permissions(directory, fs::Permissions::from_mode(0o700)).unwrap();
|
||||
}
|
||||
for index in 0..5 {
|
||||
let entry = early.join(format!("unrelated-{index}"));
|
||||
fs::write(&entry, b"not a temp").unwrap();
|
||||
fs::set_permissions(entry, fs::Permissions::from_mode(0o400)).unwrap();
|
||||
}
|
||||
let stale = late.join(".crank-artifact-tmp-v1-00000000000000000000000000000000-1-1");
|
||||
fs::write(&stale, b"partial").unwrap();
|
||||
fs::set_permissions(&stale, fs::Permissions::from_mode(0o400)).unwrap();
|
||||
|
||||
let (_, first, cursor) = store
|
||||
.scan_stale_temps_after(Duration::ZERO, None, 5, 1)
|
||||
.unwrap();
|
||||
assert!(first.is_empty());
|
||||
|
||||
let (_, second, _) = store
|
||||
.scan_stale_temps_after(Duration::ZERO, Some(cursor), 5, 1)
|
||||
.unwrap();
|
||||
assert_eq!(second.len(), 1);
|
||||
store
|
||||
.delete_stale_temp(second.into_iter().next().unwrap())
|
||||
.unwrap();
|
||||
assert!(!stale.exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stale_temp_cleanup_resumes_inside_a_busy_shard() {
|
||||
let root = TestRoot::new("cleanup-intra-shard");
|
||||
let store = ArtifactStore::open(&root.0).unwrap();
|
||||
let sha = root.0.join("sha256");
|
||||
let shard = sha.join("00");
|
||||
fs::create_dir(&sha).unwrap();
|
||||
fs::create_dir(&shard).unwrap();
|
||||
for directory in [&sha, &shard] {
|
||||
fs::set_permissions(directory, fs::Permissions::from_mode(0o700)).unwrap();
|
||||
}
|
||||
for index in 0..12 {
|
||||
let entry = shard.join(format!("unrelated-{index}"));
|
||||
fs::write(&entry, b"not a temp").unwrap();
|
||||
fs::set_permissions(entry, fs::Permissions::from_mode(0o400)).unwrap();
|
||||
}
|
||||
let stale = shard.join(".crank-artifact-tmp-v1-00000000000000000000000000000000-1-2");
|
||||
fs::write(&stale, b"partial").unwrap();
|
||||
fs::set_permissions(&stale, fs::Permissions::from_mode(0o400)).unwrap();
|
||||
|
||||
let mut cursor = None;
|
||||
let mut discovered = None;
|
||||
for _ in 0..4 {
|
||||
let (_, candidates, next_cursor) = store
|
||||
.scan_stale_temps_after(Duration::ZERO, cursor, 5, 1)
|
||||
.unwrap();
|
||||
if let Some(candidate) = candidates.into_iter().next() {
|
||||
discovered = Some(candidate);
|
||||
break;
|
||||
}
|
||||
cursor = Some(next_cursor);
|
||||
}
|
||||
let candidate = discovered.expect("the temp behind one scan budget is eventually discovered");
|
||||
store.delete_stale_temp(candidate).unwrap();
|
||||
assert!(!stale.exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stale_temp_cleanup_reconsiders_candidates_beyond_each_result_page() {
|
||||
let root = TestRoot::new("cleanup-result-continuation");
|
||||
let store = ArtifactStore::open(&root.0).unwrap();
|
||||
let sha = root.0.join("sha256");
|
||||
let shard = sha.join("00");
|
||||
fs::create_dir(&sha).unwrap();
|
||||
fs::create_dir(&shard).unwrap();
|
||||
for directory in [&sha, &shard] {
|
||||
fs::set_permissions(directory, fs::Permissions::from_mode(0o700)).unwrap();
|
||||
}
|
||||
for index in 0..20 {
|
||||
let temp = shard.join(format!(
|
||||
".crank-artifact-tmp-v1-00000000000000000000000000000000-1-{index}"
|
||||
));
|
||||
fs::write(&temp, b"partial").unwrap();
|
||||
fs::set_permissions(temp, fs::Permissions::from_mode(0o400)).unwrap();
|
||||
}
|
||||
|
||||
let mut cursor = None;
|
||||
let mut deleted = 0;
|
||||
for _ in 0..16 {
|
||||
let (scan, candidates, next_cursor) = store
|
||||
.scan_stale_temps_after(Duration::ZERO, cursor, 512, 3)
|
||||
.unwrap();
|
||||
for candidate in candidates {
|
||||
store.delete_stale_temp(candidate).unwrap();
|
||||
deleted += 1;
|
||||
}
|
||||
cursor = Some(next_cursor);
|
||||
if scan.complete {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
assert_eq!(deleted, 20, "every stale candidate must remain reachable");
|
||||
assert!(fs::read_dir(shard).unwrap().next().is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn housekeeping_revalidates_subsecond_mtime() {
|
||||
let root = TestRoot::new("cleanup-subsecond");
|
||||
@@ -479,6 +592,16 @@ fn open_rejects_non_private_root_and_existing_finals_must_be_immutable() {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn health_check_revalidates_the_pinned_root_permissions() {
|
||||
let root = TestRoot::new("health-check");
|
||||
let store = ArtifactStore::open(&root.0).unwrap();
|
||||
assert_eq!(store.check_health(), Ok(()));
|
||||
|
||||
fs::set_permissions(&root.0, fs::Permissions::from_mode(0o755)).unwrap();
|
||||
assert_eq!(store.check_health(), Err(ArtifactError::UnsafeRoot));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn open_rejects_symlinked_root() {
|
||||
let root = TestRoot::new("root-symlink-target");
|
||||
|
||||
@@ -3,6 +3,7 @@ use std::{
|
||||
os::unix::fs::{MetadataExt, PermissionsExt},
|
||||
path::PathBuf,
|
||||
sync::atomic::{AtomicU64, Ordering},
|
||||
sync::{Mutex, OnceLock},
|
||||
time::{Duration, SystemTime},
|
||||
};
|
||||
|
||||
@@ -19,8 +20,6 @@ use crank_artifacts::test_support::{
|
||||
FaultAction, checkpoint_hits, clear_checkpoint, reset_traversal_calls, set_checkpoint,
|
||||
set_checkpoint_on_hit, traversal_calls, wait_until_held,
|
||||
};
|
||||
#[cfg(debug_assertions)]
|
||||
use std::sync::{Mutex, OnceLock};
|
||||
|
||||
static NEXT_ROOT: AtomicU64 = AtomicU64::new(0);
|
||||
const FULL_SCAN_BUDGET: usize = 2048;
|
||||
@@ -47,7 +46,6 @@ impl Drop for TestRoot {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(debug_assertions)]
|
||||
fn fault_guard() -> std::sync::MutexGuard<'static, ()> {
|
||||
static GUARD: OnceLock<Mutex<()>> = OnceLock::new();
|
||||
GUARD.get_or_init(|| Mutex::new(())).lock().unwrap()
|
||||
@@ -55,6 +53,7 @@ fn fault_guard() -> std::sync::MutexGuard<'static, ()> {
|
||||
|
||||
#[test]
|
||||
fn paginates_final_entries_without_disclosing_locations() {
|
||||
let _guard = fault_guard();
|
||||
let root = TestRoot::new("pages");
|
||||
let store = ArtifactStore::open(&root.0).unwrap();
|
||||
store.put(b"reconciliation first").unwrap();
|
||||
@@ -116,6 +115,7 @@ fn paginates_final_entries_without_disclosing_locations() {
|
||||
|
||||
#[test]
|
||||
fn classifies_malformed_and_unsafe_entries_with_a_usable_page() {
|
||||
let _guard = fault_guard();
|
||||
let root = TestRoot::new("malformed");
|
||||
let store = ArtifactStore::open(&root.0).unwrap();
|
||||
let stored = store.put(b"reconciliation valid").unwrap();
|
||||
@@ -157,6 +157,7 @@ fn classifies_malformed_and_unsafe_entries_with_a_usable_page() {
|
||||
|
||||
#[test]
|
||||
fn skips_a_valid_publish_temp_without_classifying_it_as_malformed() {
|
||||
let _guard = fault_guard();
|
||||
let root = TestRoot::new("valid-temp");
|
||||
let store = ArtifactStore::open(&root.0).unwrap();
|
||||
let stored = store.put(b"reconciliation with concurrent temp").unwrap();
|
||||
@@ -181,6 +182,7 @@ fn skips_a_valid_publish_temp_without_classifying_it_as_malformed() {
|
||||
|
||||
#[test]
|
||||
fn unsafe_canonical_shard_does_not_starve_later_shards() {
|
||||
let _guard = fault_guard();
|
||||
let root = TestRoot::new("unsafe-shard");
|
||||
let store = ArtifactStore::open(&root.0).unwrap();
|
||||
let sha = root.0.join("sha256");
|
||||
@@ -212,6 +214,7 @@ fn unsafe_canonical_shard_does_not_starve_later_shards() {
|
||||
#[test]
|
||||
#[cfg(debug_assertions)]
|
||||
fn traversal_never_exceeds_the_exact_syscall_budget() {
|
||||
let _guard = fault_guard();
|
||||
let root = TestRoot::new("syscall-budget");
|
||||
let store = ArtifactStore::open(&root.0).unwrap();
|
||||
store.put(b"bounded traversal syscall accounting").unwrap();
|
||||
@@ -237,6 +240,7 @@ fn traversal_never_exceeds_the_exact_syscall_budget() {
|
||||
|
||||
#[test]
|
||||
fn rejects_a_cursor_from_another_store() {
|
||||
let _guard = fault_guard();
|
||||
let first_root = TestRoot::new("foreign-cursor-first");
|
||||
let second_root = TestRoot::new("foreign-cursor-second");
|
||||
let first = ArtifactStore::open(&first_root.0).unwrap();
|
||||
@@ -276,6 +280,7 @@ fn disappearing_entry_after_readdir_is_skipped() {
|
||||
|
||||
#[test]
|
||||
fn quarantine_and_delete_are_idempotent() {
|
||||
let _guard = fault_guard();
|
||||
let root = TestRoot::new("mutation");
|
||||
let store = ArtifactStore::open(&root.0).unwrap();
|
||||
let stored = store.put(b"reconciliation mutation").unwrap();
|
||||
@@ -320,6 +325,7 @@ fn quarantine_and_delete_are_idempotent() {
|
||||
|
||||
#[test]
|
||||
fn quarantine_preserves_old_inode_when_the_digest_is_republished() {
|
||||
let _guard = fault_guard();
|
||||
let root = TestRoot::new("no-clobber");
|
||||
let store = ArtifactStore::open(&root.0).unwrap();
|
||||
let stored = store.put(b"reconciliation no-clobber").unwrap();
|
||||
@@ -358,6 +364,7 @@ fn quarantine_preserves_old_inode_when_the_digest_is_republished() {
|
||||
|
||||
#[test]
|
||||
fn quarantine_refuses_to_replace_an_unrelated_inode() {
|
||||
let _guard = fault_guard();
|
||||
let root = TestRoot::new("unrelated-collision");
|
||||
let store = ArtifactStore::open(&root.0).unwrap();
|
||||
let stored = store.put(b"canonical final survives collision").unwrap();
|
||||
@@ -394,6 +401,7 @@ fn quarantine_refuses_to_replace_an_unrelated_inode() {
|
||||
|
||||
#[test]
|
||||
fn delete_fails_closed_for_a_replaced_or_hardlinked_quarantined_inode() {
|
||||
let _guard = fault_guard();
|
||||
let root = TestRoot::new("quarantine-replaced");
|
||||
let store = ArtifactStore::open(&root.0).unwrap();
|
||||
let stored = store
|
||||
@@ -431,6 +439,7 @@ fn delete_fails_closed_for_a_replaced_or_hardlinked_quarantined_inode() {
|
||||
|
||||
#[test]
|
||||
fn delete_revalidates_a_post_scan_hardlink_without_inode_replacement() {
|
||||
let _guard = fault_guard();
|
||||
let root = TestRoot::new("quarantine-hardlink");
|
||||
let store = ArtifactStore::open(&root.0).unwrap();
|
||||
let stored = store.put(b"same quarantined inode gains a link").unwrap();
|
||||
@@ -496,6 +505,7 @@ fn delete_fsync_ambiguity_is_retryable_and_recovers_as_absent() {
|
||||
|
||||
#[test]
|
||||
fn rejects_a_replaced_final_inode() {
|
||||
let _guard = fault_guard();
|
||||
let root = TestRoot::new("replaced");
|
||||
let store = ArtifactStore::open(&root.0).unwrap();
|
||||
let stored = store.put(b"reconciliation original").unwrap();
|
||||
@@ -773,6 +783,7 @@ fn crash_windows_are_recoverable() {
|
||||
|
||||
#[test]
|
||||
fn registration_revalidates_content_and_enforces_grace_without_candidate_identity_access() {
|
||||
let _guard = fault_guard();
|
||||
let root = TestRoot::new("registration");
|
||||
let store = ArtifactStore::open(&root.0).unwrap();
|
||||
let stored = store.put(b"reconciliation registration").unwrap();
|
||||
@@ -820,6 +831,7 @@ fn registration_revalidates_content_and_enforces_grace_without_candidate_identit
|
||||
|
||||
#[test]
|
||||
fn namespace_scan_can_finish_quarantine_recovery_before_final_sweep() {
|
||||
let _guard = fault_guard();
|
||||
let root = TestRoot::new("namespace-order");
|
||||
let store = ArtifactStore::open(&root.0).unwrap();
|
||||
store.put(b"quarantine-first recovery").unwrap();
|
||||
@@ -856,6 +868,7 @@ fn namespace_scan_can_finish_quarantine_recovery_before_final_sweep() {
|
||||
|
||||
#[test]
|
||||
fn namespace_scan_rejects_a_cursor_from_a_broader_scan() {
|
||||
let _guard = fault_guard();
|
||||
let root = TestRoot::new("namespace-cursor-scope");
|
||||
let store = ArtifactStore::open(&root.0).unwrap();
|
||||
let (report, _) = store.scan_reconciliation(None, 0, 1).unwrap();
|
||||
@@ -876,6 +889,7 @@ fn namespace_scan_rejects_a_cursor_from_a_broader_scan() {
|
||||
|
||||
#[test]
|
||||
fn full_scan_rejects_a_quarantine_only_cursor() {
|
||||
let _guard = fault_guard();
|
||||
let root = TestRoot::new("namespace-cursor-narrow");
|
||||
let store = ArtifactStore::open(&root.0).unwrap();
|
||||
let (report, _) = store
|
||||
@@ -893,6 +907,7 @@ fn full_scan_rejects_a_quarantine_only_cursor() {
|
||||
|
||||
#[test]
|
||||
fn presence_probe_distinguishes_final_quarantine_and_absent_without_disclosure() {
|
||||
let _guard = fault_guard();
|
||||
let root = TestRoot::new("presence");
|
||||
let store = ArtifactStore::open(&root.0).unwrap();
|
||||
let stored = store.put(b"presence probe").unwrap();
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user