create-feltdb 0.11.8 → 0.11.9
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/lib.rs +363 -2
- package/dist/server-source/crates/feltdb/src/read_basis.rs +257 -0
- package/dist/server-source/crates/feltdb/src/state_contract.rs +442 -73
- package/dist/server-source/crates/feltdb/src/workload_diagnostics.rs +18 -4
- package/dist/server-source/crates/feltdb-server/src/main.rs +198 -10
- package/package.json +1 -1
- package/dist/server-source/crates/feltdb-wasm/test.db +0 -0
- package/dist/server-source/crates/feltdb-wasm/test_wasm.db.lock +0 -0
package/dist/package-versions.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
1
|
// One release train keeps generated applications installable. The repository
|
|
2
2
|
// validation script checks these values against every workspace manifest.
|
|
3
|
-
export const FELTDB_PACKAGE_VERSION = '0.11.
|
|
3
|
+
export const FELTDB_PACKAGE_VERSION = '0.11.9';
|
|
4
4
|
export const feltdbPackageRange = `^${FELTDB_PACKAGE_VERSION}`;
|
|
@@ -101,6 +101,7 @@ pub mod production_api;
|
|
|
101
101
|
mod provenance;
|
|
102
102
|
pub mod query_execution_diagnostics;
|
|
103
103
|
pub mod query_performance;
|
|
104
|
+
pub mod read_basis;
|
|
104
105
|
mod references;
|
|
105
106
|
pub mod replay_fuzzing;
|
|
106
107
|
pub mod replica_acknowledgements;
|
|
@@ -674,6 +675,224 @@ impl RecordPrecondition {
|
|
|
674
675
|
}
|
|
675
676
|
}
|
|
676
677
|
|
|
678
|
+
/// State a decision was read from: records by identity, and equality
|
|
679
|
+
/// predicates over a collection (every record of `collection` whose fields
|
|
680
|
+
/// equal these values, including records that do not exist yet).
|
|
681
|
+
///
|
|
682
|
+
/// Generic: it names collections, ids, fields, and values, never what they mean.
|
|
683
|
+
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
|
|
684
|
+
pub struct ReadSet {
|
|
685
|
+
#[serde(default)]
|
|
686
|
+
pub records: Vec<ReadRecord>,
|
|
687
|
+
#[serde(default)]
|
|
688
|
+
pub predicates: Vec<ReadPredicate>,
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
|
692
|
+
pub struct ReadRecord {
|
|
693
|
+
pub collection: String,
|
|
694
|
+
pub id: String,
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
|
698
|
+
pub struct ReadPredicate {
|
|
699
|
+
pub collection: String,
|
|
700
|
+
#[serde(rename = "where")]
|
|
701
|
+
pub conditions: Vec<ReadCondition>,
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
|
705
|
+
pub struct ReadCondition {
|
|
706
|
+
pub field: String,
|
|
707
|
+
pub eq: Value,
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
/// A read set and what the authority held for it at one moment.
|
|
711
|
+
///
|
|
712
|
+
/// The token is opaque and meaningful only to the authority that issued it. A
|
|
713
|
+
/// transaction fenced by a basis commits only if re-reading the same set,
|
|
714
|
+
/// inside the commit's lock, yields the same token.
|
|
715
|
+
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
|
716
|
+
pub struct ReadBasis {
|
|
717
|
+
pub reads: ReadSet,
|
|
718
|
+
pub token: String,
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
/// Token prefix for read bases this authority issues.
|
|
722
|
+
pub const READ_BASIS_PREFIX: &str = "fr1:";
|
|
723
|
+
/// Largest number of records plus predicates one read set may name.
|
|
724
|
+
pub const READ_SET_LIMIT: usize = 1_000;
|
|
725
|
+
|
|
726
|
+
impl ReadSet {
|
|
727
|
+
/// Validate, de-duplicate, and order, so two descriptions of the same reads
|
|
728
|
+
/// are one read set and one token.
|
|
729
|
+
pub fn normalised(&self) -> Result<ReadSet> {
|
|
730
|
+
let valid = |segment: &str| {
|
|
731
|
+
!segment.is_empty()
|
|
732
|
+
&& segment.len() <= 128
|
|
733
|
+
&& segment
|
|
734
|
+
.bytes()
|
|
735
|
+
.all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'_' | b'-'))
|
|
736
|
+
};
|
|
737
|
+
let mut records: BTreeMap<(String, String), ReadRecord> = BTreeMap::new();
|
|
738
|
+
for record in &self.records {
|
|
739
|
+
if !valid(&record.collection) || !valid(&record.id) {
|
|
740
|
+
return Err(FlowError::CapabilityError(format!(
|
|
741
|
+
"INVALID_READ_SET:invalid read {}:{}",
|
|
742
|
+
record.collection, record.id
|
|
743
|
+
)));
|
|
744
|
+
}
|
|
745
|
+
records.insert(
|
|
746
|
+
(record.collection.clone(), record.id.clone()),
|
|
747
|
+
record.clone(),
|
|
748
|
+
);
|
|
749
|
+
}
|
|
750
|
+
let mut predicates: BTreeMap<(String, String), ReadPredicate> = BTreeMap::new();
|
|
751
|
+
for predicate in &self.predicates {
|
|
752
|
+
if !valid(&predicate.collection) || predicate.conditions.is_empty() {
|
|
753
|
+
return Err(FlowError::CapabilityError(format!(
|
|
754
|
+
"INVALID_READ_SET:a predicate over {} must name at least one field",
|
|
755
|
+
predicate.collection
|
|
756
|
+
)));
|
|
757
|
+
}
|
|
758
|
+
let mut conditions = predicate.conditions.clone();
|
|
759
|
+
if conditions
|
|
760
|
+
.iter()
|
|
761
|
+
.any(|condition| condition.field.is_empty())
|
|
762
|
+
{
|
|
763
|
+
return Err(FlowError::CapabilityError(
|
|
764
|
+
"INVALID_READ_SET:a predicate condition requires a field".into(),
|
|
765
|
+
));
|
|
766
|
+
}
|
|
767
|
+
conditions.sort_by(|left, right| left.field.cmp(&right.field));
|
|
768
|
+
let identity = serde_json::to_string(&conditions).unwrap_or_default();
|
|
769
|
+
predicates.insert(
|
|
770
|
+
(predicate.collection.clone(), identity),
|
|
771
|
+
ReadPredicate {
|
|
772
|
+
collection: predicate.collection.clone(),
|
|
773
|
+
conditions,
|
|
774
|
+
},
|
|
775
|
+
);
|
|
776
|
+
}
|
|
777
|
+
if records.is_empty() && predicates.is_empty() {
|
|
778
|
+
return Err(FlowError::CapabilityError(
|
|
779
|
+
"INVALID_READ_SET:a read set that names nothing fences nothing".into(),
|
|
780
|
+
));
|
|
781
|
+
}
|
|
782
|
+
if records.len() + predicates.len() > READ_SET_LIMIT {
|
|
783
|
+
return Err(FlowError::CapabilityError(format!(
|
|
784
|
+
"INVALID_READ_SET:a read set may name at most {READ_SET_LIMIT} records and predicates"
|
|
785
|
+
)));
|
|
786
|
+
}
|
|
787
|
+
Ok(ReadSet {
|
|
788
|
+
records: records.into_values().collect(),
|
|
789
|
+
predicates: predicates.into_values().collect(),
|
|
790
|
+
})
|
|
791
|
+
}
|
|
792
|
+
}
|
|
793
|
+
|
|
794
|
+
/// Where a read set's logical collections and ids live in storage.
|
|
795
|
+
///
|
|
796
|
+
/// One read set, one token semantics; only the storage addressing differs
|
|
797
|
+
/// between the routes that commit transactions.
|
|
798
|
+
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
799
|
+
pub enum ReadLayout {
|
|
800
|
+
/// The direct transaction route: rows under `collection`, keyed `collection:id`.
|
|
801
|
+
Direct,
|
|
802
|
+
/// An application state namespace: rows under `namespace:collection`, keyed by id.
|
|
803
|
+
Namespace(String),
|
|
804
|
+
}
|
|
805
|
+
|
|
806
|
+
impl ReadLayout {
|
|
807
|
+
fn capability(&self, collection: &str) -> String {
|
|
808
|
+
match self {
|
|
809
|
+
Self::Direct => collection.to_string(),
|
|
810
|
+
Self::Namespace(namespace) => format!("{namespace}:{collection}"),
|
|
811
|
+
}
|
|
812
|
+
}
|
|
813
|
+
|
|
814
|
+
fn key(&self, collection: &str, id: &str) -> String {
|
|
815
|
+
match self {
|
|
816
|
+
Self::Direct => format!("{collection}:{id}"),
|
|
817
|
+
Self::Namespace(_) => id.to_string(),
|
|
818
|
+
}
|
|
819
|
+
}
|
|
820
|
+
|
|
821
|
+
/// The authority scope a token is valid in. A token is never evaluated in
|
|
822
|
+
/// another scope: it is refused as invalid there, not compared.
|
|
823
|
+
fn scope_tag(&self) -> String {
|
|
824
|
+
match self {
|
|
825
|
+
Self::Direct => "d".into(),
|
|
826
|
+
Self::Namespace(namespace) => {
|
|
827
|
+
let digest = format!(
|
|
828
|
+
"{:x}",
|
|
829
|
+
Sha256::digest(format!("namespace\0{namespace}").as_bytes())
|
|
830
|
+
);
|
|
831
|
+
format!("n{}", &digest[..16])
|
|
832
|
+
}
|
|
833
|
+
}
|
|
834
|
+
}
|
|
835
|
+
|
|
836
|
+
fn token_prefix(&self) -> String {
|
|
837
|
+
format!("{READ_BASIS_PREFIX}{}:", self.scope_tag())
|
|
838
|
+
}
|
|
839
|
+
}
|
|
840
|
+
|
|
841
|
+
/// What the state a read set names is, now, as one token. Rows are read in
|
|
842
|
+
/// full; tombstoned rows are absent; only the named records and the named
|
|
843
|
+
/// collections' matching rows are read.
|
|
844
|
+
fn read_basis_token(
|
|
845
|
+
rows: &HashMap<String, BTreeMap<String, StoredRow>>,
|
|
846
|
+
reads: &ReadSet,
|
|
847
|
+
layout: &ReadLayout,
|
|
848
|
+
) -> String {
|
|
849
|
+
let live = |capability: &str, key: &str| {
|
|
850
|
+
rows.get(capability)
|
|
851
|
+
.and_then(|bucket| bucket.get(key))
|
|
852
|
+
.filter(|row| !row.deleted)
|
|
853
|
+
.map(|row| row.value.clone())
|
|
854
|
+
};
|
|
855
|
+
let records: Vec<Value> = reads
|
|
856
|
+
.records
|
|
857
|
+
.iter()
|
|
858
|
+
.map(|record| {
|
|
859
|
+
let value = live(
|
|
860
|
+
&layout.capability(&record.collection),
|
|
861
|
+
&layout.key(&record.collection, &record.id),
|
|
862
|
+
);
|
|
863
|
+
serde_json::json!([record.collection, record.id, value])
|
|
864
|
+
})
|
|
865
|
+
.collect();
|
|
866
|
+
let predicates: Vec<Value> = reads
|
|
867
|
+
.predicates
|
|
868
|
+
.iter()
|
|
869
|
+
.map(|predicate| {
|
|
870
|
+
let matching: Vec<Value> = rows
|
|
871
|
+
.get(&layout.capability(&predicate.collection))
|
|
872
|
+
.map(|bucket| {
|
|
873
|
+
bucket
|
|
874
|
+
.iter()
|
|
875
|
+
.filter(|(_, row)| !row.deleted)
|
|
876
|
+
.filter(|(_, row)| {
|
|
877
|
+
predicate.conditions.iter().all(|condition| {
|
|
878
|
+
row.value.get(&condition.field) == Some(&condition.eq)
|
|
879
|
+
})
|
|
880
|
+
})
|
|
881
|
+
.map(|(key, row)| serde_json::json!([key, row.value]))
|
|
882
|
+
.collect()
|
|
883
|
+
})
|
|
884
|
+
.unwrap_or_default();
|
|
885
|
+
serde_json::json!([predicate.collection, predicate.conditions, matching])
|
|
886
|
+
})
|
|
887
|
+
.collect();
|
|
888
|
+
let observed = serde_json::json!({ "records": records, "predicates": predicates });
|
|
889
|
+
format!(
|
|
890
|
+
"{}{:x}",
|
|
891
|
+
layout.token_prefix(),
|
|
892
|
+
Sha256::digest(serde_json::to_vec(&observed).unwrap_or_default())
|
|
893
|
+
)
|
|
894
|
+
}
|
|
895
|
+
|
|
677
896
|
/// Which predicate failed, and what the authority actually holds.
|
|
678
897
|
///
|
|
679
898
|
/// Structured rather than a formatted string, because the caller has to tell a
|
|
@@ -704,6 +923,18 @@ pub enum PreconditionFailure {
|
|
|
704
923
|
expected: String,
|
|
705
924
|
actual: Option<String>,
|
|
706
925
|
},
|
|
926
|
+
/// State named by a read basis changed after the basis was read.
|
|
927
|
+
///
|
|
928
|
+
/// The tokens are kept for diagnostics inside the authority but never
|
|
929
|
+
/// serialized: a refusal says that the basis changed, not what it digests.
|
|
930
|
+
Fence {
|
|
931
|
+
collection: String,
|
|
932
|
+
key: String,
|
|
933
|
+
#[serde(skip_serializing, default)]
|
|
934
|
+
expected: String,
|
|
935
|
+
#[serde(skip_serializing, default)]
|
|
936
|
+
actual: String,
|
|
937
|
+
},
|
|
707
938
|
}
|
|
708
939
|
|
|
709
940
|
impl PreconditionFailure {
|
|
@@ -713,7 +944,8 @@ impl PreconditionFailure {
|
|
|
713
944
|
| Self::Missing { collection, .. }
|
|
714
945
|
| Self::Version { collection, .. }
|
|
715
946
|
| Self::Epoch { collection, .. }
|
|
716
|
-
| Self::Lease { collection, .. }
|
|
947
|
+
| Self::Lease { collection, .. }
|
|
948
|
+
| Self::Fence { collection, .. } => collection,
|
|
717
949
|
}
|
|
718
950
|
}
|
|
719
951
|
/// The storage key, which is namespaced as `collection:id`.
|
|
@@ -723,7 +955,8 @@ impl PreconditionFailure {
|
|
|
723
955
|
| Self::Missing { key, .. }
|
|
724
956
|
| Self::Version { key, .. }
|
|
725
957
|
| Self::Epoch { key, .. }
|
|
726
|
-
| Self::Lease { key, .. }
|
|
958
|
+
| Self::Lease { key, .. }
|
|
959
|
+
| Self::Fence { key, .. } => key,
|
|
727
960
|
}
|
|
728
961
|
}
|
|
729
962
|
}
|
|
@@ -762,6 +995,10 @@ impl Display for PreconditionFailure {
|
|
|
762
995
|
f,
|
|
763
996
|
"{collection}/{key} holds lease {actual:?}, expected {expected}"
|
|
764
997
|
),
|
|
998
|
+
Self::Fence { collection, .. } => write!(
|
|
999
|
+
f,
|
|
1000
|
+
"state read by this transaction has changed (read basis over {collection})"
|
|
1001
|
+
),
|
|
765
1002
|
}
|
|
766
1003
|
}
|
|
767
1004
|
}
|
|
@@ -2863,6 +3100,7 @@ impl FeltDb {
|
|
|
2863
3100
|
/// the same id once it has re-read state, and a caller that wants the
|
|
2864
3101
|
/// retry to be a distinct decision may use a new one. Both work; the choice
|
|
2865
3102
|
/// is the caller's and is asserted in the tests rather than left implied.
|
|
3103
|
+
#[allow(clippy::too_many_arguments)]
|
|
2866
3104
|
pub fn apply_atomic_transaction_guarded(
|
|
2867
3105
|
&self,
|
|
2868
3106
|
transaction_id: &str,
|
|
@@ -2872,6 +3110,78 @@ impl FeltDb {
|
|
|
2872
3110
|
record_preconditions: &[RecordPrecondition],
|
|
2873
3111
|
mutations: &[AtomicMutation],
|
|
2874
3112
|
audit: Option<Value>,
|
|
3113
|
+
) -> Result<AtomicCommit> {
|
|
3114
|
+
self.apply_atomic_transaction_fenced(
|
|
3115
|
+
transaction_id,
|
|
3116
|
+
payload_hash,
|
|
3117
|
+
expected_parent,
|
|
3118
|
+
preconditions,
|
|
3119
|
+
record_preconditions,
|
|
3120
|
+
&[],
|
|
3121
|
+
mutations,
|
|
3122
|
+
audit,
|
|
3123
|
+
)
|
|
3124
|
+
}
|
|
3125
|
+
|
|
3126
|
+
/// Capture a read basis on the direct route, under the same lock every commit takes.
|
|
3127
|
+
pub fn read_basis(&self, reads: &ReadSet) -> Result<ReadBasis> {
|
|
3128
|
+
self.read_basis_in(reads, &ReadLayout::Direct)
|
|
3129
|
+
}
|
|
3130
|
+
|
|
3131
|
+
/// Capture a read basis in a storage layout, under the same lock every commit takes.
|
|
3132
|
+
pub fn read_basis_in(&self, reads: &ReadSet, layout: &ReadLayout) -> Result<ReadBasis> {
|
|
3133
|
+
let reads = reads.normalised()?;
|
|
3134
|
+
let inner = self.state();
|
|
3135
|
+
let token = read_basis_token(&inner.rows, &reads, layout);
|
|
3136
|
+
Ok(ReadBasis { reads, token })
|
|
3137
|
+
}
|
|
3138
|
+
|
|
3139
|
+
/// `apply_atomic_transaction_guarded`, additionally fenced by read bases.
|
|
3140
|
+
///
|
|
3141
|
+
/// Each basis is re-evaluated inside the same lock as the writes, after the
|
|
3142
|
+
/// record preconditions and before anything is staged. If the state it
|
|
3143
|
+
/// names differs from what was read -- whichever write path changed it --
|
|
3144
|
+
/// the transaction fails with `PreconditionFailed(Fence)` and applies
|
|
3145
|
+
/// nothing. State outside every basis is never read and never conflicts.
|
|
3146
|
+
#[allow(clippy::too_many_arguments)]
|
|
3147
|
+
pub fn apply_atomic_transaction_fenced(
|
|
3148
|
+
&self,
|
|
3149
|
+
transaction_id: &str,
|
|
3150
|
+
payload_hash: Option<&str>,
|
|
3151
|
+
expected_parent: Option<u64>,
|
|
3152
|
+
preconditions: &[AtomicPrecondition],
|
|
3153
|
+
record_preconditions: &[RecordPrecondition],
|
|
3154
|
+
fences: &[ReadBasis],
|
|
3155
|
+
mutations: &[AtomicMutation],
|
|
3156
|
+
audit: Option<Value>,
|
|
3157
|
+
) -> Result<AtomicCommit> {
|
|
3158
|
+
self.apply_atomic_transaction_fenced_in(
|
|
3159
|
+
transaction_id,
|
|
3160
|
+
payload_hash,
|
|
3161
|
+
expected_parent,
|
|
3162
|
+
preconditions,
|
|
3163
|
+
record_preconditions,
|
|
3164
|
+
&ReadLayout::Direct,
|
|
3165
|
+
fences,
|
|
3166
|
+
mutations,
|
|
3167
|
+
audit,
|
|
3168
|
+
)
|
|
3169
|
+
}
|
|
3170
|
+
|
|
3171
|
+
/// `apply_atomic_transaction_fenced`, with the read bases addressed in
|
|
3172
|
+
/// `layout`. The same evaluation, inside the same lock, for every route.
|
|
3173
|
+
#[allow(clippy::too_many_arguments)]
|
|
3174
|
+
pub fn apply_atomic_transaction_fenced_in(
|
|
3175
|
+
&self,
|
|
3176
|
+
transaction_id: &str,
|
|
3177
|
+
payload_hash: Option<&str>,
|
|
3178
|
+
expected_parent: Option<u64>,
|
|
3179
|
+
preconditions: &[AtomicPrecondition],
|
|
3180
|
+
record_preconditions: &[RecordPrecondition],
|
|
3181
|
+
layout: &ReadLayout,
|
|
3182
|
+
fences: &[ReadBasis],
|
|
3183
|
+
mutations: &[AtomicMutation],
|
|
3184
|
+
audit: Option<Value>,
|
|
2875
3185
|
) -> Result<AtomicCommit> {
|
|
2876
3186
|
let (commit, events) = {
|
|
2877
3187
|
let mut inner = self.state();
|
|
@@ -3004,6 +3314,38 @@ impl FeltDb {
|
|
|
3004
3314
|
}
|
|
3005
3315
|
}
|
|
3006
3316
|
|
|
3317
|
+
for fence in fences {
|
|
3318
|
+
if !fence.token.starts_with(&layout.token_prefix()) {
|
|
3319
|
+
return Err(FlowError::CapabilityError(
|
|
3320
|
+
"INVALID_READ_SET:a read basis was not issued by this authority scope"
|
|
3321
|
+
.into(),
|
|
3322
|
+
));
|
|
3323
|
+
}
|
|
3324
|
+
let reads = fence.reads.normalised()?;
|
|
3325
|
+
let actual = read_basis_token(&inner.rows, &reads, layout);
|
|
3326
|
+
if actual != fence.token {
|
|
3327
|
+
let collection = reads
|
|
3328
|
+
.records
|
|
3329
|
+
.first()
|
|
3330
|
+
.map(|record| record.collection.clone())
|
|
3331
|
+
.or_else(|| {
|
|
3332
|
+
reads
|
|
3333
|
+
.predicates
|
|
3334
|
+
.first()
|
|
3335
|
+
.map(|predicate| predicate.collection.clone())
|
|
3336
|
+
})
|
|
3337
|
+
.unwrap_or_default();
|
|
3338
|
+
return Err(FlowError::PreconditionFailed(Box::new(
|
|
3339
|
+
PreconditionFailure::Fence {
|
|
3340
|
+
collection,
|
|
3341
|
+
key: "read-basis".into(),
|
|
3342
|
+
expected: fence.token.clone(),
|
|
3343
|
+
actual,
|
|
3344
|
+
},
|
|
3345
|
+
)));
|
|
3346
|
+
}
|
|
3347
|
+
}
|
|
3348
|
+
|
|
3007
3349
|
// Every precondition held. The authority owns both ends of the
|
|
3008
3350
|
// public document-version lifecycle inside this lock:
|
|
3009
3351
|
//
|
|
@@ -3977,6 +4319,24 @@ impl FeltDb {
|
|
|
3977
4319
|
.cloned())
|
|
3978
4320
|
}
|
|
3979
4321
|
|
|
4322
|
+
/// Fetch several live collection records without materializing collection state.
|
|
4323
|
+
pub fn get_collection_records<'a>(
|
|
4324
|
+
&self,
|
|
4325
|
+
capability: &str,
|
|
4326
|
+
keys: impl IntoIterator<Item = &'a str>,
|
|
4327
|
+
) -> Result<Vec<StoredRow>> {
|
|
4328
|
+
let inner = self.state();
|
|
4329
|
+
let Some(rows) = inner.rows.get(capability) else {
|
|
4330
|
+
return Ok(Vec::new());
|
|
4331
|
+
};
|
|
4332
|
+
Ok(keys
|
|
4333
|
+
.into_iter()
|
|
4334
|
+
.filter_map(|key| rows.get(key))
|
|
4335
|
+
.filter(|row| !row.deleted)
|
|
4336
|
+
.cloned()
|
|
4337
|
+
.collect())
|
|
4338
|
+
}
|
|
4339
|
+
|
|
3980
4340
|
/// Subscribe to canonical mutation events.
|
|
3981
4341
|
pub fn subscribe_changes(&self) -> broadcast::Receiver<ChangeEvent> {
|
|
3982
4342
|
self.event_tx.subscribe()
|
|
@@ -4065,6 +4425,7 @@ impl FeltDb {
|
|
|
4065
4425
|
/// Selecting the capability buckets before cloning prevents one application's
|
|
4066
4426
|
/// read snapshot from materializing every other application's durable rows.
|
|
4067
4427
|
pub fn state_rows_for_namespace(&self, state_namespace: &str) -> Result<Vec<StoredRow>> {
|
|
4428
|
+
let _span = workload_diagnostics::span(workload_diagnostics::Phase::NamespaceSnapshot);
|
|
4068
4429
|
let inner = self.state();
|
|
4069
4430
|
let prefix = format!("{state_namespace}:");
|
|
4070
4431
|
Ok(inner
|
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
//! Read-basis fences: commit only if the state a decision read is unchanged.
|
|
2
|
+
//!
|
|
3
|
+
//! ```text
|
|
4
|
+
//! basis = read_basis(records, predicates) -- one token, under the lock
|
|
5
|
+
//! ... decide ...
|
|
6
|
+
//! commit fenced by basis -- re-read under the commit lock
|
|
7
|
+
//! unchanged -> commit
|
|
8
|
+
//! changed -> PreconditionFailed(Fence), ZERO writes
|
|
9
|
+
//! ```
|
|
10
|
+
//!
|
|
11
|
+
//! The fence reads stored rows, not bookkeeping a writer maintains, so it holds
|
|
12
|
+
//! whichever write path changed them. Only the named records and the named
|
|
13
|
+
//! collections' matching rows are read: everything else is outside the fence
|
|
14
|
+
//! and never conflicts.
|
|
15
|
+
|
|
16
|
+
#[cfg(test)]
|
|
17
|
+
mod tests {
|
|
18
|
+
use crate::{
|
|
19
|
+
AtomicMutation, FeltDb, FlowError, PreconditionFailure, ReadBasis, ReadCondition,
|
|
20
|
+
ReadLayout, ReadPredicate, ReadRecord, ReadSet,
|
|
21
|
+
};
|
|
22
|
+
use serde_json::json;
|
|
23
|
+
use tempfile::TempDir;
|
|
24
|
+
|
|
25
|
+
fn db(dir: &TempDir) -> FeltDb {
|
|
26
|
+
FeltDb::open(dir.path().join("app.log")).expect("open")
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
fn set(collection: &str, id: &str, value: serde_json::Value) -> AtomicMutation {
|
|
30
|
+
AtomicMutation {
|
|
31
|
+
capability: collection.to_string(),
|
|
32
|
+
key: format!("{collection}:{id}"),
|
|
33
|
+
value: Some(value),
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
fn delete(collection: &str, id: &str) -> AtomicMutation {
|
|
38
|
+
AtomicMutation {
|
|
39
|
+
capability: collection.to_string(),
|
|
40
|
+
key: format!("{collection}:{id}"),
|
|
41
|
+
value: None,
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
fn write(db: &FeltDb, id: &str, mutations: &[AtomicMutation]) {
|
|
46
|
+
db.apply_atomic_transaction(id, None, &[], mutations, None)
|
|
47
|
+
.expect("write commits");
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
fn reads() -> ReadSet {
|
|
51
|
+
ReadSet {
|
|
52
|
+
records: vec![ReadRecord {
|
|
53
|
+
collection: "A".into(),
|
|
54
|
+
id: "a1".into(),
|
|
55
|
+
}],
|
|
56
|
+
predicates: vec![ReadPredicate {
|
|
57
|
+
collection: "B".into(),
|
|
58
|
+
conditions: vec![ReadCondition {
|
|
59
|
+
field: "a".into(),
|
|
60
|
+
eq: json!("a1"),
|
|
61
|
+
}],
|
|
62
|
+
}],
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
fn seed(db: &FeltDb) {
|
|
67
|
+
write(
|
|
68
|
+
db,
|
|
69
|
+
"seed",
|
|
70
|
+
&[
|
|
71
|
+
set("A", "a1", json!({ "id": "a1", "n": 1 })),
|
|
72
|
+
set("B", "b1", json!({ "id": "b1", "a": "a1" })),
|
|
73
|
+
set("B", "b2", json!({ "id": "b2", "a": "a2" })),
|
|
74
|
+
],
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
fn fenced(db: &FeltDb, id: &str, basis: &ReadBasis) -> Result<crate::AtomicCommit, FlowError> {
|
|
79
|
+
db.apply_atomic_transaction_fenced(
|
|
80
|
+
id,
|
|
81
|
+
None,
|
|
82
|
+
None,
|
|
83
|
+
&[],
|
|
84
|
+
&[],
|
|
85
|
+
std::slice::from_ref(basis),
|
|
86
|
+
&[set("Z", id, json!({ "id": id }))],
|
|
87
|
+
None,
|
|
88
|
+
)
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
fn is_fence_conflict(result: Result<crate::AtomicCommit, FlowError>) -> bool {
|
|
92
|
+
matches!(result, Err(FlowError::PreconditionFailed(failure)) if matches!(*failure, PreconditionFailure::Fence { .. }))
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
#[test]
|
|
96
|
+
fn an_unchanged_basis_commits_and_a_changed_one_commits_nothing() {
|
|
97
|
+
let dir = TempDir::new().unwrap();
|
|
98
|
+
let db = db(&dir);
|
|
99
|
+
seed(&db);
|
|
100
|
+
let basis = db.read_basis(&reads()).unwrap();
|
|
101
|
+
assert!(fenced(&db, "z1", &basis).is_ok());
|
|
102
|
+
write(&db, "raw", &[set("A", "a1", json!({ "id": "a1", "n": 2 }))]);
|
|
103
|
+
assert!(is_fence_conflict(fenced(&db, "z2", &basis)));
|
|
104
|
+
assert!(
|
|
105
|
+
db.get_value("Z:z2").unwrap().is_none(),
|
|
106
|
+
"a refused transaction writes nothing"
|
|
107
|
+
);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
#[test]
|
|
111
|
+
fn records_entering_or_leaving_a_predicate_conflict_and_others_do_not() {
|
|
112
|
+
let dir = TempDir::new().unwrap();
|
|
113
|
+
let db = db(&dir);
|
|
114
|
+
seed(&db);
|
|
115
|
+
let basis = db.read_basis(&reads()).unwrap();
|
|
116
|
+
write(
|
|
117
|
+
&db,
|
|
118
|
+
"outside",
|
|
119
|
+
&[
|
|
120
|
+
set("B", "b3", json!({ "id": "b3", "a": "a9" })),
|
|
121
|
+
set("C", "c1", json!({ "id": "c1" })),
|
|
122
|
+
],
|
|
123
|
+
);
|
|
124
|
+
assert!(
|
|
125
|
+
fenced(&db, "z1", &basis).is_ok(),
|
|
126
|
+
"writes outside the read set never conflict"
|
|
127
|
+
);
|
|
128
|
+
|
|
129
|
+
write(
|
|
130
|
+
&db,
|
|
131
|
+
"enter",
|
|
132
|
+
&[set("B", "b4", json!({ "id": "b4", "a": "a1" }))],
|
|
133
|
+
);
|
|
134
|
+
assert!(is_fence_conflict(fenced(&db, "z2", &basis)));
|
|
135
|
+
|
|
136
|
+
let basis = db.read_basis(&reads()).unwrap();
|
|
137
|
+
write(
|
|
138
|
+
&db,
|
|
139
|
+
"leave",
|
|
140
|
+
&[set("B", "b1", json!({ "id": "b1", "a": "a2" }))],
|
|
141
|
+
);
|
|
142
|
+
assert!(is_fence_conflict(fenced(&db, "z3", &basis)));
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
#[test]
|
|
146
|
+
fn delete_and_recreate_with_different_content_conflicts() {
|
|
147
|
+
let dir = TempDir::new().unwrap();
|
|
148
|
+
let db = db(&dir);
|
|
149
|
+
seed(&db);
|
|
150
|
+
let basis = db.read_basis(&reads()).unwrap();
|
|
151
|
+
write(&db, "delete", &[delete("A", "a1")]);
|
|
152
|
+
write(
|
|
153
|
+
&db,
|
|
154
|
+
"recreate",
|
|
155
|
+
&[set("A", "a1", json!({ "id": "a1", "n": 7 }))],
|
|
156
|
+
);
|
|
157
|
+
assert!(is_fence_conflict(fenced(&db, "z1", &basis)));
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
#[test]
|
|
161
|
+
fn tokens_are_deterministic_and_survive_restart() {
|
|
162
|
+
let dir = TempDir::new().unwrap();
|
|
163
|
+
let basis = {
|
|
164
|
+
let db = db(&dir);
|
|
165
|
+
seed(&db);
|
|
166
|
+
let first = db.read_basis(&reads()).unwrap();
|
|
167
|
+
let mut shuffled = reads();
|
|
168
|
+
shuffled.records.push(ReadRecord {
|
|
169
|
+
collection: "A".into(),
|
|
170
|
+
id: "a1".into(),
|
|
171
|
+
});
|
|
172
|
+
assert_eq!(
|
|
173
|
+
first,
|
|
174
|
+
db.read_basis(&shuffled).unwrap(),
|
|
175
|
+
"normalised: duplicates do not matter"
|
|
176
|
+
);
|
|
177
|
+
first
|
|
178
|
+
};
|
|
179
|
+
let db = db(&dir);
|
|
180
|
+
assert_eq!(db.read_basis(&reads()).unwrap().token, basis.token);
|
|
181
|
+
assert!(fenced(&db, "z1", &basis).is_ok());
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
#[test]
|
|
185
|
+
fn invalid_and_foreign_bases_are_refused_as_invalid_not_as_conflicts() {
|
|
186
|
+
let dir = TempDir::new().unwrap();
|
|
187
|
+
let db = db(&dir);
|
|
188
|
+
seed(&db);
|
|
189
|
+
assert!(db.read_basis(&ReadSet::default()).is_err());
|
|
190
|
+
let mut foreign = db.read_basis(&reads()).unwrap();
|
|
191
|
+
foreign.token = format!("fb1:{}", "0".repeat(64));
|
|
192
|
+
let result = fenced(&db, "z1", &foreign);
|
|
193
|
+
assert!(
|
|
194
|
+
matches!(result, Err(FlowError::CapabilityError(ref message)) if message.starts_with("INVALID_READ_SET:"))
|
|
195
|
+
);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
#[test]
|
|
199
|
+
fn namespace_bases_read_namespace_rows_and_are_scoped_to_their_namespace() {
|
|
200
|
+
let dir = TempDir::new().unwrap();
|
|
201
|
+
let db = db(&dir);
|
|
202
|
+
let ns = ReadLayout::Namespace("tenant-a".into());
|
|
203
|
+
let row = |collection: &str, id: &str, value: serde_json::Value| AtomicMutation {
|
|
204
|
+
capability: format!("tenant-a:{collection}"),
|
|
205
|
+
key: id.to_string(),
|
|
206
|
+
value: Some(value),
|
|
207
|
+
};
|
|
208
|
+
write(
|
|
209
|
+
&db,
|
|
210
|
+
"seed",
|
|
211
|
+
&[
|
|
212
|
+
row("A", "a1", json!({ "id": "a1", "n": 1 })),
|
|
213
|
+
row("B", "b1", json!({ "id": "b1", "a": "a1" })),
|
|
214
|
+
],
|
|
215
|
+
);
|
|
216
|
+
let basis = db.read_basis_in(&reads(), &ns).unwrap();
|
|
217
|
+
assert!(basis.token.starts_with(&ns.token_prefix()));
|
|
218
|
+
let commit = |id: &str, layout: &ReadLayout, basis: &ReadBasis| {
|
|
219
|
+
db.apply_atomic_transaction_fenced_in(
|
|
220
|
+
id,
|
|
221
|
+
None,
|
|
222
|
+
None,
|
|
223
|
+
&[],
|
|
224
|
+
&[],
|
|
225
|
+
layout,
|
|
226
|
+
std::slice::from_ref(basis),
|
|
227
|
+
&[row("Z", id, json!({ "id": id }))],
|
|
228
|
+
None,
|
|
229
|
+
)
|
|
230
|
+
};
|
|
231
|
+
let invalid = |result: Result<crate::AtomicCommit, FlowError>| matches!(result, Err(FlowError::CapabilityError(ref message)) if message.starts_with("INVALID_READ_SET:"));
|
|
232
|
+
// A token from another scope is invalid, never compared.
|
|
233
|
+
assert!(invalid(commit("z0", &ReadLayout::Direct, &basis)));
|
|
234
|
+
assert!(invalid(commit(
|
|
235
|
+
"z0",
|
|
236
|
+
&ReadLayout::Namespace("tenant-b".into()),
|
|
237
|
+
&basis
|
|
238
|
+
)));
|
|
239
|
+
let direct = db.read_basis(&reads()).unwrap();
|
|
240
|
+
assert!(invalid(commit("z0", &ns, &direct)));
|
|
241
|
+
// Direct-layout writes to the same names are outside the namespace.
|
|
242
|
+
write(
|
|
243
|
+
&db,
|
|
244
|
+
"direct",
|
|
245
|
+
&[set("A", "a1", json!({ "id": "a1", "n": 9 }))],
|
|
246
|
+
);
|
|
247
|
+
assert!(commit("z1", &ns, &basis).is_ok());
|
|
248
|
+
// A namespace write the basis read refuses the commit.
|
|
249
|
+
write(
|
|
250
|
+
&db,
|
|
251
|
+
"raw",
|
|
252
|
+
&[row("B", "b9", json!({ "id": "b9", "a": "a1" }))],
|
|
253
|
+
);
|
|
254
|
+
let result = commit("z2", &ns, &basis);
|
|
255
|
+
assert!(is_fence_conflict(result));
|
|
256
|
+
}
|
|
257
|
+
}
|