canic-core 0.22.0

Canic — a canister orchestration and management toolkit for the Internet Computer
Documentation
#![allow(clippy::cast_possible_truncation)]

use crate::{
    cdk::structures::{
        DefaultMemoryImpl,
        log::{Log as StableLogImpl, WriteError},
        memory::VirtualMemory,
    },
    eager_static, ic_memory,
    log::{Level, Topic},
    memory::impl_storable_unbounded,
    storage::{
        prelude::*,
        stable::{
            StableMemoryError,
            memory::observability::{LOG_DATA_ID, LOG_INDEX_ID},
        },
    },
};
use serde::{Deserialize, Serialize};
use std::{cell::RefCell, collections::VecDeque};

///
/// StableLog
///

type StableLog = StableLogImpl<
    LogEntryRecord,
    VirtualMemory<DefaultMemoryImpl>,
    VirtualMemory<DefaultMemoryImpl>,
>;

///
/// LogIndexMemory
///

struct LogIndexMemory;

///
/// LogDataMemory
///

struct LogDataMemory;

///
/// LogMemory
///

#[derive(Clone)]
struct LogMemory {
    index: VirtualMemory<DefaultMemoryImpl>,
    data: VirtualMemory<DefaultMemoryImpl>,
}

impl LogMemory {
    fn new() -> Self {
        Self {
            index: ic_memory!(LogIndexMemory, LOG_INDEX_ID),
            data: ic_memory!(LogDataMemory, LOG_DATA_ID),
        }
    }
}

eager_static! {
    static LOG_MEMORY: LogMemory = LogMemory::new();
}

fn create_log() -> StableLog {
    LOG_MEMORY.with(|mem| StableLogImpl::new(mem.index.clone(), mem.data.clone()))
}

eager_static! {
    static LOG: RefCell<StableLog> = RefCell::new(create_log());
}

fn with_log<R>(f: impl FnOnce(&StableLog) -> R) -> R {
    LOG.with_borrow(f)
}

fn with_log_mut<R>(f: impl FnOnce(&mut StableLog) -> R) -> R {
    LOG.with_borrow_mut(f)
}

///
/// LogEntryRecord
///

#[derive(Clone, Debug, Deserialize, Serialize)]
pub struct LogEntryRecord {
    pub crate_name: String,
    pub created_at: u64,
    pub level: Level,
    pub topic: Option<Topic>,
    pub message: String,
}

impl_storable_unbounded!(LogEntryRecord);

///
/// Log
///

pub struct Log;

impl Log {
    pub(crate) fn append(
        max_entries: usize,
        max_entry_bytes: u32,
        entry: LogEntryRecord,
    ) -> Result<u64, StorageError> {
        if max_entries == 0 {
            return Ok(0);
        }

        let entry = truncate_entry(entry, max_entry_bytes);
        let id = append_raw(&entry)?;

        Ok(id)
    }

    #[must_use]
    pub(crate) fn snapshot() -> Vec<LogEntryRecord> {
        let mut out = Vec::new();
        with_log(|log| {
            for entry in log.iter() {
                out.push(entry);
            }
        });

        out
    }
}

///
/// RetentionSummary
///

#[derive(Clone, Debug, Default)]
pub struct RetentionSummary {
    pub before: u64,
    pub retained: u64,
    pub dropped_by_age: u64,
    pub dropped_by_limit: u64,
}

impl RetentionSummary {
    #[must_use]
    pub const fn dropped_total(&self) -> u64 {
        self.dropped_by_age + self.dropped_by_limit
    }
}

pub fn apply_retention(
    cutoff: Option<u64>,
    max_entries: usize,
    max_entry_bytes: u32,
) -> Result<RetentionSummary, StorageError> {
    let before = with_log(StableLog::len);

    if max_entries == 0 {
        with_log_mut(|log| *log = create_log());

        return Ok(RetentionSummary {
            before,
            retained: 0,
            dropped_by_age: 0,
            dropped_by_limit: before,
        });
    }

    if before == 0 {
        return Ok(RetentionSummary::default());
    }

    let mut retained = VecDeque::new();
    let mut eligible = 0u64;

    with_log(|log| {
        for entry in log.iter() {
            if let Some(cutoff) = cutoff
                && entry.created_at < cutoff
            {
                continue;
            }

            eligible += 1;
            retained.push_back(entry);
            if retained.len() > max_entries {
                retained.pop_front();
            }
        }
    });

    let retained_len = retained.len() as u64;
    let dropped_by_age = if cutoff.is_some() {
        before.saturating_sub(eligible)
    } else {
        0
    };
    let dropped_by_limit = eligible.saturating_sub(retained_len);

    if dropped_by_age == 0 && dropped_by_limit == 0 {
        return Ok(RetentionSummary {
            before,
            retained: retained_len,
            dropped_by_age: 0,
            dropped_by_limit: 0,
        });
    }

    with_log_mut(|log| *log = create_log());
    for entry in retained {
        let entry = truncate_entry(entry, max_entry_bytes);
        append_raw(&entry)?;
    }

    Ok(RetentionSummary {
        before,
        retained: retained_len,
        dropped_by_age,
        dropped_by_limit,
    })
}

//
// ─────────────────────────────────────────────────────────────
// Internal helpers (mechanical)
// ─────────────────────────────────────────────────────────────
//

const TRUNCATION_SUFFIX: &str = "...[truncated]";

fn append_raw(entry: &LogEntryRecord) -> Result<u64, StorageError> {
    with_log(|log| log.append(entry)).map_err(|e| StorageError::from(map_write_error(e)))
}

const fn map_write_error(err: WriteError) -> StableMemoryError {
    match err {
        WriteError::GrowFailed {
            current_size,
            delta,
        } => StableMemoryError::LogWriteFailed {
            current_size,
            delta,
        },
    }
}

fn truncate_entry(mut entry: LogEntryRecord, max_bytes: u32) -> LogEntryRecord {
    if let Some(msg) = truncate_message(&entry.message, max_bytes) {
        entry.message = msg;
    }
    entry
}

fn truncate_message(message: &str, max_entry_bytes: u32) -> Option<String> {
    let max_entry_bytes = usize::try_from(max_entry_bytes).unwrap_or(usize::MAX);
    if message.len() <= max_entry_bytes {
        return None;
    }

    if max_entry_bytes == 0 {
        return Some(String::new());
    }

    if max_entry_bytes <= TRUNCATION_SUFFIX.len() {
        return Some(truncate_to_boundary(message, max_entry_bytes).to_string());
    }

    let keep_len = max_entry_bytes - TRUNCATION_SUFFIX.len();
    let mut truncated = truncate_to_boundary(message, keep_len).to_string();
    truncated.push_str(TRUNCATION_SUFFIX);

    Some(truncated)
}

fn truncate_to_boundary(message: &str, max_bytes: usize) -> &str {
    if message.len() <= max_bytes {
        return message;
    }

    let mut end = max_bytes;
    while end > 0 && !message.is_char_boundary(end) {
        end = end.saturating_sub(1);
    }

    &message[..end]
}