initial persistence commit

Signed-off-by: Sienna Meridian Satterwhite <sienna@r3t.io>
This commit is contained in:
2025-11-16 11:50:16 +00:00
parent 3c456abadc
commit a15e018876
16 changed files with 4262 additions and 10 deletions

1091
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -12,6 +12,12 @@ serde_json.workspace = true
crdts.workspace = true crdts.workspace = true
anyhow.workspace = true anyhow.workspace = true
sync-macros = { path = "../sync-macros" } sync-macros = { path = "../sync-macros" }
uuid = { version = "1.0", features = ["v4", "serde"] }
toml.workspace = true
tracing.workspace = true
bevy.workspace = true
bincode = "1.3"
futures-lite = "2.0"
[dev-dependencies] [dev-dependencies]
tokio.workspace = true tokio.workspace = true

View File

@@ -24,6 +24,7 @@ mod error;
mod models; mod models;
mod db; mod db;
pub mod sync; pub mod sync;
pub mod persistence;
pub use error::{ChatDbError, Result}; pub use error::{ChatDbError, Result};
pub use models::{Message, Chat}; pub use models::{Message, Chat};

View File

@@ -0,0 +1,248 @@
//! Configuration for the persistence layer
use crate::persistence::error::Result;
use serde::{Deserialize, Serialize};
use std::time::Duration;
/// Default critical flush delay in milliseconds
const DEFAULT_CRITICAL_FLUSH_DELAY_MS: u64 = 1000;
/// Default maximum buffer operations before forced flush
const DEFAULT_MAX_BUFFER_OPERATIONS: usize = 1000;
/// Configuration for the persistence layer
#[derive(Debug, Clone, Serialize, Deserialize, bevy::prelude::Resource)]
pub struct PersistenceConfig {
/// Base flush interval (may be adjusted by battery level)
pub flush_interval_secs: u64,
/// Max time to defer critical writes (entity creation, etc.)
pub critical_flush_delay_ms: u64,
/// WAL checkpoint interval
pub checkpoint_interval_secs: u64,
/// Max WAL size before forced checkpoint (in bytes)
pub max_wal_size_bytes: usize,
/// Maximum number of operations in write buffer before forcing flush
pub max_buffer_operations: usize,
/// Enable adaptive flushing based on battery
pub battery_adaptive: bool,
/// Battery tier configuration
pub battery_tiers: BatteryTiers,
/// Platform-specific settings
#[serde(default)]
pub platform: PlatformConfig,
}
impl Default for PersistenceConfig {
fn default() -> Self {
Self {
flush_interval_secs: 10,
critical_flush_delay_ms: DEFAULT_CRITICAL_FLUSH_DELAY_MS,
checkpoint_interval_secs: 30,
max_wal_size_bytes: 5 * 1024 * 1024, // 5MB
max_buffer_operations: DEFAULT_MAX_BUFFER_OPERATIONS,
battery_adaptive: true,
battery_tiers: BatteryTiers::default(),
platform: PlatformConfig::default(),
}
}
}
impl PersistenceConfig {
/// Get the flush interval based on battery status
pub fn get_flush_interval(&self, battery_level: f32, is_charging: bool) -> Duration {
if !self.battery_adaptive {
return Duration::from_secs(self.flush_interval_secs);
}
let interval_secs = if is_charging {
self.battery_tiers.charging
} else if battery_level > 0.5 {
self.battery_tiers.high
} else if battery_level > 0.2 {
self.battery_tiers.medium
} else {
self.battery_tiers.low
};
Duration::from_secs(interval_secs)
}
/// Get the critical flush delay
pub fn get_critical_flush_delay(&self) -> Duration {
Duration::from_millis(self.critical_flush_delay_ms)
}
/// Get the checkpoint interval
pub fn get_checkpoint_interval(&self) -> Duration {
Duration::from_secs(self.checkpoint_interval_secs)
}
}
/// Battery tier flush intervals (in seconds)
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BatteryTiers {
/// Flush interval when charging
pub charging: u64,
/// Flush interval when battery > 50%
pub high: u64,
/// Flush interval when battery 20-50%
pub medium: u64,
/// Flush interval when battery < 20%
pub low: u64,
}
impl Default for BatteryTiers {
fn default() -> Self {
Self {
charging: 5,
high: 10,
medium: 30,
low: 60,
}
}
}
/// Platform-specific configuration
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct PlatformConfig {
/// iOS-specific settings
#[serde(default)]
pub ios: IosConfig,
}
/// iOS-specific configuration
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct IosConfig {
/// How long to wait for background flush before giving up (seconds)
pub background_flush_timeout_secs: u64,
/// Flush interval when in low power mode (seconds)
pub low_power_mode_interval_secs: u64,
}
impl Default for IosConfig {
fn default() -> Self {
Self {
background_flush_timeout_secs: 5,
low_power_mode_interval_secs: 60,
}
}
}
/// Load persistence configuration from a TOML string
///
/// Parses TOML configuration and validates all settings. Use this for
/// loading configuration from embedded strings or dynamic sources.
///
/// # Parameters
/// - `toml`: TOML-formatted configuration string
///
/// # Returns
/// - `Ok(PersistenceConfig)`: Parsed and validated configuration
/// - `Err`: If TOML is invalid or contains invalid values
///
/// # Example TOML
/// ```toml
/// flush_interval_secs = 10
/// battery_adaptive = true
/// [battery_tiers]
/// charging = 5
/// high = 10
/// ```
pub fn load_config_from_str(toml: &str) -> Result<PersistenceConfig> {
Ok(toml::from_str(toml)?)
}
/// Load persistence configuration from a TOML file
///
/// Reads and parses a TOML configuration file. This is the recommended way
/// to load configuration for production use, allowing runtime configuration
/// changes without recompilation.
///
/// # Parameters
/// - `path`: Path to TOML configuration file
///
/// # Returns
/// - `Ok(PersistenceConfig)`: Loaded configuration
/// - `Err`: If file can't be read or TOML is invalid
///
/// # Examples
/// ```no_run
/// # use lib::persistence::*;
/// # fn example() -> Result<()> {
/// let config = load_config_from_file("persistence.toml")?;
/// # Ok(())
/// # }
/// ```
pub fn load_config_from_file(path: impl AsRef<std::path::Path>) -> Result<PersistenceConfig> {
let content = std::fs::read_to_string(path)?;
Ok(load_config_from_str(&content)?)
}
/// Serialize persistence configuration to a TOML string
///
/// Converts configuration to human-readable TOML format. Use this to
/// save configuration to files or display current settings.
///
/// # Parameters
/// - `config`: Configuration to serialize
///
/// # Returns
/// - `Ok(String)`: Pretty-printed TOML configuration
/// - `Err`: If serialization fails (rare)
pub fn save_config_to_str(config: &PersistenceConfig) -> Result<String> {
Ok(toml::to_string_pretty(config)?)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_default_config() {
let config = PersistenceConfig::default();
assert_eq!(config.flush_interval_secs, 10);
assert_eq!(config.battery_adaptive, true);
}
#[test]
fn test_battery_adaptive_intervals() {
let config = PersistenceConfig::default();
// Charging
let interval = config.get_flush_interval(0.3, true);
assert_eq!(interval, Duration::from_secs(5));
// High battery
let interval = config.get_flush_interval(0.8, false);
assert_eq!(interval, Duration::from_secs(10));
// Medium battery
let interval = config.get_flush_interval(0.4, false);
assert_eq!(interval, Duration::from_secs(30));
// Low battery
let interval = config.get_flush_interval(0.1, false);
assert_eq!(interval, Duration::from_secs(60));
}
#[test]
fn test_config_serialization() {
let config = PersistenceConfig::default();
let toml = save_config_to_str(&config).unwrap();
let loaded = load_config_from_str(&toml).unwrap();
assert_eq!(config.flush_interval_secs, loaded.flush_interval_secs);
assert_eq!(config.battery_adaptive, loaded.battery_adaptive);
}
}

View File

@@ -0,0 +1,544 @@
//! Database schema and operations for persistence layer
use crate::persistence::types::*;
use crate::persistence::error::{PersistenceError, Result};
use chrono::Utc;
use rusqlite::{Connection, OptionalExtension};
use std::path::Path;
/// Default SQLite page size in bytes (4KB)
const DEFAULT_PAGE_SIZE: i64 = 4096;
/// Cache size for SQLite in KB (negative value = KB instead of pages)
const CACHE_SIZE_KB: i64 = -20000; // 20MB
/// Get current Unix timestamp in seconds
///
/// Helper to avoid repeating `Utc::now().timestamp()` throughout the code
#[inline]
fn current_timestamp() -> i64 {
Utc::now().timestamp()
}
/// Initialize SQLite connection with WAL mode and optimizations
pub fn initialize_persistence_db<P: AsRef<Path>>(path: P) -> Result<Connection> {
let conn = Connection::open(path)?;
configure_sqlite_for_persistence(&conn)?;
create_persistence_schema(&conn)?;
Ok(conn)
}
/// Configure SQLite with WAL mode and battery-friendly settings
pub fn configure_sqlite_for_persistence(conn: &Connection) -> Result<()> {
// Enable Write-Ahead Logging for better concurrency and fewer fsyncs
conn.execute_batch("PRAGMA journal_mode = WAL;")?;
// Don't auto-checkpoint on every transaction - we'll control this manually
conn.execute_batch("PRAGMA wal_autocheckpoint = 0;")?;
// NORMAL synchronous mode - fsync WAL on commit, but not every write
// This is a good balance between durability and performance
conn.execute_batch("PRAGMA synchronous = NORMAL;")?;
// Larger page size for better sequential write performance on mobile
// Note: This must be set before the database is created or after VACUUM
// We'll skip setting it if database already exists to avoid issues
let page_size: i64 = conn.query_row("PRAGMA page_size", [], |row| row.get(0))?;
if page_size == DEFAULT_PAGE_SIZE {
// Try to set larger page size, but only if we're at default
// This will only work on a fresh database
let _ = conn.execute_batch("PRAGMA page_size = 8192;");
}
// Increase cache size for better performance (in pages, negative = KB)
conn.execute_batch(&format!("PRAGMA cache_size = {};", CACHE_SIZE_KB))?;
// Use memory for temp tables (faster, we don't need temp table durability)
conn.execute_batch("PRAGMA temp_store = MEMORY;")?;
Ok(())
}
/// Create the database schema for persistence
pub fn create_persistence_schema(conn: &Connection) -> Result<()> {
// Entities table - stores entity metadata
conn.execute(
"CREATE TABLE IF NOT EXISTS entities (
id BLOB PRIMARY KEY,
entity_type TEXT NOT NULL,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
)",
[],
)?;
// Components table - stores serialized component data
conn.execute(
"CREATE TABLE IF NOT EXISTS components (
entity_id BLOB NOT NULL,
component_type TEXT NOT NULL,
data BLOB NOT NULL,
updated_at INTEGER NOT NULL,
PRIMARY KEY (entity_id, component_type),
FOREIGN KEY (entity_id) REFERENCES entities(id) ON DELETE CASCADE
)",
[],
)?;
// Index for querying components by entity
conn.execute(
"CREATE INDEX IF NOT EXISTS idx_components_entity
ON components(entity_id)",
[],
)?;
// Operation log - for CRDT sync protocol
conn.execute(
"CREATE TABLE IF NOT EXISTS operation_log (
id INTEGER PRIMARY KEY AUTOINCREMENT,
node_id TEXT NOT NULL,
sequence_number INTEGER NOT NULL,
operation BLOB NOT NULL,
timestamp INTEGER NOT NULL,
UNIQUE(node_id, sequence_number)
)",
[],
)?;
// Index for efficient operation log queries
conn.execute(
"CREATE INDEX IF NOT EXISTS idx_oplog_node_seq
ON operation_log(node_id, sequence_number)",
[],
)?;
// Vector clock table - for causality tracking
conn.execute(
"CREATE TABLE IF NOT EXISTS vector_clock (
node_id TEXT PRIMARY KEY,
counter INTEGER NOT NULL,
updated_at INTEGER NOT NULL
)",
[],
)?;
// Session state table - for crash detection
conn.execute(
"CREATE TABLE IF NOT EXISTS session_state (
key TEXT PRIMARY KEY,
value TEXT NOT NULL,
updated_at INTEGER NOT NULL
)",
[],
)?;
// WAL checkpoint tracking
conn.execute(
"CREATE TABLE IF NOT EXISTS checkpoint_state (
last_checkpoint INTEGER NOT NULL,
wal_size_bytes INTEGER NOT NULL
)",
[],
)?;
// Initialize checkpoint state if not exists
conn.execute(
"INSERT OR IGNORE INTO checkpoint_state (rowid, last_checkpoint, wal_size_bytes)
VALUES (1, ?, 0)",
[current_timestamp()],
)?;
Ok(())
}
/// Flush a batch of operations to SQLite in a single transaction
pub fn flush_to_sqlite(ops: &[PersistenceOp], conn: &mut Connection) -> Result<usize> {
if ops.is_empty() {
return Ok(0);
}
let tx = conn.transaction()?;
let mut count = 0;
for op in ops {
match op {
PersistenceOp::UpsertEntity { id, data } => {
tx.execute(
"INSERT OR REPLACE INTO entities (id, entity_type, created_at, updated_at)
VALUES (?1, ?2, ?3, ?4)",
rusqlite::params![
id.as_bytes(),
data.entity_type,
data.created_at.timestamp(),
data.updated_at.timestamp(),
],
)?;
count += 1;
}
PersistenceOp::UpsertComponent {
entity_id,
component_type,
data,
} => {
tx.execute(
"INSERT OR REPLACE INTO components (entity_id, component_type, data, updated_at)
VALUES (?1, ?2, ?3, ?4)",
rusqlite::params![
entity_id.as_bytes(),
component_type,
data,
current_timestamp(),
],
)?;
count += 1;
}
PersistenceOp::LogOperation {
node_id,
sequence,
operation,
} => {
tx.execute(
"INSERT OR REPLACE INTO operation_log (node_id, sequence_number, operation, timestamp)
VALUES (?1, ?2, ?3, ?4)",
rusqlite::params![
node_id,
sequence,
operation,
current_timestamp(),
],
)?;
count += 1;
}
PersistenceOp::UpdateVectorClock { node_id, counter } => {
tx.execute(
"INSERT OR REPLACE INTO vector_clock (node_id, counter, updated_at)
VALUES (?1, ?2, ?3)",
rusqlite::params![node_id, counter, current_timestamp()],
)?;
count += 1;
}
PersistenceOp::DeleteEntity { id } => {
tx.execute("DELETE FROM entities WHERE id = ?1", rusqlite::params![id.as_bytes()])?;
count += 1;
}
PersistenceOp::DeleteComponent {
entity_id,
component_type,
} => {
tx.execute(
"DELETE FROM components WHERE entity_id = ?1 AND component_type = ?2",
rusqlite::params![entity_id.as_bytes(), component_type],
)?;
count += 1;
}
}
}
tx.commit()?;
Ok(count)
}
/// Manually checkpoint the WAL file to merge changes into the main database
///
/// This function performs a SQLite WAL checkpoint, which copies frames from the
/// write-ahead log back into the main database file. This is crucial for:
/// - Reducing WAL file size to save disk space
/// - Ensuring durability of committed transactions
/// - Maintaining database integrity
///
/// # Parameters
/// - `conn`: Mutable reference to the SQLite connection
/// - `mode`: Checkpoint mode controlling blocking behavior (see [`CheckpointMode`])
///
/// # Returns
/// - `Ok(CheckpointInfo)`: Information about the checkpoint operation
/// - `Err`: If the checkpoint fails or database state update fails
///
/// # Examples
/// ```no_run
/// # use rusqlite::Connection;
/// # use lib::persistence::*;
/// # fn example() -> anyhow::Result<()> {
/// let mut conn = Connection::open("app.db")?;
/// let info = checkpoint_wal(&mut conn, CheckpointMode::Passive)?;
/// if info.busy {
/// // Some pages couldn't be checkpointed due to active readers
/// }
/// # Ok(())
/// # }
/// ```
pub fn checkpoint_wal(conn: &mut Connection, mode: CheckpointMode) -> Result<CheckpointInfo> {
let mode_str = match mode {
CheckpointMode::Passive => "PASSIVE",
CheckpointMode::Full => "FULL",
CheckpointMode::Restart => "RESTART",
CheckpointMode::Truncate => "TRUNCATE",
};
let query = format!("PRAGMA wal_checkpoint({})", mode_str);
// Returns (busy, log_pages, checkpointed_pages)
let (busy, log_pages, checkpointed_pages): (i32, i32, i32) =
conn.query_row(&query, [], |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)))?;
// Update checkpoint state
conn.execute(
"UPDATE checkpoint_state SET last_checkpoint = ?1 WHERE rowid = 1",
[current_timestamp()],
)?;
Ok(CheckpointInfo {
busy: busy != 0,
log_pages,
checkpointed_pages,
})
}
/// Get the size of the WAL file in bytes
///
/// This checks the actual WAL file size on disk without triggering a checkpoint.
/// Large WAL files consume disk space and can slow down recovery, so monitoring
/// size helps maintain optimal performance.
///
/// # Parameters
/// - `conn`: Reference to the SQLite connection
///
/// # Returns
/// - `Ok(i64)`: WAL file size in bytes (0 if no WAL exists or in-memory database)
/// - `Err`: If the database path query fails
///
/// # Note
/// For in-memory databases, always returns 0.
pub fn get_wal_size(conn: &Connection) -> Result<i64> {
// Get the database file path
let db_path: Option<String> = conn
.query_row("PRAGMA database_list", [], |row| row.get::<_, String>(2))
.optional()?;
// If no path (in-memory database), return 0
let Some(db_path) = db_path else {
return Ok(0);
};
// WAL file has same name as database but with -wal suffix
let wal_path = format!("{}-wal", db_path);
// Check if WAL file exists and get its size
match std::fs::metadata(&wal_path) {
Ok(metadata) => Ok(metadata.len() as i64),
Err(_) => Ok(0), // WAL doesn't exist yet
}
}
/// Checkpoint mode for WAL
#[derive(Debug, Clone, Copy)]
pub enum CheckpointMode {
/// Passive checkpoint - doesn't block readers/writers
Passive,
/// Full checkpoint - waits for writers to finish
Full,
/// Restart checkpoint - like Full, but restarts WAL file
Restart,
/// Truncate checkpoint - like Restart, but truncates WAL file to 0 bytes
Truncate,
}
/// Information about a checkpoint operation
#[derive(Debug)]
pub struct CheckpointInfo {
pub busy: bool,
pub log_pages: i32,
pub checkpointed_pages: i32,
}
/// Set a session state value in the database
///
/// Session state is used to track application lifecycle events and detect crashes.
/// Values persist across restarts, enabling crash detection and recovery.
///
/// # Parameters
/// - `conn`: Mutable reference to the SQLite connection
/// - `key`: State key (e.g., "clean_shutdown", "session_id")
/// - `value`: State value to store
///
/// # Returns
/// - `Ok(())`: State was successfully saved
/// - `Err`: If the database write fails
pub fn set_session_state(conn: &mut Connection, key: &str, value: &str) -> Result<()> {
conn.execute(
"INSERT OR REPLACE INTO session_state (key, value, updated_at)
VALUES (?1, ?2, ?3)",
rusqlite::params![key, value, current_timestamp()],
)?;
Ok(())
}
/// Get a session state value from the database
///
/// Retrieves persistent state information stored across application sessions.
///
/// # Parameters
/// - `conn`: Reference to the SQLite connection
/// - `key`: State key to retrieve
///
/// # Returns
/// - `Ok(Some(value))`: State exists and was retrieved
/// - `Ok(None)`: State key doesn't exist
/// - `Err`: If the database query fails
pub fn get_session_state(conn: &Connection, key: &str) -> Result<Option<String>> {
conn.query_row(
"SELECT value FROM session_state WHERE key = ?1",
rusqlite::params![key],
|row| row.get(0),
)
.optional()
.map_err(|e| PersistenceError::Database(e))
}
/// Check if the previous session had a clean shutdown
///
/// This is critical for crash detection. When the application starts, this checks
/// if the previous session ended cleanly. If not, it indicates a crash occurred,
/// and recovery procedures may be needed.
///
/// **Side effect**: Resets the clean_shutdown flag to "false" for the current session.
/// Call [`mark_clean_shutdown`] during normal shutdown to set it back to "true".
///
/// # Parameters
/// - `conn`: Mutable reference to the SQLite connection (mutates session state)
///
/// # Returns
/// - `Ok(true)`: Previous session shut down cleanly
/// - `Ok(false)`: Previous session crashed or this is first run
/// - `Err`: If database operations fail
pub fn check_clean_shutdown(conn: &mut Connection) -> Result<bool> {
let clean = get_session_state(conn, "clean_shutdown")?
.map(|v| v == "true")
.unwrap_or(false);
// Reset for this session
set_session_state(conn, "clean_shutdown", "false")?;
Ok(clean)
}
/// Mark the current session as cleanly shut down
///
/// Call this during normal application shutdown to indicate clean termination.
/// The next startup will detect this flag via [`check_clean_shutdown`] and know
/// no crash occurred.
///
/// # Parameters
/// - `conn`: Mutable reference to the SQLite connection
///
/// # Returns
/// - `Ok(())`: Clean shutdown flag was set
/// - `Err`: If the database write fails
pub fn mark_clean_shutdown(conn: &mut Connection) -> Result<()> {
set_session_state(conn, "clean_shutdown", "true")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_database_initialization() -> Result<()> {
let conn = Connection::open_in_memory()?;
configure_sqlite_for_persistence(&conn)?;
create_persistence_schema(&conn)?;
// Verify tables exist
let tables: Vec<String> = conn
.prepare("SELECT name FROM sqlite_master WHERE type='table'")?
.query_map([], |row| row.get(0))?
.collect::<std::result::Result<Vec<_>, _>>()?;
assert!(tables.contains(&"entities".to_string()));
assert!(tables.contains(&"components".to_string()));
assert!(tables.contains(&"operation_log".to_string()));
assert!(tables.contains(&"vector_clock".to_string()));
Ok(())
}
#[test]
fn test_flush_operations() -> Result<()> {
let mut conn = Connection::open_in_memory()?;
create_persistence_schema(&conn)?;
let entity_id = uuid::Uuid::new_v4();
let ops = vec![
PersistenceOp::UpsertEntity {
id: entity_id,
data: EntityData {
id: entity_id,
created_at: Utc::now(),
updated_at: Utc::now(),
entity_type: "TestEntity".to_string(),
},
},
PersistenceOp::UpsertComponent {
entity_id,
component_type: "Transform".to_string(),
data: vec![1, 2, 3, 4],
},
];
let count = flush_to_sqlite(&ops, &mut conn)?;
assert_eq!(count, 2);
// Verify entity exists
let exists: bool = conn.query_row(
"SELECT COUNT(*) > 0 FROM entities WHERE id = ?1",
rusqlite::params![entity_id.as_bytes()],
|row| row.get(0),
)?;
assert!(exists);
Ok(())
}
#[test]
fn test_session_state() -> Result<()> {
let mut conn = Connection::open_in_memory()?;
create_persistence_schema(&conn)?;
set_session_state(&mut conn, "test_key", "test_value")?;
let value = get_session_state(&conn, "test_key")?;
assert_eq!(value, Some("test_value".to_string()));
Ok(())
}
#[test]
fn test_crash_recovery() -> Result<()> {
let mut conn = Connection::open_in_memory()?;
create_persistence_schema(&conn)?;
// Simulate first startup - should report as crash (no clean shutdown marker)
let clean = check_clean_shutdown(&mut conn)?;
assert!(!clean, "First startup should be detected as crash");
// Mark clean shutdown
mark_clean_shutdown(&mut conn)?;
// Next startup should report clean shutdown
let clean = check_clean_shutdown(&mut conn)?;
assert!(clean, "Should detect clean shutdown");
// After checking clean shutdown, flag should be reset to false
// So if we check again without marking, it should report as crash
let value = get_session_state(&conn, "clean_shutdown")?;
assert_eq!(value, Some("false".to_string()), "Flag should be reset after check");
Ok(())
}
}

View File

@@ -0,0 +1,107 @@
//! Error types for the persistence layer
use std::fmt;
/// Result type for persistence operations
pub type Result<T> = std::result::Result<T, PersistenceError>;
/// Errors that can occur in the persistence layer
#[derive(Debug)]
pub enum PersistenceError {
/// Database operation failed
Database(rusqlite::Error),
/// Serialization failed
Serialization(bincode::Error),
/// Deserialization failed
Deserialization(String),
/// Configuration error
Config(String),
/// I/O error (file operations, WAL checks, etc.)
Io(std::io::Error),
/// Type not found in registry
TypeNotRegistered(String),
/// Entity or component not found
NotFound(String),
/// Circuit breaker is open, operation blocked
CircuitBreakerOpen {
consecutive_failures: u32,
retry_after_secs: u64,
},
/// Other error
Other(String),
}
impl fmt::Display for PersistenceError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Database(err) => write!(f, "Database error: {}", err),
Self::Serialization(err) => write!(f, "Serialization error: {}", err),
Self::Deserialization(msg) => write!(f, "Deserialization error: {}", msg),
Self::Config(msg) => write!(f, "Configuration error: {}", msg),
Self::Io(err) => write!(f, "I/O error: {}", err),
Self::TypeNotRegistered(type_name) => {
write!(f, "Type not registered in type registry: {}", type_name)
}
Self::NotFound(msg) => write!(f, "Not found: {}", msg),
Self::CircuitBreakerOpen {
consecutive_failures,
retry_after_secs,
} => write!(
f,
"Circuit breaker open after {} consecutive failures, retry after {} seconds",
consecutive_failures, retry_after_secs
),
Self::Other(msg) => write!(f, "{}", msg),
}
}
}
impl std::error::Error for PersistenceError {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
match self {
Self::Database(err) => Some(err),
Self::Serialization(err) => Some(err),
Self::Io(err) => Some(err),
_ => None,
}
}
}
// Conversions from common error types
impl From<rusqlite::Error> for PersistenceError {
fn from(err: rusqlite::Error) -> Self {
Self::Database(err)
}
}
impl From<bincode::Error> for PersistenceError {
fn from(err: bincode::Error) -> Self {
Self::Serialization(err)
}
}
impl From<std::io::Error> for PersistenceError {
fn from(err: std::io::Error) -> Self {
Self::Io(err)
}
}
impl From<toml::de::Error> for PersistenceError {
fn from(err: toml::de::Error) -> Self {
Self::Config(err.to_string())
}
}
impl From<toml::ser::Error> for PersistenceError {
fn from(err: toml::ser::Error) -> Self {
Self::Config(err.to_string())
}
}

View File

@@ -0,0 +1,212 @@
//! Health monitoring and error recovery for persistence layer
use bevy::prelude::*;
use std::time::{Duration, Instant};
/// Base delay for exponential backoff in milliseconds
const BASE_RETRY_DELAY_MS: u64 = 1000; // 1 second
/// Maximum retry delay in milliseconds (caps exponential backoff)
const MAX_RETRY_DELAY_MS: u64 = 30000; // 30 seconds
/// Maximum exponent for exponential backoff calculation
const MAX_BACKOFF_EXPONENT: u32 = 5;
/// Resource to track persistence health and failures
#[derive(Resource, Debug)]
pub struct PersistenceHealth {
/// Number of consecutive flush failures
pub consecutive_flush_failures: u32,
/// Number of consecutive checkpoint failures
pub consecutive_checkpoint_failures: u32,
/// Time of last successful flush
pub last_successful_flush: Option<Instant>,
/// Time of last successful checkpoint
pub last_successful_checkpoint: Option<Instant>,
/// Whether the persistence layer is in circuit breaker mode
pub circuit_breaker_open: bool,
/// When the circuit breaker was opened
pub circuit_breaker_opened_at: Option<Instant>,
/// Total number of failures across the session
pub total_failures: u64,
}
impl Default for PersistenceHealth {
fn default() -> Self {
Self {
consecutive_flush_failures: 0,
consecutive_checkpoint_failures: 0,
last_successful_flush: None,
last_successful_checkpoint: None,
circuit_breaker_open: false,
circuit_breaker_opened_at: None,
total_failures: 0,
}
}
}
impl PersistenceHealth {
/// Circuit breaker threshold - open after this many consecutive failures
pub const CIRCUIT_BREAKER_THRESHOLD: u32 = 5;
/// How long to keep circuit breaker open before attempting recovery
pub const CIRCUIT_BREAKER_COOLDOWN: Duration = Duration::from_secs(60);
/// Record a successful flush
pub fn record_flush_success(&mut self) {
self.consecutive_flush_failures = 0;
self.last_successful_flush = Some(Instant::now());
// Close circuit breaker if it was open
if self.circuit_breaker_open {
info!("Persistence recovered - closing circuit breaker");
self.circuit_breaker_open = false;
self.circuit_breaker_opened_at = None;
}
}
/// Record a flush failure
pub fn record_flush_failure(&mut self) {
self.consecutive_flush_failures += 1;
self.total_failures += 1;
if self.consecutive_flush_failures >= Self::CIRCUIT_BREAKER_THRESHOLD {
if !self.circuit_breaker_open {
warn!(
"Opening circuit breaker after {} consecutive flush failures",
self.consecutive_flush_failures
);
self.circuit_breaker_open = true;
self.circuit_breaker_opened_at = Some(Instant::now());
}
}
}
/// Record a successful checkpoint
pub fn record_checkpoint_success(&mut self) {
self.consecutive_checkpoint_failures = 0;
self.last_successful_checkpoint = Some(Instant::now());
}
/// Record a checkpoint failure
pub fn record_checkpoint_failure(&mut self) {
self.consecutive_checkpoint_failures += 1;
self.total_failures += 1;
}
/// Check if we should attempt operations (circuit breaker state)
///
/// **CRITICAL FIX**: Now takes `&mut self` to properly reset the circuit breaker
/// after cooldown expires. This prevents the circuit breaker from remaining
/// permanently open after one post-cooldown failure.
pub fn should_attempt_operation(&mut self) -> bool {
if !self.circuit_breaker_open {
return true;
}
// Check if cooldown period has elapsed
if let Some(opened_at) = self.circuit_breaker_opened_at {
if opened_at.elapsed() >= Self::CIRCUIT_BREAKER_COOLDOWN {
// Transition to half-open state by resetting the breaker
info!("Circuit breaker cooldown elapsed - entering half-open state (testing recovery)");
self.circuit_breaker_open = false;
self.circuit_breaker_opened_at = None;
// consecutive_flush_failures is kept to track if this probe succeeds
return true;
}
}
false
}
/// Get exponential backoff delay based on consecutive failures
pub fn get_retry_delay(&self) -> Duration {
// Exponential backoff: 1s, 2s, 4s, 8s, 16s, max 30s
let delay_ms = BASE_RETRY_DELAY_MS * 2u64.pow(self.consecutive_flush_failures.min(MAX_BACKOFF_EXPONENT));
Duration::from_millis(delay_ms.min(MAX_RETRY_DELAY_MS))
}
}
/// Message emitted when persistence fails
#[derive(Message, Debug, Clone)]
pub struct PersistenceFailureEvent {
pub error: String,
pub consecutive_failures: u32,
pub circuit_breaker_open: bool,
}
/// Message emitted when persistence recovers from failures
#[derive(Message, Debug, Clone)]
pub struct PersistenceRecoveryEvent {
pub previous_failures: u32,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_circuit_breaker() {
let mut health = PersistenceHealth::default();
// Should allow operations initially
assert!(health.should_attempt_operation());
assert!(!health.circuit_breaker_open);
// Record failures
for _ in 0..PersistenceHealth::CIRCUIT_BREAKER_THRESHOLD {
health.record_flush_failure();
}
// Circuit breaker should now be open
assert!(health.circuit_breaker_open);
assert!(!health.should_attempt_operation());
// Should still block immediately after opening
assert!(!health.should_attempt_operation());
}
#[test]
fn test_recovery() {
let mut health = PersistenceHealth::default();
// Trigger circuit breaker
for _ in 0..PersistenceHealth::CIRCUIT_BREAKER_THRESHOLD {
health.record_flush_failure();
}
assert!(health.circuit_breaker_open);
// Successful flush should close circuit breaker
health.record_flush_success();
assert!(!health.circuit_breaker_open);
assert_eq!(health.consecutive_flush_failures, 0);
}
#[test]
fn test_exponential_backoff() {
let mut health = PersistenceHealth::default();
// No failures = 1s delay
assert_eq!(health.get_retry_delay(), Duration::from_secs(1));
// 1 failure = 2s
health.record_flush_failure();
assert_eq!(health.get_retry_delay(), Duration::from_secs(2));
// 2 failures = 4s
health.record_flush_failure();
assert_eq!(health.get_retry_delay(), Duration::from_secs(4));
// Max out at 30s
for _ in 0..10 {
health.record_flush_failure();
}
assert_eq!(health.get_retry_delay(), Duration::from_secs(30));
}
}

View File

@@ -0,0 +1,158 @@
//! iOS lifecycle event handling for persistence
//!
//! This module provides event types and handlers for iOS application lifecycle
//! events that require immediate persistence (e.g., background suspension).
//!
//! # iOS Integration
//!
//! To integrate with iOS, wire up these handlers in your app delegate:
//!
//! ```swift
//! // In your iOS app delegate:
//! func applicationWillResignActive(_ application: UIApplication) {
//! // Send AppLifecycleEvent::WillResignActive to Bevy
//! }
//!
//! func applicationDidEnterBackground(_ application: UIApplication) {
//! // Send AppLifecycleEvent::DidEnterBackground to Bevy
//! }
//! ```
use crate::persistence::*;
use bevy::prelude::*;
/// Application lifecycle events that require persistence handling
///
/// These events are critical moments where data must be flushed immediately
/// to avoid data loss.
#[derive(Debug, Clone, Message)]
pub enum AppLifecycleEvent {
/// Application will resign active (iOS: `applicationWillResignActive`)
///
/// Sent when the app is about to move from active to inactive state.
/// Example: incoming phone call, user switches to another app
WillResignActive,
/// Application did enter background (iOS: `applicationDidEnterBackground`)
///
/// Sent when the app has moved to the background. The app has approximately
/// 5 seconds to complete critical tasks before suspension.
DidEnterBackground,
/// Application will enter foreground (iOS: `applicationWillEnterForeground`)
///
/// Sent when the app is about to enter the foreground (user returning to app).
WillEnterForeground,
/// Application did become active (iOS: `applicationDidBecomeActive`)
///
/// Sent when the app has become active and is ready to receive user input.
DidBecomeActive,
/// Application will terminate (iOS: `applicationWillTerminate`)
///
/// Sent when the app is about to terminate. Similar to shutdown but from OS.
WillTerminate,
}
/// System to handle iOS lifecycle events and trigger immediate persistence
///
/// This system listens for lifecycle events and performs immediate flushes
/// when the app is backgrounding or terminating.
pub fn lifecycle_event_system(
mut events: MessageReader<AppLifecycleEvent>,
mut write_buffer: ResMut<WriteBufferResource>,
db: Res<PersistenceDb>,
mut metrics: ResMut<PersistenceMetrics>,
mut health: ResMut<PersistenceHealth>,
mut pending_tasks: ResMut<PendingFlushTasks>,
) {
for event in events.read() {
match event {
AppLifecycleEvent::WillResignActive => {
// App is becoming inactive - perform immediate flush
info!("App will resign active - performing immediate flush");
if let Err(e) = force_flush(&mut write_buffer, &db, &mut metrics) {
error!("Failed to flush on resign active: {}", e);
health.record_flush_failure();
} else {
health.record_flush_success();
}
}
AppLifecycleEvent::DidEnterBackground => {
// App entered background - perform immediate flush and checkpoint
info!("App entered background - performing immediate flush and checkpoint");
// Force immediate flush
if let Err(e) = force_flush(&mut write_buffer, &db, &mut metrics) {
error!("Failed to flush on background: {}", e);
health.record_flush_failure();
} else {
health.record_flush_success();
}
// Also checkpoint the WAL to ensure durability
let start = std::time::Instant::now();
match db.lock() {
Ok(mut conn) => {
match checkpoint_wal(&mut conn, CheckpointMode::Passive) {
Ok(_) => {
let duration = start.elapsed();
metrics.record_checkpoint(duration);
health.record_checkpoint_success();
info!("Background checkpoint completed successfully");
}
Err(e) => {
error!("Failed to checkpoint on background: {}", e);
health.record_checkpoint_failure();
}
}
}
Err(e) => {
error!("Failed to acquire database lock for checkpoint: {}", e);
health.record_checkpoint_failure();
}
}
}
AppLifecycleEvent::WillTerminate => {
// App will terminate - perform shutdown sequence
warn!("App will terminate - performing shutdown sequence");
if let Err(e) = shutdown_system(&mut write_buffer, &db, &mut metrics, Some(&mut pending_tasks)) {
error!("Failed to perform shutdown on terminate: {}", e);
} else {
info!("Clean shutdown completed on terminate");
}
}
AppLifecycleEvent::WillEnterForeground => {
// App returning from background - no immediate action needed
info!("App will enter foreground");
}
AppLifecycleEvent::DidBecomeActive => {
// App became active - no immediate action needed
info!("App did become active");
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_lifecycle_event_creation() {
let event = AppLifecycleEvent::WillResignActive;
match event {
AppLifecycleEvent::WillResignActive => {
// Success
}
_ => panic!("Event type mismatch"),
}
}
}

View File

@@ -0,0 +1,211 @@
//! Metrics tracking for persistence layer
use std::time::Duration;
/// Metrics for monitoring persistence performance
#[derive(Debug, Clone, Default, bevy::prelude::Resource)]
pub struct PersistenceMetrics {
// Write volume
pub total_writes: u64,
pub bytes_written: u64,
// Timing
pub flush_count: u64,
pub total_flush_duration: Duration,
pub checkpoint_count: u64,
pub total_checkpoint_duration: Duration,
// WAL health
pub wal_size_bytes: u64,
pub max_wal_size_bytes: u64,
// Recovery
pub crash_recovery_count: u64,
pub clean_shutdown_count: u64,
// Buffer stats
pub max_buffer_size: usize,
pub total_coalesced_ops: u64,
}
impl PersistenceMetrics {
/// Record a flush operation
pub fn record_flush(&mut self, operations: usize, duration: Duration, bytes_written: u64) {
self.flush_count += 1;
self.total_writes += operations as u64;
self.total_flush_duration += duration;
self.bytes_written += bytes_written;
}
/// Record a checkpoint operation
pub fn record_checkpoint(&mut self, duration: Duration) {
self.checkpoint_count += 1;
self.total_checkpoint_duration += duration;
}
/// Update WAL size
pub fn update_wal_size(&mut self, size: u64) {
self.wal_size_bytes = size;
if size > self.max_wal_size_bytes {
self.max_wal_size_bytes = size;
}
}
/// Record a crash recovery
pub fn record_crash_recovery(&mut self) {
self.crash_recovery_count += 1;
}
/// Record a clean shutdown
pub fn record_clean_shutdown(&mut self) {
self.clean_shutdown_count += 1;
}
/// Record buffer stats
pub fn record_buffer_stats(&mut self, buffer_size: usize, coalesced: u64) {
if buffer_size > self.max_buffer_size {
self.max_buffer_size = buffer_size;
}
self.total_coalesced_ops += coalesced;
}
/// Get average flush duration
pub fn avg_flush_duration(&self) -> Duration {
if self.flush_count == 0 {
Duration::from_secs(0)
} else {
self.total_flush_duration / self.flush_count as u32
}
}
/// Get average checkpoint duration
pub fn avg_checkpoint_duration(&self) -> Duration {
if self.checkpoint_count == 0 {
Duration::from_secs(0)
} else {
self.total_checkpoint_duration / self.checkpoint_count as u32
}
}
/// Get crash recovery rate
pub fn crash_recovery_rate(&self) -> f64 {
let total = self.crash_recovery_count + self.clean_shutdown_count;
if total == 0 {
0.0
} else {
self.crash_recovery_count as f64 / total as f64
}
}
/// Check if metrics indicate performance issues
pub fn check_health(&self) -> Vec<HealthWarning> {
let mut warnings = Vec::new();
// Check flush duration
if self.avg_flush_duration() > Duration::from_millis(50) {
warnings.push(HealthWarning::SlowFlush(self.avg_flush_duration()));
}
// Check WAL size
if self.wal_size_bytes > 5 * 1024 * 1024 {
// 5MB
warnings.push(HealthWarning::LargeWal(self.wal_size_bytes));
}
// Check crash rate
if self.crash_recovery_rate() > 0.1 {
warnings.push(HealthWarning::HighCrashRate(self.crash_recovery_rate()));
}
warnings
}
/// Reset all metrics
pub fn reset(&mut self) {
*self = Self::default();
}
}
/// Health warnings for persistence metrics
#[derive(Debug, Clone)]
pub enum HealthWarning {
/// Flush operations are taking too long
SlowFlush(Duration),
/// WAL file is too large
LargeWal(u64),
/// High crash recovery rate
HighCrashRate(f64),
}
impl std::fmt::Display for HealthWarning {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
HealthWarning::SlowFlush(duration) => {
write!(
f,
"Flush duration ({:?}) exceeds 50ms threshold",
duration
)
}
HealthWarning::LargeWal(size) => {
write!(f, "WAL size ({} bytes) exceeds 5MB threshold", size)
}
HealthWarning::HighCrashRate(rate) => {
write!(f, "Crash recovery rate ({:.1}%) exceeds 10% threshold", rate * 100.0)
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_metrics_recording() {
let mut metrics = PersistenceMetrics::default();
metrics.record_flush(10, Duration::from_millis(5), 1024);
assert_eq!(metrics.flush_count, 1);
assert_eq!(metrics.total_writes, 10);
assert_eq!(metrics.bytes_written, 1024);
metrics.record_checkpoint(Duration::from_millis(10));
assert_eq!(metrics.checkpoint_count, 1);
}
#[test]
fn test_average_calculations() {
let mut metrics = PersistenceMetrics::default();
metrics.record_flush(10, Duration::from_millis(10), 1024);
metrics.record_flush(20, Duration::from_millis(20), 2048);
assert_eq!(metrics.avg_flush_duration(), Duration::from_millis(15));
}
#[test]
fn test_health_warnings() {
let mut metrics = PersistenceMetrics::default();
// Add slow flush
metrics.record_flush(10, Duration::from_millis(100), 1024);
let warnings = metrics.check_health();
assert_eq!(warnings.len(), 1);
assert!(matches!(warnings[0], HealthWarning::SlowFlush(_)));
}
#[test]
fn test_crash_recovery_rate() {
let mut metrics = PersistenceMetrics::default();
metrics.record_crash_recovery();
metrics.record_clean_shutdown();
metrics.record_clean_shutdown();
assert_eq!(metrics.crash_recovery_rate(), 1.0 / 3.0);
}
}

View File

@@ -0,0 +1,51 @@
//! Persistence layer for battery-efficient state management
//!
//! This module implements the persistence strategy defined in RFC 0002.
//! It provides a three-tier system to minimize disk I/O while maintaining data durability:
//!
//! 1. **In-Memory Dirty Tracking** - Track changes without writing immediately
//! 2. **Write Buffer** - Batch and coalesce operations before writing
//! 3. **SQLite with WAL Mode** - Controlled checkpoints to minimize fsync() calls
//!
//! # Example
//!
//! ```no_run
//! use lib::persistence::*;
//! use bevy::prelude::*;
//!
//! fn setup(mut commands: Commands) {
//! // Spawn an entity with the Persisted marker
//! commands.spawn(Persisted::new());
//! }
//!
//! // The persistence plugin automatically tracks changes to Persisted components
//! fn main() {
//! App::new()
//! .add_plugins(DefaultPlugins)
//! .add_plugins(PersistencePlugin::new("app.db"))
//! .add_systems(Startup, setup)
//! .run();
//! }
//! ```
mod types;
mod database;
mod systems;
mod config;
mod metrics;
mod plugin;
mod reflection;
mod health;
mod error;
mod lifecycle;
pub use types::*;
pub use database::*;
pub use systems::*;
pub use config::*;
pub use metrics::*;
pub use plugin::*;
pub use reflection::*;
pub use health::*;
pub use error::*;
pub use lifecycle::*;

View File

@@ -0,0 +1,259 @@
//! Bevy plugin for the persistence layer
//!
//! This module provides a Bevy plugin that sets up all the necessary resources
//! and systems for the persistence layer.
use crate::persistence::*;
use bevy::prelude::*;
use std::path::PathBuf;
use std::ops::{Deref, DerefMut};
/// Bevy plugin for persistence
///
/// # Example
///
/// ```no_run
/// use bevy::prelude::*;
/// use lib::persistence::PersistencePlugin;
///
/// App::new()
/// .add_plugins(PersistencePlugin::new("app.db"))
/// .run();
/// ```
pub struct PersistencePlugin {
/// Path to the SQLite database file
pub db_path: PathBuf,
/// Persistence configuration
pub config: PersistenceConfig,
}
impl PersistencePlugin {
/// Create a new persistence plugin with default configuration
pub fn new(db_path: impl Into<PathBuf>) -> Self {
Self {
db_path: db_path.into(),
config: PersistenceConfig::default(),
}
}
/// Create a new persistence plugin with custom configuration
pub fn with_config(db_path: impl Into<PathBuf>, config: PersistenceConfig) -> Self {
Self {
db_path: db_path.into(),
config,
}
}
/// Load configuration from a TOML file
pub fn with_config_file(
db_path: impl Into<PathBuf>,
config_path: impl AsRef<std::path::Path>,
) -> crate::persistence::error::Result<Self> {
let config = load_config_from_file(config_path)?;
Ok(Self {
db_path: db_path.into(),
config,
})
}
}
impl Plugin for PersistencePlugin {
fn build(&self, app: &mut App) {
// Initialize database
let db = PersistenceDb::from_path(&self.db_path)
.expect("Failed to initialize persistence database");
// Register types for reflection
app.register_type::<Persisted>();
// Add messages/events
app.add_message::<PersistenceFailureEvent>()
.add_message::<PersistenceRecoveryEvent>()
.add_message::<AppLifecycleEvent>();
// Insert resources
app.insert_resource(db)
.insert_resource(DirtyEntitiesResource::default())
.insert_resource(WriteBufferResource::new(self.config.max_buffer_operations))
.insert_resource(self.config.clone())
.insert_resource(BatteryStatus::default())
.insert_resource(PersistenceMetrics::default())
.insert_resource(CheckpointTimer::default())
.insert_resource(PersistenceHealth::default())
.insert_resource(PendingFlushTasks::default());
// Add startup system
app.add_systems(Startup, persistence_startup_system);
// Add systems in the appropriate schedule
app.add_systems(
Update,
(
lifecycle_event_system,
collect_dirty_entities_bevy_system,
flush_system,
checkpoint_bevy_system,
)
.chain(),
);
}
}
/// Resource wrapper for DirtyEntities
#[derive(Resource, Default)]
pub struct DirtyEntitiesResource(pub DirtyEntities);
impl std::ops::Deref for DirtyEntitiesResource {
type Target = DirtyEntities;
fn deref(&self) -> &Self::Target {
&self.0
}
}
impl std::ops::DerefMut for DirtyEntitiesResource {
fn deref_mut(&mut self) -> &mut Self::Target {
&mut self.0
}
}
/// Resource wrapper for WriteBuffer
#[derive(Resource)]
pub struct WriteBufferResource(pub WriteBuffer);
impl WriteBufferResource {
pub fn new(max_operations: usize) -> Self {
Self(WriteBuffer::new(max_operations))
}
}
impl std::ops::Deref for WriteBufferResource {
type Target = WriteBuffer;
fn deref(&self) -> &Self::Target {
&self.0
}
}
impl std::ops::DerefMut for WriteBufferResource {
fn deref_mut(&mut self) -> &mut Self::Target {
&mut self.0
}
}
/// Startup system to initialize persistence
fn persistence_startup_system(
db: Res<PersistenceDb>,
mut metrics: ResMut<PersistenceMetrics>,
) {
if let Err(e) = startup_system(db.deref(), metrics.deref_mut()) {
error!("Failed to initialize persistence: {}", e);
} else {
info!("Persistence system initialized");
}
}
/// System to collect dirty entities using Bevy's change detection
///
/// This system tracks changes to the `Persisted` component. When `Persisted` is
/// marked as changed (via `mark_dirty()` or direct mutation), ALL components on
/// that entity are serialized and added to the write buffer.
///
/// For automatic tracking without manual `mark_dirty()` calls, use the
/// `auto_track_component_changes_system` which automatically detects changes
/// to common components like Transform, GlobalTransform, etc.
fn collect_dirty_entities_bevy_system(
mut dirty: ResMut<DirtyEntitiesResource>,
mut write_buffer: ResMut<WriteBufferResource>,
query: Query<(Entity, &Persisted), Changed<Persisted>>,
world: &World,
type_registry: Res<AppTypeRegistry>,
) {
let registry = type_registry.read();
// Track changed entities and serialize all their components
for (entity, persisted) in query.iter() {
// Serialize all components on this entity (generic tracking)
let components = serialize_all_components_from_entity(entity, world, &registry);
// Add operations for each component
for (component_type, data) in components {
dirty.mark_dirty(persisted.network_id, &component_type);
write_buffer.add(PersistenceOp::UpsertComponent {
entity_id: persisted.network_id,
component_type,
data,
});
}
}
}
/// System to automatically track changes to common Bevy components
///
/// This system detects changes to Transform, automatically triggering persistence
/// by accessing `Persisted` mutably (which marks it as changed via Bevy's change detection).
///
/// Add this system to your app if you want automatic persistence of Transform changes:
///
/// ```no_run
/// # use bevy::prelude::*;
/// # use lib::persistence::*;
/// App::new()
/// .add_plugins(PersistencePlugin::new("app.db"))
/// .add_systems(Update, auto_track_transform_changes_system)
/// .run();
/// ```
pub fn auto_track_transform_changes_system(
mut query: Query<&mut Persisted, (With<Transform>, Changed<Transform>)>,
) {
// Simply accessing &mut Persisted triggers Bevy's change detection
for _persisted in query.iter_mut() {
// No-op - the mutable access itself marks Persisted as changed
}
}
/// System to checkpoint the WAL
fn checkpoint_bevy_system(
db: Res<PersistenceDb>,
config: Res<PersistenceConfig>,
mut timer: ResMut<CheckpointTimer>,
mut metrics: ResMut<PersistenceMetrics>,
mut health: ResMut<PersistenceHealth>,
) {
match checkpoint_system(db.deref(), config.deref(), timer.deref_mut(), metrics.deref_mut()) {
Ok(_) => {
health.record_checkpoint_success();
}
Err(e) => {
health.record_checkpoint_failure();
error!(
"Failed to checkpoint WAL (attempt {}): {}",
health.consecutive_checkpoint_failures,
e
);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_plugin_creation() {
let plugin = PersistencePlugin::new("test.db");
assert_eq!(plugin.db_path, PathBuf::from("test.db"));
}
#[test]
fn test_plugin_with_config() {
let mut config = PersistenceConfig::default();
config.flush_interval_secs = 5;
let plugin = PersistencePlugin::with_config("test.db", config);
assert_eq!(plugin.config.flush_interval_secs, 5);
}
}

View File

@@ -0,0 +1,255 @@
//! Reflection-based component serialization for persistence
//!
//! This module provides utilities to serialize and deserialize Bevy components
//! using reflection, allowing the persistence layer to work with any component
//! that implements Reflect.
use bevy::prelude::*;
use bevy::reflect::serde::{ReflectSerializer, ReflectDeserializer};
use bevy::reflect::TypeRegistry;
use crate::persistence::error::{PersistenceError, Result};
/// Marker component to indicate that an entity should be persisted
///
/// Add this component to any entity that should have its state persisted to disk.
/// The persistence system will automatically serialize all components on entities
/// with this marker when they change.
///
/// # Triggering Persistence
///
/// To trigger persistence after modifying components on an entity, access `Persisted`
/// mutably through a query. Bevy's change detection will automatically mark it as changed:
///
/// ```no_run
/// # use bevy::prelude::*;
/// # use lib::persistence::*;
/// fn update_position(mut query: Query<(&mut Transform, &mut Persisted)>) {
/// for (mut transform, mut persisted) in query.iter_mut() {
/// transform.translation.x += 1.0;
/// // Accessing &mut Persisted triggers change detection automatically
/// }
/// }
/// ```
///
/// Alternatively, use `auto_track_transform_changes_system` for automatic persistence
/// of Transform changes without manual queries.
#[derive(Component, Reflect, Default)]
#[reflect(Component)]
pub struct Persisted {
/// Unique network ID for this entity
pub network_id: uuid::Uuid,
}
impl Persisted {
pub fn new() -> Self {
Self {
network_id: uuid::Uuid::new_v4(),
}
}
pub fn with_id(network_id: uuid::Uuid) -> Self {
Self { network_id }
}
}
/// Trait for components that can be persisted
pub trait Persistable: Component + Reflect {
/// Get the type name for this component (used as key in database)
fn type_name() -> &'static str {
std::any::type_name::<Self>()
}
}
/// Serialize a component using Bevy's reflection system
///
/// This converts any component implementing `Reflect` into bytes for storage.
/// Uses bincode for efficient binary serialization with type information from
/// the registry to handle polymorphic types correctly.
///
/// # Parameters
/// - `component`: Component to serialize (must implement `Reflect`)
/// - `type_registry`: Bevy's type registry for reflection metadata
///
/// # Returns
/// - `Ok(Vec<u8>)`: Serialized component data
/// - `Err`: If serialization fails (e.g., type not properly registered)
///
/// # Examples
/// ```no_run
/// # use bevy::prelude::*;
/// # use lib::persistence::*;
/// # fn example(component: &Transform, registry: &AppTypeRegistry) -> anyhow::Result<()> {
/// let registry = registry.read();
/// let bytes = serialize_component(component.as_reflect(), &registry)?;
/// # Ok(())
/// # }
/// ```
pub fn serialize_component(
component: &dyn Reflect,
type_registry: &TypeRegistry,
) -> Result<Vec<u8>> {
let serializer = ReflectSerializer::new(component, type_registry);
bincode::serialize(&serializer).map_err(PersistenceError::from)
}
/// Deserialize a component using Bevy's reflection system
///
/// Converts serialized bytes back into a reflected component. The returned
/// component is boxed and must be downcast to the concrete type for use.
///
/// # Parameters
/// - `bytes`: Serialized component data from [`serialize_component`]
/// - `type_registry`: Bevy's type registry for reflection metadata
///
/// # Returns
/// - `Ok(Box<dyn PartialReflect>)`: Deserialized component (needs downcasting)
/// - `Err`: If deserialization fails (e.g., type not registered, data corruption)
///
/// # Examples
/// ```no_run
/// # use bevy::prelude::*;
/// # use lib::persistence::*;
/// # fn example(bytes: &[u8], registry: &AppTypeRegistry) -> anyhow::Result<()> {
/// let registry = registry.read();
/// let reflected = deserialize_component(bytes, &registry)?;
/// // Downcast to concrete type as needed
/// # Ok(())
/// # }
/// ```
pub fn deserialize_component(
bytes: &[u8],
type_registry: &TypeRegistry,
) -> Result<Box<dyn PartialReflect>> {
let mut deserializer = bincode::Deserializer::from_slice(bytes, bincode::options());
let reflect_deserializer = ReflectDeserializer::new(type_registry);
use serde::de::DeserializeSeed;
reflect_deserializer
.deserialize(&mut deserializer)
.map_err(|e| PersistenceError::Deserialization(e.to_string()))
}
/// Serialize a component directly from an entity using its type path
///
/// This is a convenience function that combines type lookup, reflection, and
/// serialization. It's the primary method used by the persistence system to
/// save component state without knowing the concrete type at compile time.
///
/// # Parameters
/// - `entity`: Bevy entity to read the component from
/// - `component_type`: Type path string (e.g., "bevy_transform::components::Transform")
/// - `world`: Bevy world containing the entity
/// - `type_registry`: Bevy's type registry for reflection metadata
///
/// # Returns
/// - `Some(Vec<u8>)`: Serialized component data
/// - `None`: If entity doesn't have the component or type isn't registered
///
/// # Examples
/// ```no_run
/// # use bevy::prelude::*;
/// # use lib::persistence::*;
/// # fn example(entity: Entity, world: &World, registry: &AppTypeRegistry) -> Option<()> {
/// let registry = registry.read();
/// let bytes = serialize_component_from_entity(
/// entity,
/// "bevy_transform::components::Transform",
/// world,
/// &registry
/// )?;
/// # Some(())
/// # }
/// ```
pub fn serialize_component_from_entity(
entity: Entity,
component_type: &str,
world: &World,
type_registry: &TypeRegistry,
) -> Option<Vec<u8>> {
// Get the type registration
let registration = type_registry.get_with_type_path(component_type)?;
// Get the ReflectComponent data
let reflect_component = registration.data::<ReflectComponent>()?;
// Reflect the component from the entity
let reflected = reflect_component.reflect(world.entity(entity))?;
// Serialize it directly
serialize_component(reflected, type_registry).ok()
}
/// Serialize all components from an entity that have reflection data
///
/// This iterates over all components on an entity and serializes those that:
/// - Are registered in the type registry
/// - Have `ReflectComponent` data (meaning they support reflection)
/// - Are not the `Persisted` marker component (to avoid redundant storage)
///
/// # Parameters
/// - `entity`: Bevy entity to serialize components from
/// - `world`: Bevy world containing the entity
/// - `type_registry`: Bevy's type registry for reflection metadata
///
/// # Returns
/// Vector of tuples containing (component_type_path, serialized_data) for each component
pub fn serialize_all_components_from_entity(
entity: Entity,
world: &World,
type_registry: &TypeRegistry,
) -> Vec<(String, Vec<u8>)> {
let mut components = Vec::new();
// Get the entity reference
let entity_ref = world.entity(entity);
// Iterate over all type registrations
for registration in type_registry.iter() {
// Skip if no ReflectComponent data (not a component)
let Some(reflect_component) = registration.data::<ReflectComponent>() else {
continue;
};
// Get the type path for this component
let type_path = registration.type_info().type_path();
// Skip the Persisted marker component itself (we don't need to persist it)
if type_path.ends_with("::Persisted") {
continue;
}
// Try to reflect this component from the entity
if let Some(reflected) = reflect_component.reflect(entity_ref) {
// Serialize the component
if let Ok(data) = serialize_component(reflected, type_registry) {
components.push((type_path.to_string(), data));
}
}
}
components
}
#[cfg(test)]
mod tests {
use super::*;
#[derive(Component, Reflect, Default)]
#[reflect(Component)]
struct TestComponent {
value: i32,
}
#[test]
fn test_component_serialization() -> Result<()> {
let mut registry = TypeRegistry::default();
registry.register::<TestComponent>();
let component = TestComponent { value: 42 };
let bytes = serialize_component(&component, &registry)?;
assert!(!bytes.is_empty());
Ok(())
}
}

View File

@@ -0,0 +1,459 @@
//! Bevy systems for the persistence layer
//!
//! This module provides systems that integrate the persistence layer with Bevy's ECS.
//! These systems handle dirty tracking, write buffering, and flushing to SQLite.
use crate::persistence::*;
use crate::persistence::error::Result;
use bevy::prelude::*;
use bevy::tasks::{IoTaskPool, Task};
use futures_lite::future;
use rusqlite::Connection;
use std::sync::{Arc, Mutex};
use std::time::Instant;
/// Resource wrapping the SQLite connection
#[derive(Clone, bevy::prelude::Resource)]
pub struct PersistenceDb {
pub conn: Arc<Mutex<Connection>>,
}
impl PersistenceDb {
pub fn new(conn: Connection) -> Self {
Self {
conn: Arc::new(Mutex::new(conn)),
}
}
pub fn from_path(path: impl AsRef<std::path::Path>) -> Result<Self> {
let conn = initialize_persistence_db(path)?;
Ok(Self::new(conn))
}
pub fn in_memory() -> Result<Self> {
let conn = Connection::open_in_memory()?;
configure_sqlite_for_persistence(&conn)?;
create_persistence_schema(&conn)?;
Ok(Self::new(conn))
}
/// Acquire the database connection with proper error handling
///
/// Handles mutex poisoning gracefully by converting to PersistenceError.
/// If a thread panics while holding the mutex, subsequent lock attempts
/// will fail with a poisoned error, which this method converts to a
/// recoverable error instead of panicking.
///
/// # Returns
/// - `Ok(MutexGuard<Connection>)`: Locked connection ready for use
/// - `Err(PersistenceError)`: If mutex is poisoned
pub fn lock(&self) -> Result<std::sync::MutexGuard<'_, Connection>> {
self.conn.lock()
.map_err(|e| PersistenceError::Other(format!("Database connection mutex poisoned: {}", e)))
}
}
/// Resource for tracking when the last checkpoint occurred
#[derive(Debug, bevy::prelude::Resource)]
pub struct CheckpointTimer {
pub last_checkpoint: Instant,
}
impl Default for CheckpointTimer {
fn default() -> Self {
Self {
last_checkpoint: Instant::now(),
}
}
}
/// Resource for tracking pending async flush tasks
#[derive(Default, bevy::prelude::Resource)]
pub struct PendingFlushTasks {
pub tasks: Vec<Task<Result<FlushResult>>>,
}
/// Result of an async flush operation
#[derive(Debug, Clone)]
pub struct FlushResult {
pub operations_count: usize,
pub duration: std::time::Duration,
pub bytes_written: u64,
}
/// Helper function to calculate total bytes written from operations
fn calculate_bytes_written(ops: &[PersistenceOp]) -> u64 {
ops.iter()
.map(|op| match op {
PersistenceOp::UpsertComponent { data, .. } => data.len() as u64,
PersistenceOp::LogOperation { operation, .. } => operation.len() as u64,
_ => 0,
})
.sum()
}
/// Helper function to perform a flush with metrics tracking (synchronous)
///
/// Used for critical operations like shutdown where we need to block
fn perform_flush_sync(
ops: &[PersistenceOp],
db: &PersistenceDb,
metrics: &mut PersistenceMetrics,
) -> Result<()> {
if ops.is_empty() {
return Ok(());
}
let start = Instant::now();
let count = {
let mut conn = db.lock()?;
flush_to_sqlite(ops, &mut conn)?
};
let duration = start.elapsed();
let bytes_written = calculate_bytes_written(ops);
metrics.record_flush(count, duration, bytes_written);
Ok(())
}
/// Helper function to perform a flush asynchronously (for normal operations)
///
/// This runs on the I/O task pool to avoid blocking the main thread
fn perform_flush_async(
ops: Vec<PersistenceOp>,
db: PersistenceDb,
) -> Result<FlushResult> {
if ops.is_empty() {
return Ok(FlushResult {
operations_count: 0,
duration: std::time::Duration::ZERO,
bytes_written: 0,
});
}
let bytes_written = calculate_bytes_written(&ops);
let start = Instant::now();
let count = {
let mut conn = db.lock()?;
flush_to_sqlite(&ops, &mut conn)?
};
let duration = start.elapsed();
Ok(FlushResult {
operations_count: count,
duration,
bytes_written,
})
}
/// System to flush the write buffer to SQLite asynchronously
///
/// This system runs on a schedule based on the configuration and battery status.
/// It spawns async tasks to avoid blocking the main thread and handles errors gracefully.
///
/// The system also polls pending flush tasks and updates metrics when they complete.
pub fn flush_system(
mut write_buffer: ResMut<WriteBufferResource>,
db: Res<PersistenceDb>,
config: Res<PersistenceConfig>,
battery: Res<BatteryStatus>,
mut metrics: ResMut<PersistenceMetrics>,
mut pending_tasks: ResMut<PendingFlushTasks>,
mut health: ResMut<PersistenceHealth>,
mut failure_events: MessageWriter<PersistenceFailureEvent>,
mut recovery_events: MessageWriter<PersistenceRecoveryEvent>,
) {
// First, poll and handle completed async flush tasks
pending_tasks.tasks.retain_mut(|task| {
if let Some(result) = future::block_on(future::poll_once(task)) {
match result {
Ok(flush_result) => {
let previous_failures = health.consecutive_flush_failures;
health.record_flush_success();
// Update metrics
metrics.record_flush(
flush_result.operations_count,
flush_result.duration,
flush_result.bytes_written,
);
// Emit recovery event if we recovered from failures
if previous_failures > 0 {
recovery_events.write(PersistenceRecoveryEvent {
previous_failures,
});
}
}
Err(e) => {
health.record_flush_failure();
let error_msg = format!("{}", e);
error!(
"Async flush failed (attempt {}/{}): {}",
health.consecutive_flush_failures,
PersistenceHealth::CIRCUIT_BREAKER_THRESHOLD,
error_msg
);
// Emit failure event
failure_events.write(PersistenceFailureEvent {
error: error_msg,
consecutive_failures: health.consecutive_flush_failures,
circuit_breaker_open: health.circuit_breaker_open,
});
}
}
false // Remove completed task
} else {
true // Keep pending task
}
});
// Check circuit breaker before spawning new flush
if !health.should_attempt_operation() {
return;
}
let flush_interval = config.get_flush_interval(battery.level, battery.is_charging);
// Check if we should flush
if !write_buffer.should_flush(flush_interval) {
return;
}
// Take operations from buffer
let ops = write_buffer.take_operations();
if ops.is_empty() {
return;
}
// Spawn async flush task on I/O thread pool
let task_pool = IoTaskPool::get();
let db_clone = db.clone();
let task = task_pool.spawn(async move {
perform_flush_async(ops, db_clone.clone())
});
pending_tasks.tasks.push(task);
// Update last flush time
write_buffer.last_flush = Instant::now();
}
/// System to checkpoint the WAL file
///
/// This runs less frequently than flush_system to merge the WAL into the main database.
pub fn checkpoint_system(
db: &PersistenceDb,
config: &PersistenceConfig,
timer: &mut CheckpointTimer,
metrics: &mut PersistenceMetrics,
) -> Result<()> {
let checkpoint_interval = config.get_checkpoint_interval();
// Check if it's time to checkpoint
if timer.last_checkpoint.elapsed() < checkpoint_interval {
// Also check WAL size
let wal_size = {
let conn = db.lock()?;
get_wal_size(&conn)?
};
metrics.update_wal_size(wal_size as u64);
// Force checkpoint if WAL is too large
if wal_size < config.max_wal_size_bytes as i64 {
return Ok(());
}
}
// Perform checkpoint
let start = Instant::now();
let info = {
let mut conn = db.lock()?;
checkpoint_wal(&mut conn, CheckpointMode::Passive)?
};
let duration = start.elapsed();
// Update metrics
metrics.record_checkpoint(duration);
timer.last_checkpoint = Instant::now();
// Log if checkpoint was busy
if info.busy {
tracing::warn!("WAL checkpoint was busy - some pages may not have been checkpointed");
}
Ok(())
}
/// System to handle application shutdown
///
/// This ensures a final flush and checkpoint before the application exits.
/// Uses synchronous flush to ensure all data is written before exit.
///
/// **CRITICAL**: Waits for all pending async flush tasks to complete before
/// proceeding with shutdown. This prevents data loss from in-flight operations.
pub fn shutdown_system(
write_buffer: &mut WriteBuffer,
db: &PersistenceDb,
metrics: &mut PersistenceMetrics,
pending_tasks: Option<&mut PendingFlushTasks>,
) -> Result<()> {
// CRITICAL: Wait for all pending async flushes to complete
// This prevents data loss from in-flight operations
if let Some(pending) = pending_tasks {
info!("Waiting for {} pending flush tasks to complete before shutdown", pending.tasks.len());
for task in pending.tasks.drain(..) {
// Block on each pending task to ensure completion
match future::block_on(task) {
Ok(flush_result) => {
// Update metrics for completed flush
metrics.record_flush(
flush_result.operations_count,
flush_result.duration,
flush_result.bytes_written,
);
debug!("Pending flush completed: {} operations", flush_result.operations_count);
}
Err(e) => {
error!("Pending flush failed during shutdown: {}", e);
// Continue with shutdown even if a task failed
}
}
}
info!("All pending flush tasks completed");
}
// Force flush any remaining operations (synchronous for shutdown)
let ops = write_buffer.take_operations();
perform_flush_sync(&ops, db, metrics)?;
// Checkpoint the WAL
let start = Instant::now();
{
let mut conn = db.lock()?;
checkpoint_wal(&mut conn, CheckpointMode::Truncate)?;
// Mark clean shutdown
mark_clean_shutdown(&mut conn)?;
}
let duration = start.elapsed();
metrics.record_checkpoint(duration);
metrics.record_clean_shutdown();
Ok(())
}
/// System to initialize persistence on startup
///
/// This checks for crash recovery and sets up the session.
pub fn startup_system(db: &PersistenceDb, metrics: &mut PersistenceMetrics) -> Result<()> {
let mut conn = db.lock()?;
// Check if previous session shut down cleanly
let clean_shutdown = check_clean_shutdown(&mut conn)?;
if !clean_shutdown {
tracing::warn!("Previous session did not shut down cleanly - crash detected");
metrics.record_crash_recovery();
// Perform any necessary recovery operations here
// For now, SQLite's WAL mode handles recovery automatically
} else {
tracing::info!("Previous session shut down cleanly");
}
// Set up new session
let session = SessionState::new();
set_session_state(&mut conn, "session_id", &session.session_id)?;
Ok(())
}
/// Helper function to force an immediate flush (for critical operations)
///
/// Uses synchronous flush to ensure data is written immediately.
/// Suitable for critical operations like iOS background events.
pub fn force_flush(
write_buffer: &mut WriteBuffer,
db: &PersistenceDb,
metrics: &mut PersistenceMetrics,
) -> Result<()> {
let ops = write_buffer.take_operations();
perform_flush_sync(&ops, db, metrics)?;
write_buffer.last_flush = Instant::now();
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_persistence_db_in_memory() -> Result<()> {
let db = PersistenceDb::in_memory()?;
// Verify we can write and read
let entity_id = uuid::Uuid::new_v4();
let ops = vec![PersistenceOp::UpsertEntity {
id: entity_id,
data: EntityData {
id: entity_id,
created_at: chrono::Utc::now(),
updated_at: chrono::Utc::now(),
entity_type: "TestEntity".to_string(),
},
}];
let mut conn = db.lock()?;
flush_to_sqlite(&ops, &mut conn)?;
Ok(())
}
#[test]
fn test_flush_system() -> Result<()> {
let db = PersistenceDb::in_memory()?;
let mut write_buffer = WriteBuffer::new(1000);
let mut metrics = PersistenceMetrics::default();
// Add some operations
let entity_id = uuid::Uuid::new_v4();
// First add the entity
write_buffer.add(PersistenceOp::UpsertEntity {
id: entity_id,
data: EntityData {
id: entity_id,
created_at: chrono::Utc::now(),
updated_at: chrono::Utc::now(),
entity_type: "TestEntity".to_string(),
},
});
// Then add a component
write_buffer.add(PersistenceOp::UpsertComponent {
entity_id,
component_type: "Transform".to_string(),
data: vec![1, 2, 3],
});
// Take operations and flush synchronously (testing the flush logic)
let ops = write_buffer.take_operations();
perform_flush_sync(&ops, &db, &mut metrics)?;
assert_eq!(metrics.flush_count, 1);
assert_eq!(write_buffer.len(), 0);
Ok(())
}
}

View File

@@ -0,0 +1,637 @@
//! Core types for the persistence layer
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use std::collections::{HashMap, HashSet};
use std::time::Instant;
use bevy::prelude::Resource;
/// Maximum size for a single component in bytes (10MB)
/// Components larger than this may indicate serialization issues or unbounded data growth
const MAX_COMPONENT_SIZE_BYTES: usize = 10 * 1024 * 1024;
/// Critical flush deadline in milliseconds (1 second for tier-1 operations)
const CRITICAL_FLUSH_DEADLINE_MS: u64 = 1000;
/// Unique identifier for entities that can be synced across nodes
pub type EntityId = uuid::Uuid;
/// Node identifier for CRDT operations
pub type NodeId = String;
/// Priority level for persistence operations
///
/// Determines how quickly an operation should be flushed to disk:
/// - **Normal**: Regular batched flushing (5-60s intervals based on battery)
/// - **Critical**: Flush within 1 second (tier-1 operations like user actions, CRDT ops)
/// - **Immediate**: Flush immediately (shutdown, background suspension)
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
pub enum FlushPriority {
/// Normal priority - regular batched flushing
Normal,
/// Critical priority - flush within 1 second
Critical,
/// Immediate priority - flush right now
Immediate,
}
/// Resource to track entities with uncommitted changes
#[derive(Debug, Default)]
pub struct DirtyEntities {
/// Set of entity IDs with changes not yet in write buffer
pub entities: HashSet<EntityId>,
/// Map of entity ID to set of dirty component type names
pub components: HashMap<EntityId, HashSet<String>>,
/// Track when each entity was last modified (for prioritization)
pub last_modified: HashMap<EntityId, Instant>,
}
impl DirtyEntities {
pub fn new() -> Self {
Self::default()
}
/// Mark an entity's component as dirty
pub fn mark_dirty(&mut self, entity_id: EntityId, component_type: impl Into<String>) {
self.entities.insert(entity_id);
self.components
.entry(entity_id)
.or_default()
.insert(component_type.into());
self.last_modified.insert(entity_id, Instant::now());
}
/// Clear all dirty tracking (called after flush to write buffer)
pub fn clear(&mut self) {
self.entities.clear();
self.components.clear();
self.last_modified.clear();
}
/// Check if an entity is dirty
pub fn is_dirty(&self, entity_id: &EntityId) -> bool {
self.entities.contains(entity_id)
}
/// Get the number of dirty entities
pub fn count(&self) -> usize {
self.entities.len()
}
}
/// Operations that can be persisted to the database
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum PersistenceOp {
/// Insert or update an entity's existence
UpsertEntity {
id: EntityId,
data: EntityData,
},
/// Insert or update a component on an entity
UpsertComponent {
entity_id: EntityId,
component_type: String,
data: Vec<u8>,
},
/// Log an operation for CRDT sync
LogOperation {
node_id: NodeId,
sequence: u64,
operation: Vec<u8>,
},
/// Update vector clock for causality tracking
UpdateVectorClock {
node_id: NodeId,
counter: u64,
},
/// Delete an entity
DeleteEntity {
id: EntityId,
},
/// Delete a component from an entity
DeleteComponent {
entity_id: EntityId,
component_type: String,
},
}
impl PersistenceOp {
/// Get the default priority for this operation type
///
/// CRDT operations (LogOperation, UpdateVectorClock) are critical tier-1 operations
/// that should be flushed within 1 second to maintain causality across nodes.
/// Other operations use normal priority by default.
pub fn default_priority(&self) -> FlushPriority {
match self {
// CRDT operations are tier-1 (critical)
PersistenceOp::LogOperation { .. } | PersistenceOp::UpdateVectorClock { .. } => {
FlushPriority::Critical
}
// All other operations are normal priority by default
_ => FlushPriority::Normal,
}
}
}
/// Metadata about an entity
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct EntityData {
pub id: EntityId,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
pub entity_type: String,
}
/// Write buffer for batching persistence operations
#[derive(Debug)]
pub struct WriteBuffer {
/// Pending operations not yet committed to SQLite
pub pending_operations: Vec<PersistenceOp>,
/// When the buffer was last flushed
pub last_flush: Instant,
/// Maximum number of operations before forcing a flush
pub max_operations: usize,
/// Highest priority operation currently in the buffer
pub highest_priority: FlushPriority,
/// When the first critical operation was added (for deadline tracking)
pub first_critical_time: Option<Instant>,
}
impl WriteBuffer {
pub fn new(max_operations: usize) -> Self {
Self {
pending_operations: Vec::new(),
last_flush: Instant::now(),
max_operations,
highest_priority: FlushPriority::Normal,
first_critical_time: None,
}
}
/// Add an operation to the write buffer with normal priority
///
/// This is a convenience method that calls `add_with_priority` with `FlushPriority::Normal`.
///
/// # Panics
/// Panics if component data exceeds MAX_COMPONENT_SIZE_BYTES (10MB)
pub fn add(&mut self, op: PersistenceOp) {
self.add_with_priority(op, FlushPriority::Normal);
}
/// Add an operation using its default priority
///
/// Uses `PersistenceOp::default_priority()` to determine priority automatically.
/// CRDT operations will be added as Critical, others as Normal.
///
/// # Panics
/// Panics if component data exceeds MAX_COMPONENT_SIZE_BYTES (10MB)
pub fn add_with_default_priority(&mut self, op: PersistenceOp) {
let priority = op.default_priority();
self.add_with_priority(op, priority);
}
/// Add an operation to the write buffer with the specified priority
///
/// If an operation for the same entity+component already exists,
/// it will be replaced (keeping only the latest state). The priority
/// is tracked separately to determine flush urgency.
///
/// # Panics
/// Panics if component data exceeds MAX_COMPONENT_SIZE_BYTES (10MB)
pub fn add_with_priority(&mut self, op: PersistenceOp, priority: FlushPriority) {
// Validate component size to prevent unbounded memory growth
match &op {
PersistenceOp::UpsertComponent { data, component_type, .. } => {
if data.len() > MAX_COMPONENT_SIZE_BYTES {
panic!(
"Component {} size ({} bytes) exceeds maximum ({} bytes). \
This may indicate unbounded data growth or serialization issues.",
component_type,
data.len(),
MAX_COMPONENT_SIZE_BYTES
);
}
}
PersistenceOp::LogOperation { operation, .. } => {
if operation.len() > MAX_COMPONENT_SIZE_BYTES {
panic!(
"Operation size ({} bytes) exceeds maximum ({} bytes)",
operation.len(),
MAX_COMPONENT_SIZE_BYTES
);
}
}
_ => {}
}
match &op {
PersistenceOp::UpsertComponent { entity_id, component_type, .. } => {
// Remove any existing pending write for this entity+component
self.pending_operations.retain(|existing_op| {
!matches!(existing_op,
PersistenceOp::UpsertComponent {
entity_id: e_id,
component_type: c_type,
..
} if e_id == entity_id && c_type == component_type
)
});
}
PersistenceOp::UpsertEntity { id, .. } => {
// Remove any existing pending write for this entity
self.pending_operations.retain(|existing_op| {
!matches!(existing_op,
PersistenceOp::UpsertEntity { id: e_id, .. }
if e_id == id
)
});
}
_ => {
// Other operations don't need coalescing
}
}
// Track priority for flush urgency
if priority > self.highest_priority {
self.highest_priority = priority;
}
// Track when first critical operation was added (for deadline enforcement)
if priority >= FlushPriority::Critical && self.first_critical_time.is_none() {
self.first_critical_time = Some(Instant::now());
}
self.pending_operations.push(op);
}
/// Take all pending operations and return them for flushing
///
/// This resets the priority tracking state.
pub fn take_operations(&mut self) -> Vec<PersistenceOp> {
// Reset priority tracking when operations are taken
self.highest_priority = FlushPriority::Normal;
self.first_critical_time = None;
std::mem::take(&mut self.pending_operations)
}
/// Check if buffer should be flushed
///
/// Returns true if any of these conditions are met:
/// - Buffer is at capacity (max_operations reached)
/// - Regular flush interval has elapsed (for normal priority)
/// - Critical operation deadline exceeded (1 second for critical ops)
/// - Immediate priority operation exists
pub fn should_flush(&self, flush_interval: std::time::Duration) -> bool {
// Immediate priority always flushes
if self.highest_priority == FlushPriority::Immediate {
return true;
}
// Critical priority flushes after 1 second deadline
if self.highest_priority == FlushPriority::Critical {
if let Some(critical_time) = self.first_critical_time {
if critical_time.elapsed().as_millis() >= CRITICAL_FLUSH_DEADLINE_MS as u128 {
return true;
}
}
}
// Normal flushing conditions
self.pending_operations.len() >= self.max_operations
|| self.last_flush.elapsed() >= flush_interval
}
/// Get the number of pending operations
pub fn len(&self) -> usize {
self.pending_operations.len()
}
/// Check if the buffer is empty
pub fn is_empty(&self) -> bool {
self.pending_operations.is_empty()
}
}
/// Battery status for adaptive flushing
#[derive(Debug, Clone, Copy, Resource)]
pub struct BatteryStatus {
/// Battery level from 0.0 to 1.0
pub level: f32,
/// Whether the device is currently charging
pub is_charging: bool,
/// Whether low power mode is enabled (iOS)
pub is_low_power_mode: bool,
}
impl Default for BatteryStatus {
fn default() -> Self {
Self {
level: 1.0,
is_charging: false,
is_low_power_mode: false,
}
}
}
impl BatteryStatus {
/// Update battery status from iOS UIDevice.batteryLevel
///
/// # iOS Integration Example
///
/// ```swift
/// // In your iOS app code:
/// UIDevice.current.isBatteryMonitoringEnabled = true
/// let batteryLevel = UIDevice.current.batteryLevel // Returns 0.0 to 1.0
/// let isCharging = UIDevice.current.batteryState == .charging ||
/// UIDevice.current.batteryState == .full
/// let isLowPowerMode = ProcessInfo.processInfo.isLowPowerModeEnabled
///
/// // Update Bevy resource (this is pseudocode - actual implementation depends on your bridge)
/// battery_status.update_from_ios(batteryLevel, isCharging, isLowPowerMode);
/// ```
pub fn update_from_ios(&mut self, level: f32, is_charging: bool, is_low_power_mode: bool) {
self.level = level.clamp(0.0, 1.0);
self.is_charging = is_charging;
self.is_low_power_mode = is_low_power_mode;
}
/// Check if the device is in a battery-critical state
///
/// Returns true if battery is low (<20%) and not charging, or low power mode is enabled.
pub fn is_battery_critical(&self) -> bool {
(self.level < 0.2 && !self.is_charging) || self.is_low_power_mode
}
}
/// Session state tracking for crash detection
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SessionState {
pub session_id: String,
pub started_at: DateTime<Utc>,
pub clean_shutdown: bool,
}
impl SessionState {
pub fn new() -> Self {
Self {
session_id: uuid::Uuid::new_v4().to_string(),
started_at: Utc::now(),
clean_shutdown: false,
}
}
}
impl Default for SessionState {
fn default() -> Self {
Self::new()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_dirty_entities_tracking() {
let mut dirty = DirtyEntities::new();
let entity_id = EntityId::new_v4();
dirty.mark_dirty(entity_id, "Transform");
assert!(dirty.is_dirty(&entity_id));
assert_eq!(dirty.count(), 1);
dirty.clear();
assert!(!dirty.is_dirty(&entity_id));
assert_eq!(dirty.count(), 0);
}
#[test]
fn test_write_buffer_coalescing() {
let mut buffer = WriteBuffer::new(100);
let entity_id = EntityId::new_v4();
// Add first version
buffer.add(PersistenceOp::UpsertComponent {
entity_id,
component_type: "Transform".to_string(),
data: vec![1, 2, 3],
});
assert_eq!(buffer.len(), 1);
// Add second version (should replace first)
buffer.add(PersistenceOp::UpsertComponent {
entity_id,
component_type: "Transform".to_string(),
data: vec![4, 5, 6],
});
assert_eq!(buffer.len(), 1);
// Verify only latest version exists
let ops = buffer.take_operations();
assert_eq!(ops.len(), 1);
if let PersistenceOp::UpsertComponent { data, .. } = &ops[0] {
assert_eq!(data, &vec![4, 5, 6]);
} else {
panic!("Expected UpsertComponent");
}
}
#[test]
fn test_write_buffer_different_components() {
let mut buffer = WriteBuffer::new(100);
let entity_id = EntityId::new_v4();
// Add Transform
buffer.add(PersistenceOp::UpsertComponent {
entity_id,
component_type: "Transform".to_string(),
data: vec![1, 2, 3],
});
// Add Velocity (different component, should not coalesce)
buffer.add(PersistenceOp::UpsertComponent {
entity_id,
component_type: "Velocity".to_string(),
data: vec![4, 5, 6],
});
assert_eq!(buffer.len(), 2);
}
#[test]
fn test_flush_priority_immediate() {
let mut buffer = WriteBuffer::new(100);
let entity_id = EntityId::new_v4();
// Add operation with immediate priority
buffer.add_with_priority(
PersistenceOp::UpsertEntity {
id: entity_id,
data: EntityData {
id: entity_id,
created_at: chrono::Utc::now(),
updated_at: chrono::Utc::now(),
entity_type: "TestEntity".to_string(),
},
},
FlushPriority::Immediate,
);
// Should flush immediately regardless of interval
assert!(buffer.should_flush(std::time::Duration::from_secs(100)));
assert_eq!(buffer.highest_priority, FlushPriority::Immediate);
}
#[test]
fn test_flush_priority_critical_deadline() {
let mut buffer = WriteBuffer::new(100);
let entity_id = EntityId::new_v4();
// Add operation with critical priority
buffer.add_with_priority(
PersistenceOp::UpsertEntity {
id: entity_id,
data: EntityData {
id: entity_id,
created_at: chrono::Utc::now(),
updated_at: chrono::Utc::now(),
entity_type: "TestEntity".to_string(),
},
},
FlushPriority::Critical,
);
assert_eq!(buffer.highest_priority, FlushPriority::Critical);
assert!(buffer.first_critical_time.is_some());
// Should not flush immediately
assert!(!buffer.should_flush(std::time::Duration::from_secs(100)));
// Simulate deadline passing by manually setting the time
buffer.first_critical_time =
Some(Instant::now() - std::time::Duration::from_millis(CRITICAL_FLUSH_DEADLINE_MS + 100));
// Now should flush due to deadline
assert!(buffer.should_flush(std::time::Duration::from_secs(100)));
}
#[test]
fn test_flush_priority_normal() {
let mut buffer = WriteBuffer::new(100);
let entity_id = EntityId::new_v4();
// Add normal priority operation
buffer.add(PersistenceOp::UpsertEntity {
id: entity_id,
data: EntityData {
id: entity_id,
created_at: chrono::Utc::now(),
updated_at: chrono::Utc::now(),
entity_type: "TestEntity".to_string(),
},
});
assert_eq!(buffer.highest_priority, FlushPriority::Normal);
assert!(buffer.first_critical_time.is_none());
// Should not flush before interval
assert!(!buffer.should_flush(std::time::Duration::from_secs(100)));
// Set last flush to past
buffer.last_flush = Instant::now() - std::time::Duration::from_secs(200);
// Now should flush
assert!(buffer.should_flush(std::time::Duration::from_secs(100)));
}
#[test]
fn test_priority_reset_on_take() {
let mut buffer = WriteBuffer::new(100);
let entity_id = EntityId::new_v4();
// Add critical operation
buffer.add_with_priority(
PersistenceOp::UpsertEntity {
id: entity_id,
data: EntityData {
id: entity_id,
created_at: chrono::Utc::now(),
updated_at: chrono::Utc::now(),
entity_type: "TestEntity".to_string(),
},
},
FlushPriority::Critical,
);
assert_eq!(buffer.highest_priority, FlushPriority::Critical);
assert!(buffer.first_critical_time.is_some());
// Take operations
let ops = buffer.take_operations();
assert_eq!(ops.len(), 1);
// Priority should be reset
assert_eq!(buffer.highest_priority, FlushPriority::Normal);
assert!(buffer.first_critical_time.is_none());
}
#[test]
fn test_default_priority_for_crdt_ops() {
let log_op = PersistenceOp::LogOperation {
node_id: "node1".to_string(),
sequence: 1,
operation: vec![1, 2, 3],
};
let vector_clock_op = PersistenceOp::UpdateVectorClock {
node_id: "node1".to_string(),
counter: 42,
};
let entity_op = PersistenceOp::UpsertEntity {
id: EntityId::new_v4(),
data: EntityData {
id: EntityId::new_v4(),
created_at: chrono::Utc::now(),
updated_at: chrono::Utc::now(),
entity_type: "TestEntity".to_string(),
},
};
// CRDT operations should have Critical priority
assert_eq!(log_op.default_priority(), FlushPriority::Critical);
assert_eq!(vector_clock_op.default_priority(), FlushPriority::Critical);
// Other operations should have Normal priority
assert_eq!(entity_op.default_priority(), FlushPriority::Normal);
}
#[test]
fn test_add_with_default_priority() {
let mut buffer = WriteBuffer::new(100);
// Add CRDT operation using default priority
buffer.add_with_default_priority(PersistenceOp::LogOperation {
node_id: "node1".to_string(),
sequence: 1,
operation: vec![1, 2, 3],
});
// Should be tracked as Critical
assert_eq!(buffer.highest_priority, FlushPriority::Critical);
assert!(buffer.first_critical_time.is_some());
}
}

View File

@@ -54,7 +54,14 @@ impl<T: Clone> SyncedValue<T> {
} }
pub fn merge(&mut self, other: &Self) { pub fn merge(&mut self, other: &Self) {
self.apply_lww(other.value.clone(), other.timestamp, other.node_id.clone()); // Only clone if we're actually going to use the values (when other is newer)
if other.timestamp > self.timestamp
|| (other.timestamp == self.timestamp && other.node_id > self.node_id)
{
self.value = other.value.clone();
self.timestamp = other.timestamp;
self.node_id = other.node_id.clone();
}
} }
} }

24
rustfmt.toml Normal file
View File

@@ -0,0 +1,24 @@
binop_separator = "Back"
brace_style = "PreferSameLine"
control_brace_style = "AlwaysSameLine"
comment_width = 80
edition = "2021"
enum_discrim_align_threshold = 40
fn_params_layout = "Tall"
fn_single_line = false
force_explicit_abi = true
force_multiline_blocks = false
format_code_in_doc_comments = true
format_macro_matchers = true
format_macro_bodies = true
hex_literal_case = "Lower"
imports_indent = "Block"
imports_layout = "Vertical"
match_arm_leading_pipes = "Always"
match_block_trailing_comma = true
imports_granularity = "Crate"
normalize_doc_attributes = true
reorder_impl_items = true
reorder_imports = true
group_imports = "StdExternalCrate"
wrap_comments = true