use std::path::{Path, PathBuf};
use std::string::{String, ToString};
use std::vec::Vec;
use crate::bytes::Bytes;
use crate::persistence::{Database, db_file_name};
use super::flat;
use super::{BundleError, BundleManifest, EnvironmentInfo, MANIFEST_SCHEMA, flat_bundle_version};
const NAMESPACE_PREFIX: &str = "namespace = ?1 OR substr(namespace, 1, length(?1) + 1) = ?1 || '/'";
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 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 exported = match options.format {
BundleFormat::Sqlite => export_sqlite(&staged, &sources, namespaces, &manifest),
BundleFormat::Flat => export_flat(&staged, &sources, namespaces, &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 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]>,
manifest: &BundleManifest,
) -> Result<usize, BundleError> {
let database = Database::open(out, false)?;
let mut exported = 0;
for source in sources {
exported += copy_entries(&database, source, namespaces)?;
}
manifest.write(&database)?;
database.finalize_for_shipping()?;
Ok(exported)
}
fn export_flat(
out: &Path,
sources: &[PathBuf],
namespaces: Option<&[String]>,
manifest: &BundleManifest,
) -> Result<usize, BundleError> {
let mut entries = flat::Entries::new();
for source in sources {
let database = Database::open(source, true)?;
read_entries(&database, namespaces, &mut entries)?;
}
flat::write(out, &entries, manifest)?;
Ok(entries.len())
}
fn read_entries(
database: &Database,
namespaces: Option<&[String]>,
entries: &mut flat::Entries,
) -> Result<(), BundleError> {
const SELECT: &str = "SELECT namespace, key, value FROM entries";
database.with_connection(|conn| {
let mut collect = |rows: &mut rusqlite::Rows<'_>| -> Result<(), rusqlite::Error> {
while let Some(row) = rows.next()? {
let namespace: String = row.get(0)?;
let key: Vec<u8> = row.get(1)?;
let value = Bytes::from_bytes_vec(row.get(2)?);
entries.entry((namespace, key)).or_insert(value);
}
Ok(())
};
match namespaces {
None => collect(&mut conn.prepare(SELECT)?.query([])?),
Some(namespaces) => {
let mut statement =
conn.prepare(&std::format!("{SELECT} WHERE {NAMESPACE_PREFIX}"))?;
for namespace in namespaces {
collect(&mut statement.query(rusqlite::params![namespace])?)?;
}
Ok(())
}
}
})?;
Ok(())
}
fn source_database(root: &Path) -> Option<PathBuf> {
let path = if root.is_dir() {
root.join(db_file_name(&crate::environment::active()))
} else {
root.to_path_buf()
};
path.is_file().then_some(path)
}
fn copy_entries(
database: &Database,
source: &Path,
namespaces: Option<&[String]>,
) -> Result<usize, BundleError> {
let source = source.to_str().ok_or_else(|| {
BundleError::Io(std::io::Error::new(
std::io::ErrorKind::InvalidInput,
std::format!("{source:?} is not valid UTF-8, so SQLite can't attach it"),
))
})?;
let copied = database.with_connection(|conn| {
conn.execute("ATTACH DATABASE ?1 AS source", rusqlite::params![source])?;
let result = copy_attached(conn, namespaces);
if let Err(err) = conn.execute("DETACH DATABASE source", []) {
log::warn!("Bundle export: detaching {source:?} failed: {err}");
}
result
})?;
Ok(copied)
}
fn copy_attached(
conn: &rusqlite::Connection,
namespaces: Option<&[String]>,
) -> Result<usize, rusqlite::Error> {
const COPY: &str = "INSERT OR IGNORE INTO main.entries (namespace, key, value, origin) \
SELECT namespace, key, value, 1 FROM source.entries";
let Some(namespaces) = namespaces else {
return conn.execute(COPY, []);
};
let filtered = std::format!("{COPY} WHERE {NAMESPACE_PREFIX}");
let mut copied = 0;
for namespace in namespaces {
copied += conn.execute(&filtered, rusqlite::params![namespace])?;
}
Ok(copied)
}
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 => Database::open(out, true)
.map_err(BundleError::from)
.and_then(|database| BundleManifest::read(&database))
.map(|manifest| std::format!("'{}'", manifest.name))
.ok(),
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 {
let stale = sidecar(out, suffix);
if stale.exists() {
std::fs::remove_file(stale)?;
}
}
std::fs::rename(staged, out)?;
for suffix in SIDECARS {
let staged = sidecar(staged, suffix);
if staged.exists() {
std::fs::rename(staged, sidecar(out, suffix))?;
}
}
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
}