mod merge;
pub mod records;
pub mod store;
use std::collections::{BTreeMap, BTreeSet};
use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
use sha2::{Digest, Sha256};
use vta_sdk::protocols::app_state::{AppStateListResponse, AppStateWrite, WriteOutcome};
pub use records::{RecordKind, SyncParts, SyncView};
pub use store::{AppStateStore, DeleteOutcome, SyncError};
pub const NAMESPACE: &str = "openvtc";
pub const RECORD_SCHEMA_VERSION: u64 = 1;
const MAX_PAGES: usize = 500;
const LOAD_ROUNDS: usize = 3;
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct BaseRecord {
pub version: u64,
pub value: Value,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct RemoteRecord {
pub key: String,
pub version: u64,
#[serde(default)]
pub deleted: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub value: Option<Value>,
}
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
pub struct SyncState {
#[serde(default, skip_serializing_if = "String::is_empty")]
pub context_id: String,
#[serde(default)]
pub watermark: u64,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub base: BTreeMap<String, BaseRecord>,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub deferred: BTreeMap<String, RemoteRecord>,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub rejected: BTreeMap<String, String>,
#[serde(skip)]
pub load_report: Option<SyncReport>,
}
impl SyncState {
#[must_use]
pub fn is_empty(&self) -> bool {
self.context_id.is_empty()
&& self.watermark == 0
&& self.base.is_empty()
&& self.deferred.is_empty()
&& self.rejected.is_empty()
}
pub fn bind_context(&mut self, context_id: &str) {
if self.context_id != context_id {
*self = SyncState {
context_id: context_id.to_string(),
load_report: self.load_report.take(),
..SyncState::default()
};
}
}
}
#[derive(Clone, Debug, Default)]
pub struct SyncReport {
pub pulled: usize,
pub pushed: usize,
pub removed: usize,
pub conflicts: Vec<String>,
pub deferred: usize,
pub rejected: Vec<String>,
pub unreadable: Vec<String>,
pub changed: BTreeSet<RecordKind>,
pub error: Option<SyncError>,
pub bookkeeping: bool,
}
impl SyncReport {
#[must_use]
pub fn touched(&self) -> bool {
self.pulled > 0
|| self.pushed > 0
|| self.removed > 0
|| self.deferred > 0
|| self.bookkeeping
}
#[must_use]
pub fn log_lines(&self) -> Vec<String> {
let mut lines = Vec::new();
if self.pulled > 0 {
lines.push(format!(
"Applied {} change(s) made on another device",
self.pulled
));
}
for c in &self.conflicts {
lines.push(format!(
"Changed on this device and another at once: {c} — kept the other device's value"
));
}
if self.deferred > 0 {
lines.push(format!(
"{} change(s) from another device will apply when OpenVTC next starts",
self.deferred
));
}
for r in &self.rejected {
lines.push(format!("Not synced to the VTA: {r}"));
}
for u in &self.unreadable {
lines.push(format!(
"Could not read a synced record ({u}); kept for a newer build"
));
}
if let Some(e) = &self.error {
lines.push(e.explain());
}
lines
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum ApplyMode {
Load,
Live,
}
#[derive(Clone, Debug, Default)]
pub struct Outgoing {
pub puts: Vec<(String, Value, u64)>,
pub deletes: Vec<(String, u64)>,
}
impl Outgoing {
#[must_use]
pub fn is_empty(&self) -> bool {
self.puts.is_empty() && self.deletes.is_empty()
}
}
#[derive(Debug, Default)]
pub struct Exchange {
pulled: Vec<RemoteRecord>,
watermark: Option<u64>,
snapshot_keys: Option<BTreeSet<String>>,
puts: Vec<(String, Value, vta_sdk::protocols::app_state::WriteResult)>,
deletes: Vec<(String, DeleteOutcome)>,
error: Option<SyncError>,
}
fn envelope(data: &Value) -> Value {
json!({ "schemaVersion": RECORD_SCHEMA_VERSION, "data": data })
}
enum Opened {
Data(Value),
Newer(u64),
Malformed,
}
fn open(envelope: Option<&Value>) -> Opened {
let Some(env) = envelope else {
return Opened::Malformed;
};
let version = env
.get("schemaVersion")
.and_then(Value::as_u64)
.unwrap_or(0);
if version > RECORD_SCHEMA_VERSION {
return Opened::Newer(version);
}
match env.get("data") {
Some(d) if version >= 1 => Opened::Data(d.clone()),
_ => Opened::Malformed,
}
}
fn digest(v: &Value) -> String {
hex::encode(Sha256::digest(v.to_string().as_bytes()))
}
#[must_use]
pub fn outgoing(view: &SyncView<'_>, state: &SyncState) -> Outgoing {
let local = records::project(view);
let mut out = Outgoing::default();
for (key, value) in &local {
if state.deferred.contains_key(key) {
continue;
}
let base = state.base.get(key);
if base.is_some_and(|b| &b.value == value) {
continue;
}
if state.rejected.get(key) == Some(&digest(value)) {
continue;
}
out.puts
.push((key.clone(), value.clone(), base.map_or(0, |b| b.version)));
}
for (key, base) in &state.base {
if !local.contains_key(key) && !state.deferred.contains_key(key) {
out.deletes.push((key.clone(), base.version));
}
}
out
}
fn remote(r: vta_sdk::protocols::app_state::AppStateRecord) -> RemoteRecord {
RemoteRecord {
key: r.key,
version: r.version,
deleted: r.deleted,
value: r.value,
}
}
async fn pull<S: AppStateStore>(store: &S, context_id: &str, since: u64, ex: &mut Exchange) {
let mut snapshot = false;
let mut cursor: Option<String> = None;
let mut first_watermark: Option<u64> = None;
let mut keys = BTreeSet::new();
for _ in 0..MAX_PAGES {
let page: Result<AppStateListResponse, SyncError> = if snapshot {
store.snapshot(context_id, cursor.as_deref()).await
} else {
store
.changes_since(context_id, since, cursor.as_deref())
.await
};
let page = match page {
Ok(p) => p,
Err(SyncError::WatermarkTooOld) if !snapshot => {
snapshot = true;
cursor = None;
first_watermark = None;
ex.pulled.clear();
continue;
}
Err(e) => {
ex.error = Some(e);
return;
}
};
if first_watermark.is_none() {
first_watermark = Some(page.high_watermark.unwrap_or(since));
}
for r in page.records {
if snapshot {
keys.insert(r.key.clone());
}
ex.pulled.push(remote(r));
}
match (page.truncated, page.cursor) {
(true, Some(c)) => cursor = Some(c),
_ => {
ex.watermark = first_watermark;
if snapshot {
ex.snapshot_keys = Some(keys);
}
return;
}
}
}
ex.error = Some(SyncError::Contract(format!(
"the change feed did not end within {MAX_PAGES} pages"
)));
}
pub async fn exchange<S: AppStateStore>(
store: &S,
context_id: &str,
since: u64,
out: Outgoing,
) -> Exchange {
let mut ex = Exchange::default();
pull(store, context_id, since, &mut ex).await;
if ex.error.is_some() {
return ex;
}
for batch in out.puts.chunks(store::PUT_BATCH) {
let writes = batch
.iter()
.map(|(key, value, expected)| {
let mut w = AppStateWrite::new(key.clone());
w.value = Some(envelope(value));
w.expected_version = Some(*expected);
w
})
.collect();
match store.put_many(context_id, writes).await {
Ok(results) => {
for result in results {
if let Some((_, value, _)) = batch.iter().find(|(k, _, _)| *k == result.key) {
ex.puts.push((result.key.clone(), value.clone(), result));
}
}
}
Err(e) => {
ex.error = Some(e);
return ex;
}
}
}
for (key, expected) in out.deletes {
match store.delete(context_id, &key, expected).await {
Ok(outcome) => ex.deletes.push((key, outcome)),
Err(e) => {
ex.error = Some(e);
return ex;
}
}
}
ex
}
pub fn apply_remote(
parts: &mut SyncParts<'_>,
state: &mut SyncState,
mut incoming: Vec<RemoteRecord>,
mode: ApplyMode,
report: &mut SyncReport,
) {
incoming.sort_by_key(|r| r.version);
let mut latest: BTreeMap<String, RemoteRecord> = BTreeMap::new();
for r in incoming {
latest.insert(r.key.clone(), r);
}
let local = records::project(&parts.view());
for (key, rec) in latest {
if state
.base
.get(&key)
.is_some_and(|b| rec.version <= b.version)
{
continue;
}
let Some(kind) = RecordKind::of_key(&key) else {
continue;
};
let remote_data = if rec.deleted {
None
} else {
match open(rec.value.as_ref()) {
Opened::Data(d) => Some(d),
Opened::Newer(v) => {
report
.unreadable
.push(format!("a {} written in format {v}", kind.noun()));
state.deferred.insert(key.clone(), rec);
continue;
}
Opened::Malformed => {
report
.unreadable
.push(format!("a malformed {}", kind.noun()));
state.deferred.insert(key.clone(), rec);
continue;
}
}
};
if mode == ApplyMode::Live && !applies_live(kind, remote_data.as_ref(), parts) {
if state.deferred.insert(key.clone(), rec).is_none() {
report.deferred += 1;
}
continue;
}
let mut conflicts = Vec::new();
let base = state.base.get(&key).map(|b| &b.value);
let here = local.get(&key);
let merged = merge::merge3(base, here, remote_data.as_ref(), "", &mut conflicts);
if merged.as_ref() != here {
if let Err(e) = records::apply(parts, &key, merged.as_ref()) {
report.unreadable.push(format!("a {}: {e}", kind.noun()));
state.deferred.insert(key.clone(), rec);
continue;
}
report.changed.insert(kind);
report.pulled += 1;
}
if !conflicts.is_empty() {
report
.conflicts
.push(format!("a {} ({})", kind.noun(), conflicts.join(", ")));
}
state.deferred.remove(&key);
match remote_data {
Some(value) => {
state.base.insert(
key,
BaseRecord {
version: rec.version,
value,
},
);
}
None => {
state.base.remove(&key);
}
}
}
}
fn applies_live(kind: RecordKind, data: Option<&Value>, parts: &SyncParts<'_>) -> bool {
if !kind.applies_live() {
return false;
}
if kind == RecordKind::Membership
&& let Some(persona) = data.and_then(|d| d.get("persona_ref"))
{
return parts
.account
.personas
.keys()
.any(|id| serde_json::to_value(id).ok().as_ref() == Some(persona));
}
true
}
pub fn absorb(
parts: &mut SyncParts<'_>,
state: &mut SyncState,
ex: Exchange,
mode: ApplyMode,
report: &mut SyncReport,
) -> bool {
let mut pulled = ex.pulled;
if let (Some(keys), Some(watermark)) = (&ex.snapshot_keys, ex.watermark) {
for key in state.base.keys() {
if !keys.contains(key) {
pulled.push(RemoteRecord {
key: key.clone(),
version: watermark,
deleted: true,
value: None,
});
}
}
}
if !pulled.is_empty() || !ex.puts.is_empty() || !ex.deletes.is_empty() {
report.bookkeeping = true;
}
apply_remote(parts, state, pulled, mode, report);
if let Some(w) = ex.watermark
&& w != state.watermark
{
state.watermark = w;
report.bookkeeping = true;
}
let mut lost_race = false;
let mut raced = Vec::new();
for (key, value, result) in ex.puts {
match result.outcome {
WriteOutcome::Written => {
state.rejected.remove(&key);
if let Some(version) = result.version {
state.base.insert(key, BaseRecord { version, value });
report.pushed += 1;
}
}
WriteOutcome::Conflict => {
lost_race = true;
raced.push(RemoteRecord {
key,
version: result.current_version.unwrap_or(0),
deleted: result.current_deleted.unwrap_or(false),
value: result.current_value,
});
}
WriteOutcome::TooLarge => {
let kind = RecordKind::of_key(&key).map_or("record", RecordKind::noun);
report.rejected.push(format!(
"a {kind} is {} bytes, over this VTA's {}-byte limit",
result.actual_bytes.unwrap_or(0),
result.limit_bytes.unwrap_or(0)
));
state.rejected.insert(key, digest(&value));
}
WriteOutcome::NotFound | WriteOutcome::Skipped => {}
}
}
apply_remote(parts, state, raced, mode, report);
for (key, outcome) in ex.deletes {
match outcome {
DeleteOutcome::Deleted => {
state.base.remove(&key);
report.removed += 1;
}
DeleteOutcome::Conflict => lost_race = true,
}
}
if let Some(e) = ex.error {
report.error = Some(e);
}
lost_race
}
pub async fn sync_at_load<S: AppStateStore>(
store: &S,
parts: &mut SyncParts<'_>,
state: &mut SyncState,
context_id: &str,
) -> SyncReport {
let mut report = SyncReport::default();
if context_id.is_empty() {
return report;
}
state.bind_context(context_id);
let deferred: Vec<RemoteRecord> = std::mem::take(&mut state.deferred).into_values().collect();
if !deferred.is_empty() {
report.bookkeeping = true;
}
apply_remote(parts, state, deferred, ApplyMode::Load, &mut report);
for _ in 0..LOAD_ROUNDS {
let out = outgoing(&parts.view(), state);
let ex = exchange(store, context_id, state.watermark, out).await;
let lost_race = absorb(parts, state, ex, ApplyMode::Load, &mut report);
if report.error.is_some() || !lost_race {
break;
}
}
report
}
#[cfg(test)]
mod tests;