create-feltdb 0.9.1 → 0.11.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/package-versions.js +1 -1
- package/dist/server-source/crates/feltdb/src/acceptance_tests.rs +8 -1
- package/dist/server-source/crates/feltdb/src/application.rs +16 -0
- package/dist/server-source/crates/feltdb/src/bin/feltdb_node.rs +21 -22
- package/dist/server-source/crates/feltdb/src/lib.rs +556 -45
- package/dist/server-source/crates/feltdb/src/state_contract.rs +48 -3
- package/dist/server-source/crates/feltdb/src/state_model.rs +569 -4
- package/dist/server-source/crates/feltdb/src/state_trigger.rs +1 -0
- package/dist/server-source/crates/feltdb/src/versioned_object_substrate.rs +708 -0
- package/dist/server-source/crates/feltdb/src/worker_mesh.rs +1 -0
- package/dist/server-source/crates/feltdb/src/workload.rs +480 -44
- package/dist/server-source/crates/feltdb/tests/generalized_version_control_evidence.rs +123 -0
- package/dist/server-source/crates/feltdb/tests/generalized_version_control_substrate_completion.rs +281 -0
- package/dist/server-source/crates/feltdb/tests/local_process_ownership.rs +69 -0
- package/dist/server-source/crates/feltdb/tests/production_readiness_contract.rs +15 -3
- package/dist/server-source/crates/feltdb/tests/revision_retention_boundary_evidence.rs +49 -24
- package/dist/server-source/crates/feltdb/tests/storage_independent_versioned_object_substrate.rs +328 -0
- package/dist/server-source/crates/feltdb/tests/workload_envelope_contract.rs +20 -3
- package/dist/server-source/crates/feltdb-server/src/app_state.rs +14 -4
- package/dist/server-source/crates/feltdb-server/src/backup.rs +0 -4
- package/dist/server-source/crates/feltdb-server/src/canonical_principals.rs +1093 -0
- package/dist/server-source/crates/feltdb-server/src/causal.rs +905 -144
- package/dist/server-source/crates/feltdb-server/src/connections.rs +9 -6
- package/dist/server-source/crates/feltdb-server/src/control_plane.rs +90 -0
- package/dist/server-source/crates/feltdb-server/src/lib.rs +1 -0
- package/dist/server-source/crates/feltdb-server/src/main.rs +418 -82
- package/dist/server-source/crates/feltdb-server/src/principals.rs +9 -501
- package/dist/server-source/crates/feltdb-server/src/tenancy.rs +22 -0
- package/dist/server-source/crates/feltdb-server/tests/principal_tenant_migration.rs +247 -0
- package/package.json +1 -1
|
@@ -130,6 +130,7 @@ pub mod transaction_preconditions;
|
|
|
130
130
|
pub mod transactions;
|
|
131
131
|
mod trigger;
|
|
132
132
|
pub mod trigger_contract;
|
|
133
|
+
pub mod versioned_object_substrate;
|
|
133
134
|
pub mod worker_mesh;
|
|
134
135
|
mod workflow;
|
|
135
136
|
#[cfg(test)]
|
|
@@ -238,9 +239,11 @@ pub use state_facade::FeltDBStateSystem;
|
|
|
238
239
|
pub use state_hash::{CanonicalState, StateHash};
|
|
239
240
|
pub use state_model::{
|
|
240
241
|
apply_reconciliation_plan, path_relation, paths_overlap, reconcile, resolve_path, ChangeKind,
|
|
241
|
-
ConflictClass, ConflictClassification,
|
|
242
|
-
|
|
243
|
-
|
|
242
|
+
ConflictClass, ConflictClassification, GarbageCollectionError, GarbageCollectionReport,
|
|
243
|
+
MaterializationError, MaterializationProjection, PathComponent, PathConflict, PathRelation,
|
|
244
|
+
ReconciliationPlan, RefError, Relationship, SemanticChange, SemanticDiff, StateId, StateObject,
|
|
245
|
+
StateReconciliationResult, StateRef, StateRevision, StateStore, StateTopology,
|
|
246
|
+
STATE_MODEL_VERSION,
|
|
244
247
|
};
|
|
245
248
|
pub use state_transition_store::{MemoryStateTransitionStore, StateTransitionRecord};
|
|
246
249
|
pub use state_trigger::{
|
|
@@ -262,6 +265,10 @@ pub use trigger_contract::{
|
|
|
262
265
|
ContractRuntime, ContractTrigger, MatchOutcome, MatchReason, TriggerMatchInput,
|
|
263
266
|
TriggerMatchOutput, TRIGGER_MATCH_CONTRACT_ID, TRIGGER_MATCH_CONTRACT_VERSION,
|
|
264
267
|
};
|
|
268
|
+
pub use versioned_object_substrate::{
|
|
269
|
+
DurableObjectStore, DurableRef, LocalObjectStore, Reachability, S3CompatibleBucket,
|
|
270
|
+
S3CompatibleObjectStore, SubstrateError, VersionedObjectSubstrate,
|
|
271
|
+
};
|
|
265
272
|
pub use workflow::{
|
|
266
273
|
BlockedReason, WorkflowGraph, WorkflowInstance, WorkflowNode, WorkflowOperation, WorkflowRef,
|
|
267
274
|
WorkflowState, WorkflowStepRef, WorkflowStepState,
|
|
@@ -280,12 +287,12 @@ use std::any::type_name;
|
|
|
280
287
|
use std::collections::hash_map::DefaultHasher;
|
|
281
288
|
use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet, VecDeque};
|
|
282
289
|
use std::fmt::{Display, Formatter};
|
|
283
|
-
use std::fs::{self, OpenOptions};
|
|
290
|
+
use std::fs::{self, File, OpenOptions};
|
|
284
291
|
use std::hash::{Hash, Hasher};
|
|
285
292
|
use std::io::{BufRead, BufReader, Write};
|
|
286
293
|
use std::ops::Bound::{Excluded, Unbounded};
|
|
287
294
|
use std::path::{Path, PathBuf};
|
|
288
|
-
use std::sync::{Arc, Mutex};
|
|
295
|
+
use std::sync::{Arc, Mutex, OnceLock, Weak};
|
|
289
296
|
use std::time::{SystemTime, UNIX_EPOCH};
|
|
290
297
|
use tokio::sync::broadcast;
|
|
291
298
|
|
|
@@ -362,8 +369,17 @@ impl From<String> for FlowError {
|
|
|
362
369
|
pub struct FeltDb {
|
|
363
370
|
inner: Arc<Mutex<Inner>>,
|
|
364
371
|
event_tx: broadcast::Sender<ChangeEvent>,
|
|
372
|
+
_ownership: Arc<File>,
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
struct OpenDatabase {
|
|
376
|
+
inner: Weak<Mutex<Inner>>,
|
|
377
|
+
event_tx: broadcast::Sender<ChangeEvent>,
|
|
378
|
+
ownership: Weak<File>,
|
|
365
379
|
}
|
|
366
380
|
|
|
381
|
+
static OPEN_DATABASES: OnceLock<Mutex<HashMap<PathBuf, OpenDatabase>>> = OnceLock::new();
|
|
382
|
+
|
|
367
383
|
#[derive(Default)]
|
|
368
384
|
#[allow(dead_code)] // Reserved runtime subsystems are initialized before their public APIs land.
|
|
369
385
|
struct Inner {
|
|
@@ -403,6 +419,10 @@ struct Inner {
|
|
|
403
419
|
/// audit archive, and the durable log remains the record of what happened.
|
|
404
420
|
/// Keeping it bounded is what makes it safe to maintain on the commit path.
|
|
405
421
|
transaction_attributions: VecDeque<TransactionAttribution>,
|
|
422
|
+
/// Complete committed transaction order, rebuilt exclusively from the
|
|
423
|
+
/// durable transaction log. Unlike the bounded operator window above this
|
|
424
|
+
/// is the canonical replay/provenance index.
|
|
425
|
+
transaction_history: Vec<TransactionAttribution>,
|
|
406
426
|
collection_cardinality: HashMap<String, u64>,
|
|
407
427
|
/// Derived equality index over `rows`. Never durable, never replicated, and
|
|
408
428
|
/// never consulted for anything but candidate selection: it is maintained
|
|
@@ -505,6 +525,12 @@ impl Inner {
|
|
|
505
525
|
let bucket = self.rows.entry(capability.clone()).or_default();
|
|
506
526
|
let previous = bucket.insert(key.clone(), row);
|
|
507
527
|
drop(mutation);
|
|
528
|
+
if previous.is_none() {
|
|
529
|
+
*self
|
|
530
|
+
.collection_cardinality
|
|
531
|
+
.entry(capability.clone())
|
|
532
|
+
.or_default() += 1;
|
|
533
|
+
}
|
|
508
534
|
if !indexed {
|
|
509
535
|
return previous.is_none();
|
|
510
536
|
}
|
|
@@ -543,6 +569,11 @@ impl Inner {
|
|
|
543
569
|
return false;
|
|
544
570
|
};
|
|
545
571
|
drop(mutation);
|
|
572
|
+
let count = self
|
|
573
|
+
.collection_cardinality
|
|
574
|
+
.entry(capability.to_string())
|
|
575
|
+
.or_default();
|
|
576
|
+
*count = count.saturating_sub(1);
|
|
546
577
|
let _index = workload_diagnostics::span(workload_diagnostics::Phase::IndexMaintenance);
|
|
547
578
|
self.equality_index
|
|
548
579
|
.remove_record(capability, key, &removed.value);
|
|
@@ -552,6 +583,7 @@ impl Inner {
|
|
|
552
583
|
/// Drop all records, as a snapshot install does. The index follows.
|
|
553
584
|
fn clear_rows(&mut self) {
|
|
554
585
|
self.rows.clear();
|
|
586
|
+
self.collection_cardinality.clear();
|
|
555
587
|
self.equality_index.clear();
|
|
556
588
|
}
|
|
557
589
|
}
|
|
@@ -773,6 +805,10 @@ const TRANSACTION_ATTRIBUTION_KEYS: usize = 32;
|
|
|
773
805
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
|
774
806
|
pub struct TransactionAttribution {
|
|
775
807
|
pub transaction_id: String,
|
|
808
|
+
/// Stable operation identities assigned to the rows in this transaction.
|
|
809
|
+
/// These are distinct from both transaction and revision identity.
|
|
810
|
+
#[serde(default)]
|
|
811
|
+
pub operation_ids: Vec<String>,
|
|
776
812
|
/// The actor the caller authenticated as, when the transaction named one.
|
|
777
813
|
pub subject: Option<String>,
|
|
778
814
|
pub tenant_id: Option<String>,
|
|
@@ -789,8 +825,23 @@ pub struct TransactionAttribution {
|
|
|
789
825
|
/// How many mutations the transaction made, whether or not `keys` lists
|
|
790
826
|
/// them all.
|
|
791
827
|
pub operations: usize,
|
|
828
|
+
#[serde(default)]
|
|
829
|
+
pub principal_id: Option<String>,
|
|
830
|
+
#[serde(default)]
|
|
831
|
+
pub agent_id: Option<String>,
|
|
832
|
+
#[serde(default)]
|
|
833
|
+
pub session_id: Option<String>,
|
|
834
|
+
#[serde(default)]
|
|
835
|
+
pub delegator_id: Option<String>,
|
|
836
|
+
#[serde(default)]
|
|
837
|
+
pub work_id: Option<String>,
|
|
792
838
|
/// Milliseconds since the epoch, matching `StoredRow::unix_ms`.
|
|
793
839
|
pub unix_ms: u128,
|
|
840
|
+
/// Canonical transaction metadata exactly as committed. Record values are
|
|
841
|
+
/// deliberately absent; stable work/session/agent/operation references
|
|
842
|
+
/// live here so historical provenance survives process loss.
|
|
843
|
+
#[serde(default)]
|
|
844
|
+
pub audit: Option<Value>,
|
|
794
845
|
}
|
|
795
846
|
|
|
796
847
|
impl TransactionAttribution {
|
|
@@ -813,6 +864,13 @@ impl TransactionAttribution {
|
|
|
813
864
|
};
|
|
814
865
|
TransactionAttribution {
|
|
815
866
|
transaction_id: transaction_id.to_string(),
|
|
867
|
+
operation_ids: rows
|
|
868
|
+
.iter()
|
|
869
|
+
.filter_map(|row| row.operation.as_ref())
|
|
870
|
+
.map(|operation| {
|
|
871
|
+
format!("operation:{}:{}", operation.instance_id, operation.sequence)
|
|
872
|
+
})
|
|
873
|
+
.collect(),
|
|
816
874
|
subject: text("subject"),
|
|
817
875
|
tenant_id: text("tenant"),
|
|
818
876
|
application_id: text("application"),
|
|
@@ -827,7 +885,13 @@ impl TransactionAttribution {
|
|
|
827
885
|
.map(|row| format!("{}:{}", row.capability, row.key))
|
|
828
886
|
.collect(),
|
|
829
887
|
operations: rows.len(),
|
|
888
|
+
principal_id: text("principal_id"),
|
|
889
|
+
agent_id: text("agent_id"),
|
|
890
|
+
session_id: text("session_id"),
|
|
891
|
+
delegator_id: text("delegator_id"),
|
|
892
|
+
work_id: text("work_id").or_else(|| text("workload_id")),
|
|
830
893
|
unix_ms,
|
|
894
|
+
audit: audit.cloned(),
|
|
831
895
|
}
|
|
832
896
|
}
|
|
833
897
|
|
|
@@ -838,14 +902,84 @@ impl TransactionAttribution {
|
|
|
838
902
|
}
|
|
839
903
|
}
|
|
840
904
|
|
|
905
|
+
/// Upgrade compacted attribution written before operation identities became a
|
|
906
|
+
/// first-class part of canonical history. The mapping is deterministic from
|
|
907
|
+
/// durable transaction data, so reopening never regenerates a different ID.
|
|
908
|
+
fn normalize_transaction_attribution(
|
|
909
|
+
mut attribution: TransactionAttribution,
|
|
910
|
+
) -> TransactionAttribution {
|
|
911
|
+
if attribution.operation_ids.is_empty() {
|
|
912
|
+
if attribution.transaction_id.starts_with("operation:") {
|
|
913
|
+
let operation_id = attribution.transaction_id.clone();
|
|
914
|
+
attribution.transaction_id = operation_id.replacen("operation:", "transaction:", 1);
|
|
915
|
+
attribution.operation_ids.push(operation_id);
|
|
916
|
+
} else {
|
|
917
|
+
attribution.operation_ids = (1..=attribution.operations)
|
|
918
|
+
.map(|index| {
|
|
919
|
+
format!(
|
|
920
|
+
"operation:legacy-transaction:{}:{index}",
|
|
921
|
+
attribution.transaction_id
|
|
922
|
+
)
|
|
923
|
+
})
|
|
924
|
+
.collect();
|
|
925
|
+
}
|
|
926
|
+
}
|
|
927
|
+
attribution
|
|
928
|
+
}
|
|
929
|
+
|
|
841
930
|
/// Records an attribution, discarding the oldest once the window is full.
|
|
842
931
|
fn remember_attribution(inner: &mut Inner, attribution: TransactionAttribution) {
|
|
932
|
+
inner.transaction_history.push(attribution.clone());
|
|
843
933
|
if inner.transaction_attributions.len() >= TRANSACTION_ATTRIBUTION_WINDOW {
|
|
844
934
|
inner.transaction_attributions.pop_front();
|
|
845
935
|
}
|
|
846
936
|
inner.transaction_attributions.push_back(attribution);
|
|
847
937
|
}
|
|
848
938
|
|
|
939
|
+
fn remember_operation_attribution(
|
|
940
|
+
inner: &mut Inner,
|
|
941
|
+
row: &StoredRow,
|
|
942
|
+
base_revision: u64,
|
|
943
|
+
commit_revision: u64,
|
|
944
|
+
) {
|
|
945
|
+
let Some(operation) = row.operation.as_ref() else {
|
|
946
|
+
return;
|
|
947
|
+
};
|
|
948
|
+
remember_attribution(
|
|
949
|
+
inner,
|
|
950
|
+
TransactionAttribution {
|
|
951
|
+
transaction_id: format!(
|
|
952
|
+
"transaction:{}:{}",
|
|
953
|
+
operation.instance_id, operation.sequence
|
|
954
|
+
),
|
|
955
|
+
operation_ids: vec![format!(
|
|
956
|
+
"operation:{}:{}",
|
|
957
|
+
operation.instance_id, operation.sequence
|
|
958
|
+
)],
|
|
959
|
+
subject: None,
|
|
960
|
+
tenant_id: None,
|
|
961
|
+
application_id: None,
|
|
962
|
+
application_revision: operation
|
|
963
|
+
.revision
|
|
964
|
+
.as_ref()
|
|
965
|
+
.map(|revision| revision.id.clone()),
|
|
966
|
+
base_revision,
|
|
967
|
+
commit_revision,
|
|
968
|
+
state_before: operation.sequence.saturating_sub(1),
|
|
969
|
+
state_after: operation.sequence,
|
|
970
|
+
keys: vec![format!("{}:{}", row.capability, row.key)],
|
|
971
|
+
operations: 1,
|
|
972
|
+
principal_id: None,
|
|
973
|
+
agent_id: None,
|
|
974
|
+
session_id: None,
|
|
975
|
+
delegator_id: None,
|
|
976
|
+
work_id: None,
|
|
977
|
+
unix_ms: row.unix_ms,
|
|
978
|
+
audit: None,
|
|
979
|
+
},
|
|
980
|
+
);
|
|
981
|
+
}
|
|
982
|
+
|
|
849
983
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
850
984
|
struct TransactionLogRecord {
|
|
851
985
|
record_type: String,
|
|
@@ -1551,6 +1685,15 @@ struct LogHeader {
|
|
|
1551
1685
|
/// which deserialize as 0 and are classified as unversioned.
|
|
1552
1686
|
#[serde(default)]
|
|
1553
1687
|
format_version: u32,
|
|
1688
|
+
/// Canonical commit provenance retained when operation-log compaction
|
|
1689
|
+
/// rewrites current state. This is history, not a derived event store.
|
|
1690
|
+
#[serde(default)]
|
|
1691
|
+
transaction_history: Vec<TransactionAttribution>,
|
|
1692
|
+
/// Durable binding between a caller transaction identity and its semantic
|
|
1693
|
+
/// request. Compaction must retain this or an old id could be reused for a
|
|
1694
|
+
/// different mutation after restart.
|
|
1695
|
+
#[serde(default)]
|
|
1696
|
+
transaction_payload_hashes: HashMap<String, String>,
|
|
1554
1697
|
}
|
|
1555
1698
|
|
|
1556
1699
|
#[derive(Debug, Default, Serialize, Deserialize)]
|
|
@@ -1834,7 +1977,56 @@ struct RetentionRecord {
|
|
|
1834
1977
|
|
|
1835
1978
|
impl FeltDb {
|
|
1836
1979
|
pub fn open<P: AsRef<Path>>(path: P) -> Result<Self> {
|
|
1837
|
-
let
|
|
1980
|
+
let requested_path = path.as_ref();
|
|
1981
|
+
let parent = requested_path
|
|
1982
|
+
.parent()
|
|
1983
|
+
.filter(|parent| !parent.as_os_str().is_empty())
|
|
1984
|
+
.unwrap_or_else(|| Path::new("."));
|
|
1985
|
+
fs::create_dir_all(parent)?;
|
|
1986
|
+
let path = if requested_path.exists() {
|
|
1987
|
+
fs::canonicalize(requested_path)?
|
|
1988
|
+
} else {
|
|
1989
|
+
let file_name = requested_path.file_name().ok_or_else(|| {
|
|
1990
|
+
std::io::Error::new(
|
|
1991
|
+
std::io::ErrorKind::InvalidInput,
|
|
1992
|
+
"FeltDB database path has no file name",
|
|
1993
|
+
)
|
|
1994
|
+
})?;
|
|
1995
|
+
fs::canonicalize(parent)?.join(file_name)
|
|
1996
|
+
};
|
|
1997
|
+
|
|
1998
|
+
let databases = OPEN_DATABASES.get_or_init(|| Mutex::new(HashMap::new()));
|
|
1999
|
+
let mut databases = databases.lock().expect("open database registry poisoned");
|
|
2000
|
+
if let Some(open) = databases.get(&path) {
|
|
2001
|
+
if let (Some(inner), Some(ownership)) =
|
|
2002
|
+
(open.inner.upgrade(), open.ownership.upgrade())
|
|
2003
|
+
{
|
|
2004
|
+
return Ok(Self {
|
|
2005
|
+
inner,
|
|
2006
|
+
event_tx: open.event_tx.clone(),
|
|
2007
|
+
_ownership: ownership,
|
|
2008
|
+
});
|
|
2009
|
+
}
|
|
2010
|
+
}
|
|
2011
|
+
databases.remove(&path);
|
|
2012
|
+
|
|
2013
|
+
let mut ownership_path = path.as_os_str().to_os_string();
|
|
2014
|
+
ownership_path.push(".lock");
|
|
2015
|
+
let ownership = OpenOptions::new()
|
|
2016
|
+
.create(true)
|
|
2017
|
+
.read(true)
|
|
2018
|
+
.write(true)
|
|
2019
|
+
.open(PathBuf::from(ownership_path))?;
|
|
2020
|
+
ownership.try_lock().map_err(|error| match error {
|
|
2021
|
+
std::fs::TryLockError::WouldBlock => std::io::Error::new(
|
|
2022
|
+
std::io::ErrorKind::WouldBlock,
|
|
2023
|
+
format!(
|
|
2024
|
+
"FeltDB database {} is already owned by another process",
|
|
2025
|
+
path.display()
|
|
2026
|
+
),
|
|
2027
|
+
),
|
|
2028
|
+
std::fs::TryLockError::Error(error) => error,
|
|
2029
|
+
})?;
|
|
1838
2030
|
|
|
1839
2031
|
// Fail closed. The format is decided before any record is interpreted
|
|
1840
2032
|
// and before anything is written, so a database this build does not
|
|
@@ -1847,9 +2039,6 @@ impl FeltDb {
|
|
|
1847
2039
|
FormatCompatibility::Compatible(format) => format,
|
|
1848
2040
|
};
|
|
1849
2041
|
|
|
1850
|
-
if let Some(parent) = path.parent() {
|
|
1851
|
-
fs::create_dir_all(parent)?;
|
|
1852
|
-
}
|
|
1853
2042
|
if !path.exists() {
|
|
1854
2043
|
let mut file = OpenOptions::new().create(true).append(true).open(&path)?;
|
|
1855
2044
|
// A new database states its format in its first record, so it never
|
|
@@ -1885,10 +2074,20 @@ impl FeltDb {
|
|
|
1885
2074
|
|
|
1886
2075
|
let (event_tx, _) = broadcast::channel(1024);
|
|
1887
2076
|
|
|
1888
|
-
|
|
2077
|
+
let database = Self {
|
|
1889
2078
|
inner: Arc::new(Mutex::new(inner)),
|
|
1890
2079
|
event_tx,
|
|
1891
|
-
|
|
2080
|
+
_ownership: Arc::new(ownership),
|
|
2081
|
+
};
|
|
2082
|
+
databases.insert(
|
|
2083
|
+
path,
|
|
2084
|
+
OpenDatabase {
|
|
2085
|
+
inner: Arc::downgrade(&database.inner),
|
|
2086
|
+
event_tx: database.event_tx.clone(),
|
|
2087
|
+
ownership: Arc::downgrade(&database._ownership),
|
|
2088
|
+
},
|
|
2089
|
+
);
|
|
2090
|
+
Ok(database)
|
|
1892
2091
|
}
|
|
1893
2092
|
|
|
1894
2093
|
/// Write a self-contained durable backup artifact.
|
|
@@ -2130,10 +2329,21 @@ impl FeltDb {
|
|
|
2130
2329
|
.map(|(cap, _)| cap)
|
|
2131
2330
|
.unwrap_or("default")
|
|
2132
2331
|
.to_string();
|
|
2133
|
-
self.insert_internal(capability, key.to_string(), value)?;
|
|
2332
|
+
self.insert_internal(capability, key.to_string(), value, false)?;
|
|
2134
2333
|
Ok(())
|
|
2135
2334
|
}
|
|
2136
2335
|
|
|
2336
|
+
/// Insert only if the logical record is absent, deciding presence under the
|
|
2337
|
+
/// same lock that appends the operation and mints its first revision.
|
|
2338
|
+
pub fn insert_if_absent<T: Serialize>(&self, key: &str, value: T) -> Result<()> {
|
|
2339
|
+
let capability = key
|
|
2340
|
+
.split_once(':')
|
|
2341
|
+
.map(|(cap, _)| cap)
|
|
2342
|
+
.unwrap_or("default")
|
|
2343
|
+
.to_string();
|
|
2344
|
+
self.insert_internal(capability, key.to_string(), value, true)
|
|
2345
|
+
}
|
|
2346
|
+
|
|
2137
2347
|
/// Update an existing record with vector clock tracking
|
|
2138
2348
|
pub fn update<T: Serialize>(&self, key: &str, value: T) -> Result<()> {
|
|
2139
2349
|
let capability = key
|
|
@@ -2346,7 +2556,7 @@ impl FeltDb {
|
|
|
2346
2556
|
}
|
|
2347
2557
|
|
|
2348
2558
|
// Authorized: apply mutation
|
|
2349
|
-
self.insert_internal(capability, key.to_string(), value)?;
|
|
2559
|
+
self.insert_internal(capability, key.to_string(), value, false)?;
|
|
2350
2560
|
Ok(())
|
|
2351
2561
|
}
|
|
2352
2562
|
|
|
@@ -2658,13 +2868,19 @@ impl FeltDb {
|
|
|
2658
2868
|
.get(transaction_id)
|
|
2659
2869
|
.copied()
|
|
2660
2870
|
.unwrap_or((inner.authority_revision, inner.authority_revision));
|
|
2871
|
+
let (state_before, state_after) = inner
|
|
2872
|
+
.transaction_history
|
|
2873
|
+
.iter()
|
|
2874
|
+
.find(|attribution| attribution.transaction_id == transaction_id)
|
|
2875
|
+
.map(|attribution| (attribution.state_before, attribution.state_after))
|
|
2876
|
+
.unwrap_or((inner.sequence, inner.sequence));
|
|
2661
2877
|
return Ok(AtomicCommit {
|
|
2662
2878
|
transaction_id: transaction_id.into(),
|
|
2663
2879
|
base_revision,
|
|
2664
2880
|
commit_revision,
|
|
2665
2881
|
status: TransactionStatus::Committed,
|
|
2666
|
-
state_before
|
|
2667
|
-
state_after
|
|
2882
|
+
state_before,
|
|
2883
|
+
state_after,
|
|
2668
2884
|
rows: vec![],
|
|
2669
2885
|
duplicate: true,
|
|
2670
2886
|
});
|
|
@@ -2960,26 +3176,11 @@ impl FeltDb {
|
|
|
2960
3176
|
}
|
|
2961
3177
|
if row.deleted {
|
|
2962
3178
|
inner.remove_row(&row.capability, &row.key);
|
|
2963
|
-
// Decrement cardinality on delete
|
|
2964
|
-
let count = inner
|
|
2965
|
-
.collection_cardinality
|
|
2966
|
-
.entry(row.capability.clone())
|
|
2967
|
-
.or_default();
|
|
2968
|
-
*count = count.saturating_sub(1);
|
|
2969
3179
|
} else {
|
|
2970
3180
|
// Record and index change together, inside the commit that
|
|
2971
3181
|
// is already holding the state lock: a transaction that is
|
|
2972
3182
|
// refused leaves neither, and one that commits publishes both.
|
|
2973
|
-
|
|
2974
|
-
|
|
2975
|
-
// Increment cardinality only on insert, not update
|
|
2976
|
-
if is_insert {
|
|
2977
|
-
let count = inner
|
|
2978
|
-
.collection_cardinality
|
|
2979
|
-
.entry(row.capability.clone())
|
|
2980
|
-
.or_default();
|
|
2981
|
-
*count = count.saturating_add(1);
|
|
2982
|
-
}
|
|
3183
|
+
inner.put_row(row.clone());
|
|
2983
3184
|
}
|
|
2984
3185
|
}
|
|
2985
3186
|
let tail_began = workload_diagnostics::enabled().then(std::time::Instant::now);
|
|
@@ -3070,6 +3271,7 @@ impl FeltDb {
|
|
|
3070
3271
|
capability: String,
|
|
3071
3272
|
key: String,
|
|
3072
3273
|
value: T,
|
|
3274
|
+
require_absent: bool,
|
|
3073
3275
|
) -> Result<()> {
|
|
3074
3276
|
let rust_type = type_name::<T>().to_string();
|
|
3075
3277
|
let value_json = serde_json::to_value(value)?;
|
|
@@ -3091,6 +3293,21 @@ impl FeltDb {
|
|
|
3091
3293
|
{
|
|
3092
3294
|
let mut inner = self.state();
|
|
3093
3295
|
|
|
3296
|
+
if require_absent
|
|
3297
|
+
&& inner
|
|
3298
|
+
.rows
|
|
3299
|
+
.get(&capability)
|
|
3300
|
+
.and_then(|bucket| bucket.get(&key))
|
|
3301
|
+
.is_some()
|
|
3302
|
+
{
|
|
3303
|
+
return Err(FlowError::PreconditionFailed(Box::new(
|
|
3304
|
+
PreconditionFailure::Present {
|
|
3305
|
+
collection: capability,
|
|
3306
|
+
key,
|
|
3307
|
+
},
|
|
3308
|
+
)));
|
|
3309
|
+
}
|
|
3310
|
+
|
|
3094
3311
|
// Increment sequence number
|
|
3095
3312
|
inner.sequence += 1;
|
|
3096
3313
|
|
|
@@ -3123,7 +3340,14 @@ impl FeltDb {
|
|
|
3123
3340
|
inner.change_log.add_operation(op.clone());
|
|
3124
3341
|
|
|
3125
3342
|
append_event_locked(&mut inner, &row)?;
|
|
3343
|
+
let base_revision = inner.authority_revision;
|
|
3126
3344
|
inner.authority_revision += 1;
|
|
3345
|
+
remember_operation_attribution(
|
|
3346
|
+
&mut inner,
|
|
3347
|
+
&row,
|
|
3348
|
+
base_revision,
|
|
3349
|
+
base_revision + 1,
|
|
3350
|
+
);
|
|
3127
3351
|
inner.put_row(row);
|
|
3128
3352
|
if let Some(revision) = planned {
|
|
3129
3353
|
persist_revision_locked(&mut inner, revision)?;
|
|
@@ -3202,7 +3426,14 @@ impl FeltDb {
|
|
|
3202
3426
|
inner.change_log.add_operation(op.clone());
|
|
3203
3427
|
|
|
3204
3428
|
append_event_locked(&mut inner, &row)?;
|
|
3429
|
+
let base_revision = inner.authority_revision;
|
|
3205
3430
|
inner.authority_revision += 1;
|
|
3431
|
+
remember_operation_attribution(
|
|
3432
|
+
&mut inner,
|
|
3433
|
+
&row,
|
|
3434
|
+
base_revision,
|
|
3435
|
+
base_revision + 1,
|
|
3436
|
+
);
|
|
3206
3437
|
inner.put_row(row);
|
|
3207
3438
|
if let Some(revision) = planned {
|
|
3208
3439
|
persist_revision_locked(&mut inner, revision)?;
|
|
@@ -3270,7 +3501,14 @@ impl FeltDb {
|
|
|
3270
3501
|
operation: Some(op),
|
|
3271
3502
|
};
|
|
3272
3503
|
append_event(&inner.path, &tombstone)?;
|
|
3504
|
+
let base_revision = inner.authority_revision;
|
|
3273
3505
|
inner.authority_revision += 1;
|
|
3506
|
+
remember_operation_attribution(
|
|
3507
|
+
&mut inner,
|
|
3508
|
+
&tombstone,
|
|
3509
|
+
base_revision,
|
|
3510
|
+
base_revision + 1,
|
|
3511
|
+
);
|
|
3274
3512
|
|
|
3275
3513
|
// Remove from in-memory store, index entries included.
|
|
3276
3514
|
inner.remove_row(&capability, &key);
|
|
@@ -3879,6 +4117,8 @@ impl FeltDb {
|
|
|
3879
4117
|
format_version: DURABLE_FORMAT_VERSION,
|
|
3880
4118
|
local_sequence: inner.sequence,
|
|
3881
4119
|
observed_versions: snapshot.versions.clone(),
|
|
4120
|
+
transaction_history: Vec::new(),
|
|
4121
|
+
transaction_payload_hashes: HashMap::new(),
|
|
3882
4122
|
},
|
|
3883
4123
|
)?;
|
|
3884
4124
|
for row in &snapshot.rows {
|
|
@@ -3951,25 +4191,43 @@ impl FeltDb {
|
|
|
3951
4191
|
.collect())
|
|
3952
4192
|
}
|
|
3953
4193
|
|
|
3954
|
-
/// One transaction's attribution,
|
|
4194
|
+
/// One transaction's durable attribution, regardless of the operator
|
|
4195
|
+
/// window. This lookup is canonical historical provenance.
|
|
3955
4196
|
pub fn transaction_attribution(
|
|
3956
4197
|
&self,
|
|
3957
4198
|
transaction_id: &str,
|
|
3958
4199
|
) -> Result<Option<TransactionAttribution>> {
|
|
3959
4200
|
let inner = self.state();
|
|
3960
4201
|
Ok(inner
|
|
3961
|
-
.
|
|
4202
|
+
.transaction_history
|
|
3962
4203
|
.iter()
|
|
3963
4204
|
.rev()
|
|
3964
4205
|
.find(|attribution| attribution.transaction_id == transaction_id)
|
|
3965
4206
|
.cloned())
|
|
3966
4207
|
}
|
|
3967
4208
|
|
|
3968
|
-
///
|
|
4209
|
+
/// Canonical commit history in authority order, suitable for resumable
|
|
4210
|
+
/// event projection and historical provenance reconstruction.
|
|
4211
|
+
pub fn committed_transactions_after(
|
|
4212
|
+
&self,
|
|
4213
|
+
commit_revision: u64,
|
|
4214
|
+
limit: usize,
|
|
4215
|
+
) -> Result<Vec<TransactionAttribution>> {
|
|
4216
|
+
let inner = self.state();
|
|
4217
|
+
Ok(inner
|
|
4218
|
+
.transaction_history
|
|
4219
|
+
.iter()
|
|
4220
|
+
.filter(|transaction| transaction.commit_revision > commit_revision)
|
|
4221
|
+
.take(limit)
|
|
4222
|
+
.cloned()
|
|
4223
|
+
.collect())
|
|
4224
|
+
}
|
|
4225
|
+
|
|
4226
|
+
/// Durable commits that named this record, newest first.
|
|
3969
4227
|
///
|
|
3970
|
-
/// This
|
|
3971
|
-
///
|
|
3972
|
-
///
|
|
4228
|
+
/// This deliberately reads complete canonical history rather than the
|
|
4229
|
+
/// bounded operator window. Historical provenance must not disappear when
|
|
4230
|
+
/// a cache rolls over.
|
|
3973
4231
|
pub fn transactions_touching(
|
|
3974
4232
|
&self,
|
|
3975
4233
|
capability: &str,
|
|
@@ -3978,7 +4236,7 @@ impl FeltDb {
|
|
|
3978
4236
|
) -> Result<Vec<TransactionAttribution>> {
|
|
3979
4237
|
let inner = self.state();
|
|
3980
4238
|
Ok(inner
|
|
3981
|
-
.
|
|
4239
|
+
.transaction_history
|
|
3982
4240
|
.iter()
|
|
3983
4241
|
.rev()
|
|
3984
4242
|
.filter(|attribution| attribution.touched(capability, key))
|
|
@@ -4076,6 +4334,8 @@ impl FeltDb {
|
|
|
4076
4334
|
format_version: DURABLE_FORMAT_VERSION,
|
|
4077
4335
|
local_sequence: inner.sequence,
|
|
4078
4336
|
observed_versions: inner.change_log.versions(),
|
|
4337
|
+
transaction_history: inner.transaction_history.clone(),
|
|
4338
|
+
transaction_payload_hashes: inner.transaction_payload_hashes.clone(),
|
|
4079
4339
|
},
|
|
4080
4340
|
)?;
|
|
4081
4341
|
for operation in inner.change_log.pending_operations() {
|
|
@@ -4329,7 +4589,7 @@ impl Capability {
|
|
|
4329
4589
|
key
|
|
4330
4590
|
};
|
|
4331
4591
|
self.db
|
|
4332
|
-
.insert_internal(self.name.clone(), key.clone(), value)?;
|
|
4592
|
+
.insert_internal(self.name.clone(), key.clone(), value, false)?;
|
|
4333
4593
|
Ok(key)
|
|
4334
4594
|
}
|
|
4335
4595
|
|
|
@@ -4339,7 +4599,8 @@ impl Capability {
|
|
|
4339
4599
|
} else {
|
|
4340
4600
|
format!("{}:{key}", self.name)
|
|
4341
4601
|
};
|
|
4342
|
-
self.db
|
|
4602
|
+
self.db
|
|
4603
|
+
.insert_internal(self.name.clone(), full_key, value, false)
|
|
4343
4604
|
}
|
|
4344
4605
|
|
|
4345
4606
|
pub fn update<T: Serialize>(&self, key: &str, value: T) -> Result<()> {
|
|
@@ -4846,6 +5107,25 @@ fn replay_log(inner: &mut Inner) -> Result<()> {
|
|
|
4846
5107
|
.map_err(|error| corruption(line, &format!("malformed log header: {error}")))?;
|
|
4847
5108
|
inner.sequence = inner.sequence.max(header.local_sequence);
|
|
4848
5109
|
inner.change_log.observed_versions = header.observed_versions;
|
|
5110
|
+
for attribution in header.transaction_history {
|
|
5111
|
+
let attribution = normalize_transaction_attribution(attribution);
|
|
5112
|
+
inner.authority_revision = inner
|
|
5113
|
+
.authority_revision
|
|
5114
|
+
.max(attribution.commit_revision);
|
|
5115
|
+
inner
|
|
5116
|
+
.transaction_revisions
|
|
5117
|
+
.insert(
|
|
5118
|
+
attribution.transaction_id.clone(),
|
|
5119
|
+
(attribution.base_revision, attribution.commit_revision),
|
|
5120
|
+
);
|
|
5121
|
+
inner
|
|
5122
|
+
.applied_transactions
|
|
5123
|
+
.insert(attribution.transaction_id.clone());
|
|
5124
|
+
remember_attribution(inner, attribution);
|
|
5125
|
+
}
|
|
5126
|
+
inner
|
|
5127
|
+
.transaction_payload_hashes
|
|
5128
|
+
.extend(header.transaction_payload_hashes);
|
|
4849
5129
|
inner.bootstrapped = true;
|
|
4850
5130
|
snapshot_log = true;
|
|
4851
5131
|
continue;
|
|
@@ -4925,6 +5205,7 @@ fn replay_log(inner: &mut Inner) -> Result<()> {
|
|
|
4925
5205
|
}
|
|
4926
5206
|
let row: StoredRow = serde_json::from_value(value)
|
|
4927
5207
|
.map_err(|_| corruption(line, "not a durable record this build understands"))?;
|
|
5208
|
+
let derived_revision = row.capability == "state" && row.operation.is_none();
|
|
4928
5209
|
if let Some(operation) = row.operation.clone() {
|
|
4929
5210
|
// A revision is a deterministic function of the operation that
|
|
4930
5211
|
// produced it, so it never has to be lost. If the crash landed
|
|
@@ -4942,12 +5223,23 @@ fn replay_log(inner: &mut Inner) -> Result<()> {
|
|
|
4942
5223
|
inner.sync_state.merge_vector_clock(clock);
|
|
4943
5224
|
}
|
|
4944
5225
|
inner.change_log.add_operation(operation);
|
|
4945
|
-
} else if !snapshot_log {
|
|
5226
|
+
} else if !snapshot_log && !derived_revision {
|
|
4946
5227
|
// Pre-operation-envelope logs were produced only by this local
|
|
4947
5228
|
// store. Preserve a monotonic sequence when upgrading them.
|
|
4948
5229
|
inner.sequence += 1;
|
|
4949
5230
|
}
|
|
4950
|
-
|
|
5231
|
+
if !derived_revision {
|
|
5232
|
+
let base_revision = inner.authority_revision;
|
|
5233
|
+
inner.authority_revision += 1;
|
|
5234
|
+
if !snapshot_log {
|
|
5235
|
+
remember_operation_attribution(
|
|
5236
|
+
inner,
|
|
5237
|
+
&row,
|
|
5238
|
+
base_revision,
|
|
5239
|
+
base_revision + 1,
|
|
5240
|
+
);
|
|
5241
|
+
}
|
|
5242
|
+
}
|
|
4951
5243
|
if let Some(id) = row
|
|
4952
5244
|
.key
|
|
4953
5245
|
.rsplit_once(':')
|
|
@@ -5292,13 +5584,232 @@ mod tests {
|
|
|
5292
5584
|
format!("tx_{}", commits - 3),
|
|
5293
5585
|
]
|
|
5294
5586
|
);
|
|
5295
|
-
// The oldest
|
|
5296
|
-
//
|
|
5297
|
-
assert!(db
|
|
5587
|
+
// The oldest commit rolled out of the recent window but remains
|
|
5588
|
+
// addressable through canonical history.
|
|
5589
|
+
assert!(db
|
|
5590
|
+
.transaction_attribution("tx_0")
|
|
5591
|
+
.expect("lookup")
|
|
5592
|
+
.is_some());
|
|
5593
|
+
drop(db);
|
|
5594
|
+
let _ = fs::remove_file(path);
|
|
5595
|
+
}
|
|
5596
|
+
|
|
5597
|
+
#[test]
|
|
5598
|
+
fn canonical_transaction_history_survives_window_restart_and_compaction() {
|
|
5599
|
+
let path = temp_file();
|
|
5600
|
+
let db = open(&path).expect("open");
|
|
5601
|
+
let commits = TRANSACTION_ATTRIBUTION_WINDOW + 1;
|
|
5602
|
+
for index in 0..commits {
|
|
5603
|
+
db.apply_atomic_transaction(
|
|
5604
|
+
&format!("history_tx_{index}"),
|
|
5605
|
+
None,
|
|
5606
|
+
&[],
|
|
5607
|
+
&[AtomicMutation {
|
|
5608
|
+
capability: "history:Note".into(),
|
|
5609
|
+
key: format!("note_{index}"),
|
|
5610
|
+
value: Some(serde_json::json!({"index": index})),
|
|
5611
|
+
}],
|
|
5612
|
+
Some(serde_json::json!({"subject": "agent", "work_id": "work-1"})),
|
|
5613
|
+
)
|
|
5614
|
+
.expect("commit");
|
|
5615
|
+
}
|
|
5616
|
+
let complete = db.committed_transactions_after(0, usize::MAX).unwrap();
|
|
5617
|
+
assert_eq!(complete.len(), commits);
|
|
5618
|
+
assert_eq!(complete[0].transaction_id, "history_tx_0");
|
|
5619
|
+
assert_eq!(complete[0].operation_ids.len(), 1);
|
|
5620
|
+
assert_ne!(complete[0].operation_ids[0], complete[0].transaction_id);
|
|
5621
|
+
assert_eq!(complete[0].work_id.as_deref(), Some("work-1"));
|
|
5622
|
+
assert_eq!(
|
|
5623
|
+
db.transactions_touching("history:Note", "note_0", 1)
|
|
5624
|
+
.unwrap()[0]
|
|
5625
|
+
.transaction_id,
|
|
5626
|
+
"history_tx_0"
|
|
5627
|
+
);
|
|
5628
|
+
assert_eq!(
|
|
5629
|
+
db.transaction_attribution("history_tx_0")
|
|
5630
|
+
.unwrap()
|
|
5631
|
+
.unwrap()
|
|
5632
|
+
.audit
|
|
5633
|
+
.unwrap()["work_id"],
|
|
5634
|
+
"work-1"
|
|
5635
|
+
);
|
|
5636
|
+
let versions = db.operation_versions().unwrap();
|
|
5637
|
+
db.acknowledge_peer_versions("history-peer".into(), versions)
|
|
5638
|
+
.unwrap();
|
|
5639
|
+
db.compact_operation_log(&["history-peer".into()]).unwrap();
|
|
5640
|
+
drop(db);
|
|
5641
|
+
|
|
5642
|
+
let reopened = open(&path).expect("reopen");
|
|
5643
|
+
assert_eq!(
|
|
5644
|
+
reopened
|
|
5645
|
+
.committed_transactions_after(0, usize::MAX)
|
|
5646
|
+
.unwrap()
|
|
5647
|
+
.len(),
|
|
5648
|
+
commits
|
|
5649
|
+
);
|
|
5650
|
+
assert!(reopened
|
|
5651
|
+
.transaction_attribution("history_tx_0")
|
|
5652
|
+
.unwrap()
|
|
5653
|
+
.is_some());
|
|
5654
|
+
assert_eq!(
|
|
5655
|
+
reopened
|
|
5656
|
+
.transactions_touching("history:Note", "note_0", 1)
|
|
5657
|
+
.unwrap()[0]
|
|
5658
|
+
.transaction_id,
|
|
5659
|
+
"history_tx_0"
|
|
5660
|
+
);
|
|
5661
|
+
assert_eq!(
|
|
5662
|
+
reopened
|
|
5663
|
+
.transaction_attribution("history_tx_0")
|
|
5664
|
+
.unwrap()
|
|
5665
|
+
.unwrap()
|
|
5666
|
+
.operation_ids,
|
|
5667
|
+
complete[0].operation_ids
|
|
5668
|
+
);
|
|
5669
|
+
let _ = fs::remove_file(sync_metadata_path(&path));
|
|
5670
|
+
let _ = fs::remove_file(path);
|
|
5671
|
+
}
|
|
5672
|
+
|
|
5673
|
+
#[test]
|
|
5674
|
+
fn transaction_payload_binding_survives_restart_and_compaction() {
|
|
5675
|
+
let path = temp_file();
|
|
5676
|
+
let db = open(&path).expect("open");
|
|
5677
|
+
let mutation = AtomicMutation {
|
|
5678
|
+
capability: "managed:State".into(),
|
|
5679
|
+
key: "record-1".into(),
|
|
5680
|
+
value: Some(serde_json::json!({"value": 1})),
|
|
5681
|
+
};
|
|
5682
|
+
let first = db
|
|
5683
|
+
.apply_atomic_transaction_content_addressed(
|
|
5684
|
+
"managed-idempotency-1",
|
|
5685
|
+
Some("sha256:request-a"),
|
|
5686
|
+
None,
|
|
5687
|
+
&[],
|
|
5688
|
+
&[mutation.clone()],
|
|
5689
|
+
None,
|
|
5690
|
+
)
|
|
5691
|
+
.expect("initial commit");
|
|
5692
|
+
let duplicate = db
|
|
5693
|
+
.apply_atomic_transaction_content_addressed(
|
|
5694
|
+
"managed-idempotency-1",
|
|
5695
|
+
Some("sha256:request-a"),
|
|
5696
|
+
None,
|
|
5697
|
+
&[],
|
|
5698
|
+
&[mutation.clone()],
|
|
5699
|
+
None,
|
|
5700
|
+
)
|
|
5701
|
+
.expect("same semantic request");
|
|
5702
|
+
assert!(duplicate.duplicate);
|
|
5703
|
+
assert_eq!(duplicate.base_revision, first.base_revision);
|
|
5704
|
+
assert_eq!(duplicate.commit_revision, first.commit_revision);
|
|
5705
|
+
assert_eq!(duplicate.state_before, first.state_before);
|
|
5706
|
+
assert_eq!(duplicate.state_after, first.state_after);
|
|
5707
|
+
|
|
5708
|
+
let versions = db.operation_versions().unwrap();
|
|
5709
|
+
db.acknowledge_peer_versions("managed-peer".into(), versions)
|
|
5710
|
+
.unwrap();
|
|
5711
|
+
db.compact_operation_log(&["managed-peer".into()]).unwrap();
|
|
5298
5712
|
drop(db);
|
|
5713
|
+
|
|
5714
|
+
let reopened = open(&path).expect("restart");
|
|
5715
|
+
let replay = reopened
|
|
5716
|
+
.apply_atomic_transaction_content_addressed(
|
|
5717
|
+
"managed-idempotency-1",
|
|
5718
|
+
Some("sha256:request-a"),
|
|
5719
|
+
None,
|
|
5720
|
+
&[],
|
|
5721
|
+
&[mutation.clone()],
|
|
5722
|
+
None,
|
|
5723
|
+
)
|
|
5724
|
+
.expect("retry after restart");
|
|
5725
|
+
assert!(replay.duplicate);
|
|
5726
|
+
assert_eq!(replay.commit_revision, first.commit_revision);
|
|
5727
|
+
let conflict = reopened
|
|
5728
|
+
.apply_atomic_transaction_content_addressed(
|
|
5729
|
+
"managed-idempotency-1",
|
|
5730
|
+
Some("sha256:request-b"),
|
|
5731
|
+
None,
|
|
5732
|
+
&[],
|
|
5733
|
+
&[mutation],
|
|
5734
|
+
None,
|
|
5735
|
+
)
|
|
5736
|
+
.unwrap_err();
|
|
5737
|
+
assert!(conflict
|
|
5738
|
+
.to_string()
|
|
5739
|
+
.contains("TRANSACTION_ID_PAYLOAD_MISMATCH:"));
|
|
5740
|
+
let _ = fs::remove_file(sync_metadata_path(&path));
|
|
5299
5741
|
let _ = fs::remove_file(path);
|
|
5300
5742
|
}
|
|
5301
5743
|
|
|
5744
|
+
#[test]
|
|
5745
|
+
fn single_record_commits_are_canonical_history_positions() {
|
|
5746
|
+
let path = temp_file();
|
|
5747
|
+
let db = open(&path).unwrap();
|
|
5748
|
+
db.insert("orders:one", serde_json::json!({"status":"paid"}))
|
|
5749
|
+
.unwrap();
|
|
5750
|
+
let history = db.committed_transactions_after(0, 10).unwrap();
|
|
5751
|
+
assert_eq!(history.len(), 1);
|
|
5752
|
+
assert!(history[0].transaction_id.starts_with("transaction:"));
|
|
5753
|
+
assert!(history[0].operation_ids[0].starts_with("operation:"));
|
|
5754
|
+
assert_ne!(history[0].transaction_id, history[0].operation_ids[0]);
|
|
5755
|
+
assert_eq!(history[0].keys, vec!["orders:orders:one"]);
|
|
5756
|
+
drop(db);
|
|
5757
|
+
let reopened = open(&path).unwrap();
|
|
5758
|
+
assert_eq!(
|
|
5759
|
+
reopened.committed_transactions_after(0, 10).unwrap()[0].transaction_id,
|
|
5760
|
+
history[0].transaction_id
|
|
5761
|
+
);
|
|
5762
|
+
assert_eq!(
|
|
5763
|
+
reopened.committed_transactions_after(0, 10).unwrap()[0].operation_ids,
|
|
5764
|
+
history[0].operation_ids
|
|
5765
|
+
);
|
|
5766
|
+
let _ = fs::remove_file(path);
|
|
5767
|
+
}
|
|
5768
|
+
|
|
5769
|
+
#[test]
|
|
5770
|
+
fn compacted_legacy_attribution_gets_stable_distinct_operation_identity() {
|
|
5771
|
+
let legacy = TransactionAttribution {
|
|
5772
|
+
transaction_id: "legacy-transaction".into(),
|
|
5773
|
+
operation_ids: Vec::new(),
|
|
5774
|
+
subject: Some("agent-1".into()),
|
|
5775
|
+
tenant_id: Some("tenant-1".into()),
|
|
5776
|
+
application_id: Some("app-1".into()),
|
|
5777
|
+
application_revision: None,
|
|
5778
|
+
base_revision: 4,
|
|
5779
|
+
commit_revision: 5,
|
|
5780
|
+
state_before: 4,
|
|
5781
|
+
state_after: 5,
|
|
5782
|
+
keys: vec!["orders:one".into()],
|
|
5783
|
+
operations: 2,
|
|
5784
|
+
principal_id: None,
|
|
5785
|
+
agent_id: Some("agent-1".into()),
|
|
5786
|
+
session_id: None,
|
|
5787
|
+
delegator_id: None,
|
|
5788
|
+
work_id: None,
|
|
5789
|
+
unix_ms: 1,
|
|
5790
|
+
audit: None,
|
|
5791
|
+
};
|
|
5792
|
+
let first = normalize_transaction_attribution(legacy.clone());
|
|
5793
|
+
let second = normalize_transaction_attribution(legacy);
|
|
5794
|
+
assert_eq!(first, second);
|
|
5795
|
+
assert_eq!(first.transaction_id, "legacy-transaction");
|
|
5796
|
+
assert_eq!(first.operation_ids.len(), 2);
|
|
5797
|
+
assert!(first
|
|
5798
|
+
.operation_ids
|
|
5799
|
+
.iter()
|
|
5800
|
+
.all(|operation| operation.starts_with("operation:legacy-transaction:")));
|
|
5801
|
+
|
|
5802
|
+
let single = TransactionAttribution {
|
|
5803
|
+
transaction_id: "operation:authority:9".into(),
|
|
5804
|
+
operation_ids: Vec::new(),
|
|
5805
|
+
operations: 1,
|
|
5806
|
+
..first
|
|
5807
|
+
};
|
|
5808
|
+
let upgraded = normalize_transaction_attribution(single);
|
|
5809
|
+
assert_eq!(upgraded.transaction_id, "transaction:authority:9");
|
|
5810
|
+
assert_eq!(upgraded.operation_ids, vec!["operation:authority:9"]);
|
|
5811
|
+
}
|
|
5812
|
+
|
|
5302
5813
|
/// A bulk transaction names a bounded sample of keys and an exact count.
|
|
5303
5814
|
#[test]
|
|
5304
5815
|
fn a_bulk_transaction_truncates_its_key_list_and_keeps_the_count() {
|