use std::collections::btree_map::Entry;
use std::path::{Path, PathBuf};
use std::string::{String, ToString};
use std::vec::Vec;
use crate::bytes::Bytes;
use crate::future::block_on;
use crate::persistence::turso::{self as database, connect};
use super::flat;
use super::{
BundleError, BundleManifest, EnvironmentInfo, MANIFEST_SCHEMA, SqliteBundle,
flat_bundle_version,
};
const SELECT: &str = "SELECT namespace, key, value FROM entries";
const NAMESPACE_PREFIX: &str = "namespace = ?1 OR substr(namespace, 1, length(?1) + 1) = ?1 || '/'";
const INSERT: &str = "INSERT INTO entries (namespace, key, value, origin) \
VALUES (?1, ?2, ?3, 1) ON CONFLICT DO NOTHING";
const SIDECARS: [&str; 2] = ["-wal", "-shm"];
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub enum BundleFormat {
#[default]
Sqlite,
Flat,
}
#[derive(Debug, Clone, Default)]
pub struct ExportOptions {
pub name: String,
pub environments: Vec<EnvironmentInfo>,
pub namespaces: Vec<String>,
pub excluded_namespaces: Vec<String>,
pub format: BundleFormat,
}
pub fn export<R: AsRef<Path>, O: AsRef<Path>>(
cache_roots: &[R],
out: O,
options: &ExportOptions,
) -> Result<BundleManifest, BundleError> {
let out = out.as_ref();
prepare_output(out, options.format)?;
let manifest = BundleManifest {
schema: MANIFEST_SCHEMA,
name: options.name.clone(),
cubecl_version: env!("CARGO_PKG_VERSION").to_string(),
created_unix_secs: std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.ok()
.map(|elapsed| elapsed.as_secs()),
environments: resolve_environments(&options.environments),
};
let sources: Vec<PathBuf> = cache_roots
.iter()
.filter_map(|root| {
let root = root.as_ref();
source_database(root).or_else(|| {
log::warn!("Bundle export: no cache database under {root:?}, skipping.");
None
})
})
.collect();
let staged = staging_path(out);
discard(&staged);
let namespaces = filters(&options.namespaces);
let excluded = &options.excluded_namespaces;
let exported = match options.format {
BundleFormat::Sqlite => export_sqlite(&staged, &sources, namespaces, excluded, &manifest),
BundleFormat::Flat => export_flat(&staged, &sources, namespaces, excluded, &manifest),
};
let exported = match exported {
Ok(exported) => exported,
Err(err) => {
discard(&staged);
return Err(err);
}
};
publish(&staged, out)?;
if exported == 0 {
log::warn!(
"Bundle export: no entries matched. Run the application once so the caches \
are warm, and check the namespace prefixes."
);
}
Ok(manifest)
}
fn under(namespace: &str, prefix: &str) -> bool {
namespace
.strip_prefix(prefix)
.is_some_and(|rest| rest.is_empty() || rest.starts_with('/'))
}
fn filters(namespaces: &[String]) -> Option<&[String]> {
let unrestricted = namespaces.is_empty() || namespaces.iter().any(String::is_empty);
(!unrestricted).then_some(namespaces)
}
fn export_sqlite(
out: &Path,
sources: &[PathBuf],
namespaces: Option<&[String]>,
excluded: &[String],
manifest: &BundleManifest,
) -> Result<usize, BundleError> {
let location = location(out)?;
let target = block_on(
turso::Builder::new_local(location)
.experimental_vacuum(true)
.build(),
)
.map_err(storage_error)?;
database::migrate(&target).map_err(storage_error)?;
let mut connection = connect(&target).map_err(storage_error)?;
manifest.write(&connection)?;
let transaction = block_on(
connection.transaction_with_behavior(turso::transaction::TransactionBehavior::Immediate),
)
.map_err(storage_error)?;
let mut exported = 0;
for source in sources {
exported += read_entries(source, namespaces, &mut Sink::Sqlite(&transaction))?;
}
block_on(transaction.commit()).map_err(storage_error)?;
let delete = std::format!("DELETE FROM entries WHERE {NAMESPACE_PREFIX}");
let mut removed = 0;
for prefix in excluded.iter().filter(|prefix| !prefix.is_empty()) {
removed += block_on(connection.execute(&delete, (prefix.as_str(),)))
.map_err(storage_error)? as usize;
}
if removed > 0 {
block_on(connection.execute("VACUUM", ())).map_err(storage_error)?;
}
exported -= removed;
if !database::checkpoint(&connection).map_err(storage_error)? {
return Err(BundleError::Storage(
"the bundle's WAL checkpoint did not complete".to_string(),
));
}
drop(connection);
drop(target);
finalize_for_shipping(out)?;
Ok(exported)
}
const JOURNAL_MODE_BYTES: core::ops::Range<u64> = 18..20;
const LEGACY_JOURNAL: [u8; 2] = [1, 1];
fn finalize_for_shipping(out: &Path) -> Result<(), BundleError> {
use std::io::{Seek, SeekFrom, Write};
let mut file = std::fs::OpenOptions::new().write(true).open(out)?;
file.seek(SeekFrom::Start(JOURNAL_MODE_BYTES.start))?;
file.write_all(&LEGACY_JOURNAL)?;
file.sync_all()?;
Ok(())
}
fn export_flat(
out: &Path,
sources: &[PathBuf],
namespaces: Option<&[String]>,
excluded: &[String],
manifest: &BundleManifest,
) -> Result<usize, BundleError> {
let mut entries = flat::Entries::new();
for source in sources {
read_entries(source, namespaces, &mut Sink::Flat(&mut entries))?;
}
entries.retain(|(namespace, _), _| {
!excluded
.iter()
.any(|prefix| !prefix.is_empty() && under(namespace, prefix))
});
flat::write(out, &entries, manifest)?;
Ok(entries.len())
}
enum Sink<'a> {
Sqlite(&'a turso::Connection),
Flat(&'a mut flat::Entries),
}
impl Sink<'_> {
fn push(
&mut self,
namespace: String,
key: Vec<u8>,
value: Vec<u8>,
) -> Result<usize, BundleError> {
match self {
Sink::Sqlite(connection) => {
let inserted = block_on(connection.execute(INSERT, (namespace, key, value)))
.map_err(storage_error)?;
Ok(inserted as usize)
}
Sink::Flat(entries) => match entries.entry((namespace, key)) {
Entry::Vacant(vacant) => {
vacant.insert(Bytes::from_bytes_vec(value));
Ok(1)
}
Entry::Occupied(_) => Ok(0),
},
}
}
}
fn read_entries(
source: &Path,
namespaces: Option<&[String]>,
sink: &mut Sink<'_>,
) -> Result<usize, BundleError> {
let location = location(source)?;
let database = block_on(turso::Builder::new_local(location).read_only(true).build())
.map_err(storage_error)?;
let connection = connect(&database).map_err(storage_error)?;
match namespaces {
None => {
let rows = block_on(connection.query(SELECT, ())).map_err(storage_error)?;
collect(rows, sink)
}
Some(namespaces) => {
let query = std::format!("{SELECT} WHERE {NAMESPACE_PREFIX}");
let mut accepted = 0;
for namespace in namespaces {
let rows = block_on(connection.query(&query, (namespace.as_str(),)))
.map_err(storage_error)?;
accepted += collect(rows, sink)?;
}
Ok(accepted)
}
}
}
fn collect(mut rows: turso::Rows, sink: &mut Sink<'_>) -> Result<usize, BundleError> {
let mut accepted = 0;
while let Some(row) = block_on(rows.next()).map_err(storage_error)? {
let namespace: String = row.get(0).map_err(storage_error)?;
let key: Vec<u8> = row.get(1).map_err(storage_error)?;
let value: Vec<u8> = row.get(2).map_err(storage_error)?;
accepted += sink.push(namespace, key, value)?;
}
Ok(accepted)
}
fn source_database(root: &Path) -> Option<PathBuf> {
let path = if root.is_dir() {
root.join(crate::environment::file_name(&crate::environment::active()))
} else {
root.to_path_buf()
};
path.is_file().then_some(path)
}
fn location(path: &Path) -> Result<&str, BundleError> {
path.to_str()
.ok_or_else(|| BundleError::Storage(std::format!("cache path {path:?} is not valid UTF-8")))
}
fn prepare_output(out: &Path, format: BundleFormat) -> Result<(), BundleError> {
if let Some(parent) = out.parent()
&& !parent.as_os_str().is_empty()
{
std::fs::create_dir_all(parent)?;
}
if !out.exists() {
return Ok(());
}
let existing = match format {
BundleFormat::Sqlite => match SqliteBundle::open(out) {
Ok(bundle) => Some(std::format!("'{}'", bundle.manifest().name)),
Err(BundleError::UnsupportedDatabase(schema)) => {
Some(std::format!("(database schema {schema})"))
}
Err(_) => None,
},
BundleFormat::Flat => flat_header(out).map(|version| std::format!("(flat v{version})")),
};
match existing {
Some(described) => log::info!("Replacing the existing bundle {described} at {out:?}"),
None => {
return Err(BundleError::Io(std::io::Error::new(
std::io::ErrorKind::AlreadyExists,
std::format!("{out:?} exists and is not a cubecl bundle; remove it first"),
)));
}
}
Ok(())
}
fn flat_header(out: &Path) -> Option<u32> {
use std::io::Read;
let mut header = [0u8; 12];
let mut file = std::fs::File::open(out).ok()?;
file.read_exact(&mut header).ok()?;
flat_bundle_version(&header)
}
fn staging_path(out: &Path) -> PathBuf {
PathBuf::from(std::format!("{}.tmp", out.display()))
}
fn sidecar(path: &Path, suffix: &str) -> PathBuf {
PathBuf::from(std::format!("{}{suffix}", path.display()))
}
fn discard(staged: &Path) {
for path in [staged.to_path_buf()]
.into_iter()
.chain(SIDECARS.iter().map(|suffix| sidecar(staged, suffix)))
{
if path.exists()
&& let Err(err) = std::fs::remove_file(&path)
{
log::warn!("Bundle export: can't remove {path:?}: {err}");
}
}
}
fn publish(staged: &Path, out: &Path) -> Result<(), BundleError> {
for suffix in SIDECARS {
for stale in [sidecar(out, suffix), sidecar(staged, suffix)] {
if stale.exists() {
std::fs::remove_file(stale)?;
}
}
}
std::fs::rename(staged, out)?;
Ok(())
}
fn resolve_environments(configured: &[EnvironmentInfo]) -> Vec<EnvironmentInfo> {
let mut environments = configured.to_vec();
if environments.is_empty() {
environments.push(EnvironmentInfo::default());
}
for environment in &mut environments {
if environment.os.is_empty() {
environment.os = std::env::consts::OS.to_string();
}
if environment.arch.is_empty() {
environment.arch = std::env::consts::ARCH.to_string();
}
}
environments
}
pub(super) fn storage_error(error: turso::Error) -> BundleError {
BundleError::Storage(error.to_string())
}