icydb-core 0.200.0

IcyDB — A schema-first typed query engine and persistence runtime for Internet Computer canisters
Documentation
use super::*;
use crate::db::{
    Db, StoreAllocationIdentities, StoreAllocationIdentity, StoreRegistry,
    StoreRuntimeStorageCapabilities,
    commit::{CommitMemoryAllocation, commit_memory_handle},
    data::DataStore,
    index::IndexStore,
    journal::JournalTailStore,
    schema::SchemaStore,
};
use crate::error::{ErrorClass, ErrorDetail, ErrorOrigin, RecoveryErrorDetail};
use crate::testing::test_memory;
use crate::traits::{CanisterKind, Path};
use ic_memory::stable_structures::{Memory, VectorMemory};
use std::cell::RefCell;

crate::test_canister! {
    ident = DatabaseFormatTestCanister,
    commit_memory_id = crate::testing::test_commit_memory_id(),
}

crate::test_store! {
    ident = DatabaseFormatTestStore,
    canister = DatabaseFormatTestCanister,
}

const FORMAT_DATA_ALLOCATION: StoreAllocationIdentity =
    StoreAllocationIdentity::new(230, "icydb.test.database_format.data.v1");
const FORMAT_INDEX_ALLOCATION: StoreAllocationIdentity =
    StoreAllocationIdentity::new(231, "icydb.test.database_format.index.v1");
const FORMAT_SCHEMA_ALLOCATION: StoreAllocationIdentity =
    StoreAllocationIdentity::new(232, "icydb.test.database_format.schema.v1");
const FORMAT_JOURNAL_ALLOCATION: StoreAllocationIdentity =
    StoreAllocationIdentity::new(233, "icydb.test.database_format.journal.v1");

thread_local! {
    static FORMAT_DATA: RefCell<DataStore> =
        RefCell::new(DataStore::init_journaled(test_memory(230)));
    static FORMAT_INDEX: RefCell<IndexStore> =
        RefCell::new(IndexStore::init_journaled(test_memory(231)));
    static FORMAT_SCHEMA: RefCell<SchemaStore> =
        RefCell::new(SchemaStore::init_journaled(test_memory(232)));
    static FORMAT_JOURNAL: RefCell<JournalTailStore> =
        RefCell::new(JournalTailStore::init(test_memory(233)));
    static FORMAT_REGISTRY: StoreRegistry = {
        let mut registry = StoreRegistry::new();
        registry
            .register_journaled_store(
                DatabaseFormatTestStore::PATH,
                &FORMAT_DATA,
                &FORMAT_INDEX,
                &FORMAT_SCHEMA,
                &FORMAT_JOURNAL,
                StoreAllocationIdentities::new_journaled(
                    FORMAT_DATA_ALLOCATION,
                    FORMAT_INDEX_ALLOCATION,
                    FORMAT_SCHEMA_ALLOCATION,
                    FORMAT_JOURNAL_ALLOCATION,
                ),
                StoreRuntimeStorageCapabilities::journaled(),
            )
            .expect("database-format test registry should be valid");
        registry
    };
}

static FORMAT_DB: Db<DatabaseFormatTestCanister> = Db::new(&FORMAT_REGISTRY);

fn empty_store_roles() -> StoreRoleMemories<VectorMemory> {
    StoreRoleMemories {
        data: VectorMemory::default(),
        index: VectorMemory::default(),
        schema: VectorMemory::default(),
        journal: VectorMemory::default(),
    }
}

fn write_boot_bytes(memory: &impl Memory, bytes: &[u8; DATABASE_BOOT_RECORD_BYTES]) {
    if memory.size() == 0 {
        assert!(memory.grow(1) >= 0);
    }
    memory.write(0, bytes);
}

#[test]
fn current_database_boot_record_wire_vector_is_frozen() {
    assert_eq!(
        DatabaseBootRecord::current().encode(),
        [
            0x49, 0x43, 0x59, 0x44, 0x42, 0x42, 0x4f, 0x54, 0x00, 0x01, 0x01, 0xa9, 0xe0, 0x21,
            0x1a,
        ],
    );
    assert_eq!(crc32c(b"123456789"), 0xe306_9283);
}

#[test]
fn virgin_database_writes_current_boot_before_other_allocations() {
    let control = VectorMemory::default();
    let roles = [empty_store_roles()];

    admit_or_initialize_database_format(&control, &roles)
        .expect("virgin database should initialize");

    assert_eq!(control.size(), 1);
    assert!(matches!(
        inspect_boot_record(&control).expect("boot record should decode"),
        BootInspection::Present(DatabaseBootRecord {
            format_version: DATABASE_FORMAT_VERSION_CURRENT,
            state: DatabaseBootState::Initialized,
        })
    ));
    assert!(roles.iter().all(StoreRoleMemories::physically_virgin));
}

#[test]
fn zeroed_control_page_retries_as_virgin_after_interrupted_growth() {
    let control = VectorMemory::default();
    let roles = [empty_store_roles()];
    assert!(control.grow(1) >= 0);

    admit_or_initialize_database_format(&control, &roles)
        .expect("zeroed control page should resume initialization");

    assert!(matches!(
        inspect_boot_record(&control).expect("boot record should decode"),
        BootInspection::Present(_)
    ));
}

#[test]
fn missing_boot_with_nonvirgin_store_role_rejects_as_unsupported() {
    let control = VectorMemory::default();
    let roles = [empty_store_roles()];
    assert!(roles[0].data.grow(1) >= 0);

    assert_eq!(
        admit_or_initialize_database_format(&control, &roles),
        Err(DatabaseFormatGateError::Admission(
            DatabaseFormatAdmissionError::UnsupportedFormatVersion {
                found: None,
                required: DATABASE_FORMAT_VERSION_CURRENT,
            }
        )),
    );
}

#[test]
fn legacy_commit_stable_cell_rejects_as_missing_old_format() {
    let control = VectorMemory::default();
    let roles = [empty_store_roles()];
    assert!(control.grow(1) >= 0);
    control.write(0, LEGACY_STABLE_CELL_MAGIC);

    assert_eq!(
        admit_or_initialize_database_format(&control, &roles),
        Err(DatabaseFormatGateError::Admission(
            DatabaseFormatAdmissionError::UnsupportedFormatVersion {
                found: None,
                required: DATABASE_FORMAT_VERSION_CURRENT,
            }
        )),
    );
}

#[test]
fn current_boot_admits_nonempty_current_store_roles_without_decoding_them() {
    let control = VectorMemory::default();
    let roles = [empty_store_roles()];
    write_boot_bytes(&control, &DatabaseBootRecord::current().encode());
    assert!(roles[0].schema.grow(1) >= 0);

    admit_or_initialize_database_format(&control, &roles)
        .expect("current boot should admit current store allocations");
}

#[test]
fn noncurrent_valid_boot_record_rejects_with_found_version() {
    let control = VectorMemory::default();
    let roles = [empty_store_roles()];
    let record = DatabaseBootRecord {
        format_version: DatabaseFormatVersion(DATABASE_FORMAT_VERSION_CURRENT.get() + 1),
        state: DatabaseBootState::Initialized,
    };
    write_boot_bytes(&control, &record.encode());

    assert_eq!(
        admit_or_initialize_database_format(&control, &roles),
        Err(DatabaseFormatGateError::Admission(
            DatabaseFormatAdmissionError::UnsupportedFormatVersion {
                found: Some(record.format_version),
                required: DATABASE_FORMAT_VERSION_CURRENT,
            }
        )),
    );
}

#[test]
fn malformed_checksum_rejects_as_marker_corruption() {
    let control = VectorMemory::default();
    let roles = [empty_store_roles()];
    let mut bytes = DatabaseBootRecord::current().encode();
    bytes[DATABASE_BOOT_CHECKSUM_OFFSET] ^= 0xff;
    write_boot_bytes(&control, &bytes);

    assert_eq!(
        admit_or_initialize_database_format(&control, &roles),
        Err(DatabaseFormatGateError::Admission(
            DatabaseFormatAdmissionError::MalformedFormatMarker {
                reason: RecoveryFormatMarkerError::Checksum,
            }
        )),
    );
}

#[test]
fn malformed_state_rejects_after_checksum_validation() {
    let control = VectorMemory::default();
    let roles = [empty_store_roles()];
    let mut bytes = DatabaseBootRecord::current().encode();
    bytes[10] = 0xff;
    let checksum = crc32c(&bytes[..DATABASE_BOOT_CHECKSUM_OFFSET]);
    bytes[DATABASE_BOOT_CHECKSUM_OFFSET..].copy_from_slice(&checksum.to_be_bytes());
    write_boot_bytes(&control, &bytes);

    assert_eq!(
        admit_or_initialize_database_format(&control, &roles),
        Err(DatabaseFormatGateError::Admission(
            DatabaseFormatAdmissionError::MalformedFormatMarker {
                reason: RecoveryFormatMarkerError::State,
            }
        )),
    );
}

#[test]
fn malformed_magic_rejects_as_marker_corruption() {
    let control = VectorMemory::default();
    let roles = [empty_store_roles()];
    let mut bytes = DatabaseBootRecord::current().encode();
    bytes[0] = b'X';
    write_boot_bytes(&control, &bytes);

    assert_eq!(
        admit_or_initialize_database_format(&control, &roles),
        Err(DatabaseFormatGateError::Admission(
            DatabaseFormatAdmissionError::MalformedFormatMarker {
                reason: RecoveryFormatMarkerError::Magic,
            }
        )),
    );
}

#[test]
fn admission_errors_keep_unsupported_and_malformed_details_distinct() {
    let unsupported = map_admission_error(DatabaseFormatAdmissionError::UnsupportedFormatVersion {
        found: None,
        required: DATABASE_FORMAT_VERSION_CURRENT,
    });
    let malformed = map_admission_error(DatabaseFormatAdmissionError::MalformedFormatMarker {
        reason: RecoveryFormatMarkerError::Magic,
    });

    assert_eq!(unsupported.class(), ErrorClass::IncompatiblePersistedFormat);
    assert_eq!(unsupported.origin(), ErrorOrigin::Recovery);
    assert!(matches!(
        unsupported.detail(),
        Some(ErrorDetail::Recovery(
            RecoveryErrorDetail::UnsupportedFormatVersion {
                found: None,
                required,
            }
        )) if *required == DATABASE_FORMAT_VERSION_CURRENT.get()
    ));
    assert_eq!(malformed.class(), ErrorClass::Corruption);
    assert_eq!(malformed.origin(), ErrorOrigin::Recovery);
    assert!(matches!(
        malformed.detail(),
        Some(ErrorDetail::Recovery(
            RecoveryErrorDetail::MalformedFormatMarker {
                reason: RecoveryFormatMarkerError::Magic,
            }
        ))
    ));
}

#[test]
fn recovery_entry_rejects_noncurrent_boot_before_commit_decode() {
    let allocation = CommitMemoryAllocation {
        memory_id: DatabaseFormatTestCanister::COMMIT_MEMORY_ID,
        stable_key: DatabaseFormatTestCanister::COMMIT_STABLE_KEY,
    };
    let control = commit_memory_handle(allocation).expect("test control memory should open");
    let noncurrent = DatabaseBootRecord {
        format_version: DatabaseFormatVersion(DATABASE_FORMAT_VERSION_CURRENT.get() + 1),
        state: DatabaseBootState::Initialized,
    };
    write_boot_bytes(&control, &noncurrent.encode());

    let error = crate::db::commit::ensure_recovered(&FORMAT_DB)
        .expect_err("recovery must reject noncurrent database format first");

    assert_eq!(error.class(), ErrorClass::IncompatiblePersistedFormat);
    assert_eq!(error.origin(), ErrorOrigin::Recovery);
    assert!(matches!(
        error.detail(),
        Some(ErrorDetail::Recovery(
            RecoveryErrorDetail::UnsupportedFormatVersion {
                found: Some(found),
                required,
            }
        )) if *found == noncurrent.format_version.get()
            && *required == DATABASE_FORMAT_VERSION_CURRENT.get()
    ));
}