840 lines
27 KiB
Rust
840 lines
27 KiB
Rust
use anyhow::Result;
|
|
use anyhow::anyhow;
|
|
use cultcache_rs::CultCache;
|
|
use cultcache_rs::CultCacheEnvelope;
|
|
use cultcache_rs::DatabaseEntry;
|
|
use cultcache_rs::SchemaConflictError;
|
|
use serde::Serialize;
|
|
use serde::de::DeserializeOwned;
|
|
use serde_json::Value;
|
|
use std::collections::BTreeMap;
|
|
use std::collections::BTreeSet;
|
|
use std::sync::Arc;
|
|
use std::sync::Mutex;
|
|
use std::sync::atomic::AtomicBool;
|
|
use std::sync::atomic::Ordering;
|
|
use std::thread;
|
|
use std::thread::JoinHandle;
|
|
use std::time::Duration;
|
|
|
|
use crate::CultNetDocumentMutationContract;
|
|
use crate::CultNetMessage;
|
|
use crate::CultNetRawDocumentRecord;
|
|
use crate::CultNetRawPayloadEncoding;
|
|
|
|
#[derive(Clone, Debug, Default, PartialEq, Eq)]
|
|
pub struct CultNetDocumentPutOptions {
|
|
pub stored_at: Option<String>,
|
|
pub source_runtime_id: Option<String>,
|
|
pub source_agent_id: Option<String>,
|
|
pub source_role: Option<String>,
|
|
pub tags: Option<Vec<String>>,
|
|
}
|
|
|
|
#[derive(Clone, Debug, PartialEq, Eq)]
|
|
pub struct CultNetDocumentBinding {
|
|
pub schema_id: String,
|
|
pub document_type: String,
|
|
pub mutation_contract: Option<CultNetDocumentMutationContract>,
|
|
pub payload_schema_version: Option<String>,
|
|
}
|
|
|
|
impl CultNetDocumentBinding {
|
|
pub fn for_entry<T: DatabaseEntry>(payload_schema_version: impl Into<Option<String>>) -> Self {
|
|
Self {
|
|
document_type: T::TYPE.to_string(),
|
|
schema_id: T::TYPE.to_string(),
|
|
mutation_contract: None,
|
|
payload_schema_version: payload_schema_version.into(),
|
|
}
|
|
}
|
|
|
|
pub fn for_entry_with_schema_id<T: DatabaseEntry>(
|
|
schema_id: impl Into<String>,
|
|
payload_schema_version: impl Into<Option<String>>,
|
|
) -> Self {
|
|
Self {
|
|
document_type: T::TYPE.to_string(),
|
|
schema_id: schema_id.into(),
|
|
mutation_contract: None,
|
|
payload_schema_version: payload_schema_version.into(),
|
|
}
|
|
}
|
|
|
|
pub fn with_mutation_contract(mut self, contract: CultNetDocumentMutationContract) -> Self {
|
|
self.mutation_contract = Some(contract);
|
|
self
|
|
}
|
|
}
|
|
|
|
#[derive(Clone, Debug, Default)]
|
|
pub struct CultNetDocumentRegistry {
|
|
bindings_by_type: BTreeMap<String, CultNetDocumentBinding>,
|
|
bindings_by_schema_id: BTreeMap<String, CultNetDocumentBinding>,
|
|
}
|
|
|
|
#[derive(Clone, Debug, PartialEq, Eq)]
|
|
pub struct CultNetReactiveDocumentOptions {
|
|
pub flush_delay: Duration,
|
|
pub detect_local_changes: bool,
|
|
pub replace_dirty_current_on_canonical_snapshot: bool,
|
|
}
|
|
|
|
impl Default for CultNetReactiveDocumentOptions {
|
|
fn default() -> Self {
|
|
Self {
|
|
flush_delay: Duration::from_millis(16),
|
|
detect_local_changes: true,
|
|
replace_dirty_current_on_canonical_snapshot: false,
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Clone, Debug, PartialEq)]
|
|
pub struct CultNetReactiveDocumentReconciliation<T> {
|
|
pub canonical: T,
|
|
pub predicted: T,
|
|
pub delta: BTreeMap<String, Value>,
|
|
pub version: u64,
|
|
pub received_at: String,
|
|
}
|
|
|
|
struct CultNetReactiveDocumentState<T> {
|
|
dirty: bool,
|
|
flushing: bool,
|
|
flush_queued: bool,
|
|
last_clean_payload: Vec<u8>,
|
|
reconciliation: Option<CultNetReactiveDocumentReconciliation<T>>,
|
|
reconciliation_version: u64,
|
|
last_error: Option<String>,
|
|
}
|
|
|
|
pub struct CultNetReactiveDocument<T>
|
|
where
|
|
T: DatabaseEntry + Serialize + DeserializeOwned,
|
|
{
|
|
registry: CultNetDocumentRegistry,
|
|
cache: Arc<Mutex<CultCache>>,
|
|
record_key: String,
|
|
current: Arc<Mutex<T>>,
|
|
state: Arc<Mutex<CultNetReactiveDocumentState<T>>>,
|
|
options: CultNetReactiveDocumentOptions,
|
|
disposed: Arc<AtomicBool>,
|
|
detect_worker: Option<JoinHandle<()>>,
|
|
}
|
|
|
|
impl CultNetDocumentRegistry {
|
|
pub fn new() -> Self {
|
|
Self::default()
|
|
}
|
|
|
|
/// Binds `binding`. One binding owns a document type and a schema id: a binding claiming a type or a schema id
|
|
/// another binding already holds is refused with [`SchemaConflictError`], naming the new binding's schema id and
|
|
/// both document types, and nothing is bound. Binding a type to the schema id it already holds replaces that binding.
|
|
pub fn register(&mut self, binding: CultNetDocumentBinding) -> Result<&mut Self> {
|
|
let holder = self
|
|
.bindings_by_type
|
|
.get(&binding.document_type)
|
|
.filter(|held| held.schema_id != binding.schema_id)
|
|
.or_else(|| {
|
|
self.bindings_by_schema_id
|
|
.get(&binding.schema_id)
|
|
.filter(|held| held.document_type != binding.document_type)
|
|
});
|
|
if let Some(holder) = holder {
|
|
return Err(anyhow::Error::new(SchemaConflictError {
|
|
schema_id: binding.schema_id.clone(),
|
|
schema_names: vec![holder.document_type.clone(), binding.document_type.clone()],
|
|
record_key: String::new(),
|
|
unread_slots: None,
|
|
}));
|
|
}
|
|
self.bindings_by_schema_id
|
|
.insert(binding.schema_id.clone(), binding.clone());
|
|
self.bindings_by_type
|
|
.insert(binding.document_type.clone(), binding);
|
|
Ok(self)
|
|
}
|
|
|
|
pub fn binding(&self, document_type: &str) -> Option<&CultNetDocumentBinding> {
|
|
self.bindings_by_type.get(document_type)
|
|
}
|
|
|
|
pub fn binding_by_schema_id(&self, schema_id: &str) -> Option<&CultNetDocumentBinding> {
|
|
self.bindings_by_schema_id.get(schema_id)
|
|
}
|
|
|
|
pub fn mutation_contracts(&self) -> Vec<CultNetDocumentMutationContract> {
|
|
self.bindings_by_type
|
|
.values()
|
|
.filter_map(|binding| binding.mutation_contract.clone())
|
|
.collect()
|
|
}
|
|
|
|
pub fn create_document_delete_message(
|
|
&self,
|
|
message_id: impl Into<String>,
|
|
schema_id: impl Into<String>,
|
|
record_key: impl Into<String>,
|
|
) -> CultNetMessage {
|
|
CultNetMessage::DocumentDelete {
|
|
message_id: message_id.into(),
|
|
schema_id: schema_id.into(),
|
|
record_key: record_key.into(),
|
|
}
|
|
}
|
|
|
|
pub fn create_raw_document_put_message_from_envelope(
|
|
&self,
|
|
message_id: impl Into<String>,
|
|
envelope: &CultCacheEnvelope,
|
|
) -> Result<CultNetMessage> {
|
|
Ok(CultNetMessage::DocumentPutRaw {
|
|
message_id: message_id.into(),
|
|
document: self.raw_document_record_from_envelope(envelope)?,
|
|
})
|
|
}
|
|
|
|
pub fn create_raw_document_put_message<T>(
|
|
&self,
|
|
message_id: impl Into<String>,
|
|
record_key: impl Into<String>,
|
|
value: &T,
|
|
options: CultNetDocumentPutOptions,
|
|
) -> Result<CultNetMessage>
|
|
where
|
|
T: DatabaseEntry + Serialize,
|
|
{
|
|
let binding = self.require_binding(T::TYPE)?;
|
|
Ok(CultNetMessage::DocumentPutRaw {
|
|
message_id: message_id.into(),
|
|
document: CultNetRawDocumentRecord {
|
|
schema_id: binding.schema_id.clone(),
|
|
record_key: record_key.into(),
|
|
stored_at: options.stored_at.unwrap_or_else(now_utc_second),
|
|
payload_encoding: CultNetRawPayloadEncoding::Messagepack,
|
|
payload: rmp_serde::to_vec(value)?,
|
|
source_runtime_id: options.source_runtime_id,
|
|
source_agent_id: options.source_agent_id,
|
|
source_role: options.source_role,
|
|
tags: options.tags,
|
|
},
|
|
})
|
|
}
|
|
|
|
pub fn create_raw_snapshot_response(
|
|
&self,
|
|
cache: &CultCache,
|
|
message_id: impl Into<String>,
|
|
schema_ids: Option<&[String]>,
|
|
record_keys: Option<&[String]>,
|
|
) -> Result<CultNetMessage> {
|
|
let requested_schema_ids = schema_ids.map(|items| items.iter().collect::<BTreeSet<_>>());
|
|
let requested_record_keys = record_keys.map(|items| items.iter().collect::<BTreeSet<_>>());
|
|
let mut documents = Vec::new();
|
|
for envelope in cache.snapshot() {
|
|
let binding = self.require_binding(&envelope.r#type)?;
|
|
if requested_schema_ids
|
|
.as_ref()
|
|
.is_some_and(|ids| !ids.contains(&binding.schema_id))
|
|
{
|
|
continue;
|
|
}
|
|
if requested_record_keys
|
|
.as_ref()
|
|
.is_some_and(|keys| !keys.contains(&envelope.key))
|
|
{
|
|
continue;
|
|
}
|
|
documents.push(self.raw_document_record_from_envelope(&envelope)?);
|
|
}
|
|
Ok(CultNetMessage::SnapshotResponseRaw {
|
|
message_id: message_id.into(),
|
|
documents,
|
|
})
|
|
}
|
|
|
|
pub fn apply_document_delete_message<T>(
|
|
&self,
|
|
cache: &mut CultCache,
|
|
message: &CultNetMessage,
|
|
) -> Result<bool>
|
|
where
|
|
T: DatabaseEntry,
|
|
{
|
|
let CultNetMessage::DocumentDelete {
|
|
schema_id,
|
|
record_key,
|
|
..
|
|
} = message
|
|
else {
|
|
return Err(anyhow!("expected cultnet.document_delete.v0"));
|
|
};
|
|
let binding = self.require_binding(T::TYPE)?;
|
|
if schema_id != &binding.schema_id {
|
|
return Err(anyhow!(
|
|
"schema id {:?} does not match registered Rust type {:?} schema {:?}",
|
|
schema_id,
|
|
T::TYPE,
|
|
binding.schema_id
|
|
));
|
|
}
|
|
cache.delete::<T>(record_key)
|
|
}
|
|
|
|
pub fn apply_raw_document_put_message<T>(
|
|
&self,
|
|
cache: &mut CultCache,
|
|
message: &CultNetMessage,
|
|
) -> Result<T>
|
|
where
|
|
T: DatabaseEntry + Serialize + DeserializeOwned,
|
|
{
|
|
let CultNetMessage::DocumentPutRaw { document, .. } = message else {
|
|
return Err(anyhow!("expected cultnet.document_put_raw.v0"));
|
|
};
|
|
let binding = self.require_binding(T::TYPE)?;
|
|
if document.schema_id != binding.schema_id {
|
|
return Err(anyhow!(
|
|
"schema id {:?} does not match registered Rust type {:?} schema {:?}",
|
|
document.schema_id,
|
|
T::TYPE,
|
|
binding.schema_id
|
|
));
|
|
}
|
|
cache.put_envelope::<T>(CultCacheEnvelope {
|
|
key: document.record_key.clone(),
|
|
r#type: binding.document_type.clone(),
|
|
payload: document.payload.clone(),
|
|
stored_at: document.stored_at.clone(),
|
|
schema_id: Some(binding.schema_id.clone()),
|
|
})
|
|
}
|
|
|
|
pub fn apply_raw_snapshot_response<T>(
|
|
&self,
|
|
cache: &mut CultCache,
|
|
response: &CultNetMessage,
|
|
) -> Result<Vec<T>>
|
|
where
|
|
T: DatabaseEntry + Serialize + DeserializeOwned,
|
|
{
|
|
let CultNetMessage::SnapshotResponseRaw { documents, .. } = response else {
|
|
return Err(anyhow!("expected cultnet.snapshot_response_raw.v0"));
|
|
};
|
|
let mut applied = Vec::new();
|
|
let binding = self.require_binding(T::TYPE)?;
|
|
for document in documents {
|
|
if document.schema_id != binding.schema_id {
|
|
continue;
|
|
}
|
|
applied.push(self.apply_raw_document_put_message::<T>(
|
|
cache,
|
|
&CultNetMessage::DocumentPutRaw {
|
|
message_id: "snapshot-apply-raw".to_string(),
|
|
document: document.clone(),
|
|
},
|
|
)?);
|
|
}
|
|
Ok(applied)
|
|
}
|
|
|
|
pub fn sync_raw_document_from_snapshot_response<T>(
|
|
&self,
|
|
cache: &mut CultCache,
|
|
response: &CultNetMessage,
|
|
record_key: &str,
|
|
) -> Result<T>
|
|
where
|
|
T: DatabaseEntry + Serialize + DeserializeOwned,
|
|
{
|
|
let CultNetMessage::SnapshotResponseRaw { documents, .. } = response else {
|
|
return Err(anyhow!("expected cultnet.snapshot_response_raw.v0"));
|
|
};
|
|
let binding = self.require_binding(T::TYPE)?;
|
|
let document = documents
|
|
.iter()
|
|
.find(|document| {
|
|
document.schema_id == binding.schema_id && document.record_key == record_key
|
|
})
|
|
.ok_or_else(|| {
|
|
anyhow!(
|
|
"No raw CultNet snapshot document for schema {:?} and record key {:?}",
|
|
binding.schema_id,
|
|
record_key
|
|
)
|
|
})?;
|
|
self.apply_raw_document_put_message::<T>(
|
|
cache,
|
|
&CultNetMessage::DocumentPutRaw {
|
|
message_id: "snapshot-sync-raw".to_string(),
|
|
document: document.clone(),
|
|
},
|
|
)
|
|
}
|
|
|
|
pub fn reactive_document<T>(
|
|
&self,
|
|
cache: Arc<Mutex<CultCache>>,
|
|
record_key: impl Into<String>,
|
|
options: CultNetReactiveDocumentOptions,
|
|
) -> Result<CultNetReactiveDocument<T>>
|
|
where
|
|
T: DatabaseEntry + Serialize + DeserializeOwned,
|
|
{
|
|
CultNetReactiveDocument::new(self.clone(), cache, record_key, options)
|
|
}
|
|
|
|
pub fn raw_document_record_from_envelope(
|
|
&self,
|
|
envelope: &CultCacheEnvelope,
|
|
) -> Result<CultNetRawDocumentRecord> {
|
|
let binding = self.require_binding(&envelope.r#type)?;
|
|
Ok(CultNetRawDocumentRecord {
|
|
schema_id: binding.schema_id.clone(),
|
|
record_key: envelope.key.clone(),
|
|
stored_at: envelope.stored_at.clone(),
|
|
payload_encoding: CultNetRawPayloadEncoding::Messagepack,
|
|
payload: envelope.payload.clone(),
|
|
source_runtime_id: None,
|
|
source_agent_id: None,
|
|
source_role: None,
|
|
tags: None,
|
|
})
|
|
}
|
|
|
|
fn require_binding(&self, document_type: &str) -> Result<&CultNetDocumentBinding> {
|
|
self.binding(document_type).ok_or_else(|| {
|
|
anyhow!("No CultNet document binding is registered for {document_type:?}")
|
|
})
|
|
}
|
|
}
|
|
|
|
impl<T> CultNetReactiveDocument<T>
|
|
where
|
|
T: DatabaseEntry + Serialize + DeserializeOwned,
|
|
{
|
|
pub fn new(
|
|
registry: CultNetDocumentRegistry,
|
|
cache: Arc<Mutex<CultCache>>,
|
|
record_key: impl Into<String>,
|
|
options: CultNetReactiveDocumentOptions,
|
|
) -> Result<Self> {
|
|
let record_key = record_key.into();
|
|
if record_key.trim().is_empty() {
|
|
return Err(anyhow!("record_key must be non-empty"));
|
|
}
|
|
registry.require_binding(T::TYPE)?;
|
|
let current = {
|
|
let cache = cache
|
|
.lock()
|
|
.map_err(|_| anyhow!("CultCache mutex poisoned"))?;
|
|
cache.get_required::<T>(&record_key)?
|
|
};
|
|
let last_clean_payload = serialize_typed(¤t)?;
|
|
let current = Arc::new(Mutex::new(current));
|
|
let state = Arc::new(Mutex::new(CultNetReactiveDocumentState {
|
|
dirty: false,
|
|
flushing: false,
|
|
flush_queued: false,
|
|
last_clean_payload,
|
|
reconciliation: None,
|
|
reconciliation_version: 0,
|
|
last_error: None,
|
|
}));
|
|
let disposed = Arc::new(AtomicBool::new(false));
|
|
let detect_worker = if options.detect_local_changes {
|
|
Some(start_reactive_detection_worker::<T>(
|
|
Arc::clone(&cache),
|
|
Arc::clone(¤t),
|
|
Arc::clone(&state),
|
|
Arc::clone(&disposed),
|
|
options.flush_delay,
|
|
record_key.clone(),
|
|
))
|
|
} else {
|
|
None
|
|
};
|
|
Ok(Self {
|
|
registry,
|
|
cache,
|
|
record_key,
|
|
current,
|
|
state,
|
|
options,
|
|
disposed,
|
|
detect_worker,
|
|
})
|
|
}
|
|
|
|
pub fn current(&self) -> Arc<Mutex<T>> {
|
|
Arc::clone(&self.current)
|
|
}
|
|
|
|
pub fn record_key(&self) -> &str {
|
|
&self.record_key
|
|
}
|
|
|
|
pub fn is_dirty(&self) -> bool {
|
|
self.state.lock().map(|state| state.dirty).unwrap_or(false)
|
|
}
|
|
|
|
pub fn reconciliation(&self) -> Option<CultNetReactiveDocumentReconciliation<T>> {
|
|
self.state
|
|
.lock()
|
|
.ok()
|
|
.and_then(|state| state.reconciliation.clone())
|
|
}
|
|
|
|
pub fn last_error(&self) -> Option<String> {
|
|
self.state
|
|
.lock()
|
|
.ok()
|
|
.and_then(|state| state.last_error.clone())
|
|
}
|
|
|
|
pub fn update(&self, update: impl FnOnce(&mut T)) -> Result<T> {
|
|
let predicted = {
|
|
let mut current = self
|
|
.current
|
|
.lock()
|
|
.map_err(|_| anyhow!("reactive document mutex poisoned"))?;
|
|
update(&mut current);
|
|
current.clone()
|
|
};
|
|
{
|
|
let mut state = self
|
|
.state
|
|
.lock()
|
|
.map_err(|_| anyhow!("reactive document state mutex poisoned"))?;
|
|
state.dirty = true;
|
|
}
|
|
if self.options.flush_delay.is_zero() {
|
|
self.flush()?;
|
|
}
|
|
Ok(predicted)
|
|
}
|
|
|
|
pub fn set_current(&self, value: T) -> Result<T> {
|
|
{
|
|
let mut current = self
|
|
.current
|
|
.lock()
|
|
.map_err(|_| anyhow!("reactive document mutex poisoned"))?;
|
|
*current = value.clone();
|
|
}
|
|
{
|
|
let mut state = self
|
|
.state
|
|
.lock()
|
|
.map_err(|_| anyhow!("reactive document state mutex poisoned"))?;
|
|
state.dirty = true;
|
|
}
|
|
if self.options.flush_delay.is_zero() {
|
|
self.flush()?;
|
|
}
|
|
Ok(value)
|
|
}
|
|
|
|
pub fn mark_dirty(&self) -> Result<()> {
|
|
let mut state = self
|
|
.state
|
|
.lock()
|
|
.map_err(|_| anyhow!("reactive document state mutex poisoned"))?;
|
|
state.dirty = true;
|
|
Ok(())
|
|
}
|
|
|
|
pub fn refresh(&self) -> Result<T> {
|
|
let canonical = {
|
|
let cache = self
|
|
.cache
|
|
.lock()
|
|
.map_err(|_| anyhow!("CultCache mutex poisoned"))?;
|
|
cache.get_required::<T>(&self.record_key)?
|
|
};
|
|
{
|
|
let mut current = self
|
|
.current
|
|
.lock()
|
|
.map_err(|_| anyhow!("reactive document mutex poisoned"))?;
|
|
*current = canonical.clone();
|
|
}
|
|
{
|
|
let mut state = self
|
|
.state
|
|
.lock()
|
|
.map_err(|_| anyhow!("reactive document state mutex poisoned"))?;
|
|
state.last_clean_payload = serialize_typed(&canonical)?;
|
|
state.dirty = false;
|
|
state.flush_queued = false;
|
|
state.reconciliation = None;
|
|
}
|
|
Ok(canonical)
|
|
}
|
|
|
|
pub fn flush(&self) -> Result<()> {
|
|
flush_reactive_document::<T>(&self.cache, &self.current, &self.state, &self.record_key)
|
|
}
|
|
|
|
pub fn apply_raw_document_put_message(&self, message: &CultNetMessage) -> Result<T> {
|
|
let canonical = {
|
|
let mut cache = self
|
|
.cache
|
|
.lock()
|
|
.map_err(|_| anyhow!("CultCache mutex poisoned"))?;
|
|
self.registry
|
|
.apply_raw_document_put_message::<T>(&mut cache, message)?
|
|
};
|
|
self.apply_canonical_value(canonical)
|
|
}
|
|
|
|
pub fn clear_reconciliation(&self) -> Result<()> {
|
|
let mut state = self
|
|
.state
|
|
.lock()
|
|
.map_err(|_| anyhow!("reactive document state mutex poisoned"))?;
|
|
state.reconciliation = None;
|
|
Ok(())
|
|
}
|
|
|
|
fn apply_canonical_value(&self, canonical: T) -> Result<T> {
|
|
let predicted = self
|
|
.current
|
|
.lock()
|
|
.map_err(|_| anyhow!("reactive document mutex poisoned"))?
|
|
.clone();
|
|
let mut state = self
|
|
.state
|
|
.lock()
|
|
.map_err(|_| anyhow!("reactive document state mutex poisoned"))?;
|
|
if state.dirty || state.flushing {
|
|
let delta = create_reconciliation_delta(&predicted, &canonical)?;
|
|
if delta.is_empty() {
|
|
state.reconciliation = None;
|
|
} else {
|
|
state.reconciliation_version += 1;
|
|
state.reconciliation = Some(CultNetReactiveDocumentReconciliation {
|
|
canonical: canonical.clone(),
|
|
predicted,
|
|
delta,
|
|
version: state.reconciliation_version,
|
|
received_at: now_utc_second(),
|
|
});
|
|
}
|
|
if !self.options.replace_dirty_current_on_canonical_snapshot {
|
|
return Ok(canonical);
|
|
}
|
|
}
|
|
drop(state);
|
|
{
|
|
let mut current = self
|
|
.current
|
|
.lock()
|
|
.map_err(|_| anyhow!("reactive document mutex poisoned"))?;
|
|
*current = canonical.clone();
|
|
}
|
|
let mut state = self
|
|
.state
|
|
.lock()
|
|
.map_err(|_| anyhow!("reactive document state mutex poisoned"))?;
|
|
state.last_clean_payload = serialize_typed(&canonical)?;
|
|
state.reconciliation = None;
|
|
Ok(canonical)
|
|
}
|
|
}
|
|
|
|
impl<T> Drop for CultNetReactiveDocument<T>
|
|
where
|
|
T: DatabaseEntry + Serialize + DeserializeOwned,
|
|
{
|
|
fn drop(&mut self) {
|
|
self.disposed.store(true, Ordering::SeqCst);
|
|
let _ = self.detect_worker.take();
|
|
}
|
|
}
|
|
|
|
fn start_reactive_detection_worker<T>(
|
|
cache: Arc<Mutex<CultCache>>,
|
|
current: Arc<Mutex<T>>,
|
|
state: Arc<Mutex<CultNetReactiveDocumentState<T>>>,
|
|
disposed: Arc<AtomicBool>,
|
|
flush_delay: Duration,
|
|
record_key: String,
|
|
) -> JoinHandle<()>
|
|
where
|
|
T: DatabaseEntry + Serialize + DeserializeOwned,
|
|
{
|
|
thread::spawn(move || {
|
|
let delay = if flush_delay.is_zero() {
|
|
Duration::from_millis(1)
|
|
} else {
|
|
flush_delay
|
|
};
|
|
while !disposed.load(Ordering::SeqCst) {
|
|
thread::sleep(delay);
|
|
match detect_reactive_document_change::<T>(¤t, &state).and_then(|dirty| {
|
|
if dirty {
|
|
flush_reactive_document::<T>(&cache, ¤t, &state, &record_key)?;
|
|
}
|
|
Ok(())
|
|
}) {
|
|
Ok(()) => {}
|
|
Err(error) => {
|
|
if let Ok(mut state) = state.lock() {
|
|
state.last_error = Some(error.to_string());
|
|
}
|
|
}
|
|
}
|
|
}
|
|
})
|
|
}
|
|
|
|
fn detect_reactive_document_change<T>(
|
|
current: &Arc<Mutex<T>>,
|
|
state: &Arc<Mutex<CultNetReactiveDocumentState<T>>>,
|
|
) -> Result<bool>
|
|
where
|
|
T: DatabaseEntry + Serialize + DeserializeOwned,
|
|
{
|
|
let payload = {
|
|
let current = current
|
|
.lock()
|
|
.map_err(|_| anyhow!("reactive document mutex poisoned"))?;
|
|
serialize_typed(&*current)?
|
|
};
|
|
let mut state = state
|
|
.lock()
|
|
.map_err(|_| anyhow!("reactive document state mutex poisoned"))?;
|
|
if !state.dirty && !state.flushing && payload != state.last_clean_payload {
|
|
state.dirty = true;
|
|
}
|
|
Ok(state.dirty && !state.flushing)
|
|
}
|
|
|
|
fn flush_reactive_document<T>(
|
|
cache: &Arc<Mutex<CultCache>>,
|
|
current: &Arc<Mutex<T>>,
|
|
state: &Arc<Mutex<CultNetReactiveDocumentState<T>>>,
|
|
record_key: &str,
|
|
) -> Result<()>
|
|
where
|
|
T: DatabaseEntry + Serialize + DeserializeOwned,
|
|
{
|
|
let predicted = {
|
|
let mut state_guard = state
|
|
.lock()
|
|
.map_err(|_| anyhow!("reactive document state mutex poisoned"))?;
|
|
if !state_guard.dirty {
|
|
drop(state_guard);
|
|
if !detect_reactive_document_change::<T>(current, state)? {
|
|
return Ok(());
|
|
}
|
|
state_guard = state
|
|
.lock()
|
|
.map_err(|_| anyhow!("reactive document state mutex poisoned"))?;
|
|
}
|
|
if state_guard.flushing {
|
|
state_guard.flush_queued = true;
|
|
return Ok(());
|
|
}
|
|
state_guard.flushing = true;
|
|
state_guard.dirty = false;
|
|
current
|
|
.lock()
|
|
.map_err(|_| anyhow!("reactive document mutex poisoned"))?
|
|
.clone()
|
|
};
|
|
let payload = serialize_typed(&predicted)?;
|
|
{
|
|
let mut cache = cache
|
|
.lock()
|
|
.map_err(|_| anyhow!("CultCache mutex poisoned"))?;
|
|
cache.put::<T>(record_key.to_string(), &predicted)?;
|
|
}
|
|
let should_flush_again = {
|
|
let mut state = state
|
|
.lock()
|
|
.map_err(|_| anyhow!("reactive document state mutex poisoned"))?;
|
|
state.flushing = false;
|
|
state.last_clean_payload = payload;
|
|
state.last_error = None;
|
|
state.reconciliation = None;
|
|
let changed_after_flush = {
|
|
let current = current
|
|
.lock()
|
|
.map_err(|_| anyhow!("reactive document mutex poisoned"))?;
|
|
serialize_typed(&*current)? != state.last_clean_payload
|
|
};
|
|
if changed_after_flush {
|
|
state.dirty = true;
|
|
}
|
|
let should_flush_again = state.flush_queued || state.dirty;
|
|
state.flush_queued = false;
|
|
should_flush_again
|
|
};
|
|
if should_flush_again {
|
|
flush_reactive_document::<T>(cache, current, state, record_key)?;
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn serialize_typed<T>(value: &T) -> Result<Vec<u8>>
|
|
where
|
|
T: Serialize,
|
|
{
|
|
Ok(rmp_serde::to_vec(value)?)
|
|
}
|
|
|
|
fn create_reconciliation_delta<T>(predicted: &T, canonical: &T) -> Result<BTreeMap<String, Value>>
|
|
where
|
|
T: Serialize,
|
|
{
|
|
let predicted = serde_json::to_value(predicted)?;
|
|
let canonical = serde_json::to_value(canonical)?;
|
|
let mut delta = BTreeMap::new();
|
|
match (predicted, canonical) {
|
|
(Value::Object(predicted), Value::Object(canonical)) => {
|
|
for (key, predicted_value) in predicted {
|
|
let canonical_value = canonical.get(&key).cloned().unwrap_or(Value::Null);
|
|
if predicted_value == canonical_value {
|
|
continue;
|
|
}
|
|
delta.insert(
|
|
key,
|
|
numeric_delta(&predicted_value, &canonical_value).unwrap_or(predicted_value),
|
|
);
|
|
}
|
|
}
|
|
(Value::Array(predicted), Value::Array(canonical)) => {
|
|
for (index, predicted_value) in predicted.into_iter().enumerate() {
|
|
let canonical_value = canonical.get(index).cloned().unwrap_or(Value::Null);
|
|
if predicted_value == canonical_value {
|
|
continue;
|
|
}
|
|
delta.insert(
|
|
index.to_string(),
|
|
numeric_delta(&predicted_value, &canonical_value).unwrap_or(predicted_value),
|
|
);
|
|
}
|
|
}
|
|
(predicted, canonical) if predicted != canonical => {
|
|
delta.insert(
|
|
"value".to_string(),
|
|
numeric_delta(&predicted, &canonical).unwrap_or(predicted),
|
|
);
|
|
}
|
|
_ => {}
|
|
}
|
|
Ok(delta)
|
|
}
|
|
|
|
fn numeric_delta(predicted: &Value, canonical: &Value) -> Option<Value> {
|
|
let predicted = predicted.as_f64()?;
|
|
let canonical = canonical.as_f64()?;
|
|
serde_json::Number::from_f64(predicted - canonical).map(Value::Number)
|
|
}
|
|
|
|
fn now_utc_second() -> String {
|
|
chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Secs, true)
|
|
}
|