#[cfg(feature = "crypto")]
pub(crate) mod crypto;
mod maintenance;
mod scheduler;
use std::sync::Arc;
use crate::codec::keys::{KeyKind, LogicalKey};
use crate::codec::manifest::{Compression, DeviceInfo, Manifest};
use crate::codec::segment;
use crate::database::{Inner, Origin, SyncReport};
use crate::error::{Error, ErrorKind, Result};
use crate::model::{DbId, DeviceId};
use crate::remote::{PutOutcome, RemoteEntry};
pub use maintenance::CompactReport;
pub(crate) use maintenance::{compact, create_snapshot, prune_device};
pub use scheduler::{AutoSync, SyncHandle, SyncStatus};
async fn put_once(inner: &Inner, key: &LogicalKey, bytes: &[u8]) -> Result<()> {
let key = key.to_string();
match inner.remote.put_new(&key, bytes, None).await? {
PutOutcome::Created | PutOutcome::AlreadyExists { same_content: true } => Ok(()),
PutOutcome::AlreadyExists {
same_content: false,
} => Err(Error::new(
ErrorKind::RemoteInconsistent,
format!("{key} already exists with different content"),
)),
}
}
async fn upload_device_info(inner: &Arc<Inner>, db_id: DbId) -> Result<()> {
let created_hlc = inner.store(|s| s.tick()).await?;
let device = inner.device();
let info = DeviceInfo {
device_id: device,
name: inner.device_name.clone(),
platform: Some(format!(
"{}-{}",
std::env::consts::OS,
std::env::consts::ARCH
)),
bothy_version: Some(env!("CARGO_PKG_VERSION").to_owned()),
created_hlc,
};
let key = LogicalKey::device(db_id, device);
#[cfg(feature = "crypto")]
let body = crypto::seal_json(inner.key().as_ref(), &key.to_string(), info.to_json())?;
#[cfg(not(feature = "crypto"))]
let body = info.to_json();
put_once(inner, &key, &body).await
}
pub(crate) async fn sync_key(inner: &Arc<Inner>) -> Result<Option<crate::codec::DataKey>> {
#[cfg(feature = "crypto")]
return crypto::required_key(inner).await;
#[cfg(not(feature = "crypto"))]
{
let _ = inner;
Ok(None)
}
}
fn already_attached(db: DbId) -> Error {
Error::invalid_input(format!("this device is already attached to database {db}"))
}
async fn attach(inner: &Arc<Inner>, manifest: &Manifest) -> Result<()> {
let compress = manifest.compression == Compression::Zstd;
let (grace, db_id) = (manifest.grace_period_days, manifest.db_id);
inner
.store(move |s| {
s.set_compress(compress)?;
s.set_grace_period_days(grace)?;
s.set_db_id(db_id)
})
.await
}
pub(crate) async fn init(inner: &Arc<Inner>) -> Result<DbId> {
let _sync = inner.sync_lock.lock().await;
if let Some(db) = inner.store(|s| s.db_id()).await? {
return Err(already_attached(db));
}
let db_id = inner.store(|s| s.new_db_id()).await?;
let created_hlc = inner.store(|s| s.tick()).await?;
let mut manifest = Manifest::new(db_id, inner.device(), created_hlc, &inner.app_id)?;
manifest.grace_period_days = inner.policy.grace_period_days;
#[cfg(feature = "crypto")]
{
manifest.encryption = crypto::create(inner, db_id).await?;
}
put_once(inner, &LogicalKey::manifest(db_id), &manifest.to_json()).await?;
upload_device_info(inner, db_id).await?;
attach(inner, &manifest).await?;
Ok(db_id)
}
async fn fetch_manifest(inner: &Inner, db_id: DbId) -> Result<Manifest> {
let bytes = inner
.remote
.get(&LogicalKey::manifest(db_id).to_string())
.await?
.ok_or_else(|| {
Error::new(
ErrorKind::RemoteInconsistent,
format!("database {db_id} has no manifest"),
)
})?;
let manifest = Manifest::from_json(&bytes, Some(db_id))?;
if cfg!(not(feature = "crypto")) && manifest.encryption.is_some() {
return Err(Error::format_too_new(
"database is encrypted; this build has no encryption support",
));
}
Ok(manifest)
}
pub(crate) async fn join(inner: &Arc<Inner>, db_id: DbId) -> Result<SyncReport> {
let mut report = SyncReport::default();
{
let _sync = inner.sync_lock.lock().await;
match inner.store(|s| s.db_id()).await? {
Some(db) if db == db_id => {}
Some(db) => return Err(already_attached(db)),
None => {
let manifest = fetch_manifest(inner, db_id).await?;
if manifest.app_id != inner.app_id {
return Err(Error::invalid_input(format!(
"database {db_id} belongs to app {}, not {}",
manifest.app_id, inner.app_id
)));
}
#[cfg(feature = "crypto")]
if let Some(info) = &manifest.encryption {
crypto::join(inner, db_id, info).await?;
}
upload_device_info(inner, db_id).await?;
attach(inner, &manifest).await?;
maintenance::join_from_snapshot(inner, db_id, &mut report).await?;
}
}
}
let rest = sync(inner).await?;
report.absorb(rest);
Ok(report)
}
pub(crate) async fn init_or_join(inner: &Arc<Inner>) -> Result<DbId> {
if let Some(db) = inner.store(|s| s.db_id()).await? {
return Ok(db);
}
let mut candidates = Vec::new();
for entry in inner.remote.list("").await? {
let Some(key) = LogicalKey::parse(&entry.key) else {
continue;
};
if key.kind != KeyKind::Manifest {
continue;
}
match fetch_manifest(inner, key.db_id).await {
Ok(m) if m.app_id == inner.app_id => candidates.push((m.created_hlc, m.db_id)),
Ok(_) => {}
Err(e) if e.kind() == ErrorKind::Corrupt => {}
Err(e) => return Err(e),
}
}
match candidates.into_iter().min() {
Some((_, db_id)) => {
join(inner, db_id).await?;
Ok(db_id)
}
None => init(inner).await,
}
}
async fn push(inner: &Arc<Inner>, db_id: DbId, report: &mut SyncReport) -> Result<()> {
let mut limits = inner.pack;
limits.compress = inner.store(|s| s.compress()).await? && limits.compress;
let data_key = sync_key(inner).await?;
let k = data_key.clone();
let packed = inner
.store(move |s| s.pack_outbox(db_id, limits, k.as_ref()))
.await?;
report.pushed_ops += packed.ops;
let mut forks = 0;
'retry: loop {
let device = inner.device();
for p in inner.store(|s| s.pending_uploads()).await? {
let key = LogicalKey::segment(db_id, device, p.seq).to_string();
let reservation = match p.reservation {
Some(r) => Some(r),
None => match inner.remote.reserve(&key).await? {
Some(r) => {
let (seq, token) = (p.seq, r.clone());
inner.store(move |s| s.set_reservation(seq, &token)).await?;
Some(r)
}
None => None,
},
};
let (seq, size) = (p.seq, p.bytes.len());
match inner
.remote
.put_new(&key, &p.bytes, reservation.as_deref())
.await
{
Ok(PutOutcome::Created | PutOutcome::AlreadyExists { same_content: true }) => {
inner
.store(move |s| {
s.finish_upload(seq)?;
s.count_segment(size)
})
.await?;
report.segments_up += 1;
}
Ok(PutOutcome::AlreadyExists {
same_content: false,
}) => {
forks += 1;
if forks > 3 {
return Err(Error::new(
ErrorKind::RemoteInconsistent,
format!("{key} keeps colliding after taking new device ids"),
));
}
fork(inner, db_id, report).await?;
let k = data_key.clone();
let packed = inner
.store(move |s| s.pack_outbox(db_id, limits, k.as_ref()))
.await?;
report.pushed_ops += packed.ops;
continue 'retry;
}
Err(e) => {
inner.store(move |s| s.record_attempt(seq)).await?;
return Err(e);
}
}
}
return Ok(());
}
}
async fn fork(inner: &Arc<Inner>, db_id: DbId, report: &mut SyncReport) -> Result<()> {
let new = inner.store(|s| s.fork_device()).await?;
inner.set_device(new);
report.forked_to = Some(new);
upload_device_info(inner, db_id).await
}
fn remote_segments(
db_id: DbId,
me: DeviceId,
entries: &[RemoteEntry],
) -> Vec<(DeviceId, u64, String)> {
let mut out: Vec<_> = entries
.iter()
.filter_map(|e| {
let key = LogicalKey::parse(&e.key)?;
match key.kind {
KeyKind::Segment(dev, seq) if key.db_id == db_id && dev != me => {
Some((dev, seq, e.key.clone()))
}
_ => None,
}
})
.collect();
out.sort();
out
}
struct Fetched {
entries: Vec<RemoteEntry>,
next_cursor: Option<String>,
}
const FULL_LIST_EVERY_MS: u64 = 24 * 60 * 60 * 1000;
async fn fetch(inner: &Arc<Inner>, db_id: DbId) -> Result<Fetched> {
let cursor = inner.store(|s| s.change_cursor()).await?;
let prefix = format!("{db_id}/");
Ok(match inner.remote.changes_since(cursor.as_deref()).await? {
Some(mut batch) => {
let (now, last) = inner
.store(|s| Ok((s.wall_ms(), s.last_full_list_ms()?)))
.await?;
if cursor.is_some() && now.saturating_sub(last) >= FULL_LIST_EVERY_MS {
batch.changed.extend(inner.remote.list(&prefix).await?);
inner.store(move |s| s.set_last_full_list_ms(now)).await?;
}
Fetched {
entries: batch.changed,
next_cursor: Some(batch.cursor),
}
}
None => Fetched {
entries: inner.remote.list(&prefix).await?,
next_cursor: None,
},
})
}
async fn check_fork(
inner: &Arc<Inner>,
db_id: DbId,
entries: &[RemoteEntry],
report: &mut SyncReport,
) -> Result<()> {
let me = inner.device();
let next_seq = inner.store(|s| s.next_seq()).await?;
let mut foreign = false;
for e in entries {
let Some(k) = LogicalKey::parse(&e.key) else {
continue;
};
if k.db_id != db_id {
continue;
}
match k.kind {
KeyKind::Segment(d, seq) if d == me && seq >= next_seq => foreign = true,
KeyKind::Snapshot(d, hlc) if d == me => {
if inner.store(move |s| s.knows_own_snapshot(hlc)).await? {
inner.store(move |s| s.confirm_own_snapshot(hlc)).await?;
} else {
foreign = true;
}
}
_ => {}
}
}
if foreign {
fork(inner, db_id, report).await?;
}
Ok(())
}
async fn pull(
inner: &Arc<Inner>,
db_id: DbId,
fetched: Fetched,
report: &mut SyncReport,
) -> Result<()> {
let Fetched {
entries,
next_cursor,
} = fetched;
#[cfg(feature = "crypto")]
if crypto::on_entries(inner, db_id, &entries).await? {
report.password_changed = true;
}
let data_key = sync_key(inner).await?;
for (device, seq, key) in remote_segments(db_id, inner.device(), &entries) {
if inner.store(move |s| s.is_applied(device, seq)).await? {
continue;
}
let Some(bytes) = inner.remote.get(&key).await? else {
continue;
};
let expected = segment::Expected {
db_id,
device_id: device,
seq,
};
let seg = match segment::decode_with(&bytes, &expected, data_key.as_ref()) {
Ok(seg) => seg,
Err(e) if e.kind() == ErrorKind::Corrupt => {
report.skipped.push(format!("{key}: {}", e.message()));
continue;
}
Err(e) => return Err(e),
};
let (n_ops, size) = (seg.ops.len(), bytes.len());
let changed = inner
.store(move |s| {
let changed = s.apply_remote(device, seq, &seg.ops)?;
if changed.is_some() {
s.count_segment(size)?;
}
Ok(changed)
})
.await?;
if let Some(changed) = changed {
report.segments_down += 1;
report.pulled_ops += n_ops;
report.changed_docs += changed.len();
for (c, d) in changed {
inner.emit(c, d, Origin::Remote);
}
}
}
if let Some(c) = next_cursor {
inner.store(move |s| s.set_change_cursor(&c)).await?;
}
maintenance::fill_gaps(inner, db_id, report).await
}
pub(crate) async fn sync(inner: &Arc<Inner>) -> Result<SyncReport> {
let _sync = inner.sync_lock.lock().await;
let db_id = inner.store(|s| s.db_id()).await?.ok_or_else(|| {
Error::invalid_input("database is not initialised; call init or join first")
})?;
sync_key(inner).await?;
let mut report = SyncReport::default();
let fetched = fetch(inner, db_id).await?;
check_fork(inner, db_id, &fetched.entries, &mut report).await?;
match push(inner, db_id, &mut report).await {
Ok(()) => {}
Err(e) if e.kind() == ErrorKind::QuotaExceeded => {
pull(inner, db_id, fetched, &mut report).await?;
return Err(e);
}
Err(e) => return Err(e),
}
pull(inner, db_id, fetched, &mut report).await?;
maintenance::auto(inner, db_id, &mut report).await?;
inner.store(|s| s.set_last_sync_ms(s.wall_ms())).await?;
Ok(report)
}
pub(crate) use scheduler::start as start_auto_sync;