@feltdb/core 0.8.3 → 0.8.4
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/cli/commands.js +4 -1
- package/dist/cli/provisioning-neutrality.js +79 -0
- package/dist/collection.d.ts +43 -1
- package/dist/collection.d.ts.map +1 -1
- package/dist/collection.js +192 -22
- package/dist/create/create.js +25 -21
- package/dist/create/managed-account.js +11 -0
- package/dist/create/package-versions.js +1 -1
- package/dist/create/server-source/crates/feltdb/src/equality_index.rs +595 -0
- package/dist/create/server-source/crates/feltdb/src/lib.rs +547 -115
- package/dist/create/server-source/crates/feltdb/src/phase1c3_acceptance.rs +11 -2
- package/dist/create/server-source/crates/feltdb/src/query_execution_diagnostics.rs +126 -0
- package/dist/create/server-source/crates/feltdb/src/state_contract.rs +292 -2
- package/dist/create/server-source/crates/feltdb/src/sync.rs +12 -0
- package/dist/create/server-source/crates/feltdb/src/workload_diagnostics.rs +443 -0
- package/dist/create/server-source/crates/feltdb/tests/pr34_query_collection.rs +233 -0
- package/dist/create/server-source/crates/feltdb/tests/pr35_equality_index.rs +892 -0
- package/dist/create/server-source/crates/feltdb-server/src/audit.rs +1137 -29
- package/dist/create/server-source/crates/feltdb-server/src/main.rs +474 -28
- package/dist/db.d.ts +33 -34
- package/dist/db.d.ts.map +1 -1
- package/dist/db.js +74 -20
- package/dist/deployment.d.ts +30 -0
- package/dist/deployment.d.ts.map +1 -0
- package/dist/deployment.js +130 -0
- package/dist/embedded-transaction.d.ts +22 -4
- package/dist/embedded-transaction.d.ts.map +1 -1
- package/dist/embedded-transaction.js +51 -5
- package/dist/feltdb.d.ts +14 -2
- package/dist/feltdb.d.ts.map +1 -1
- package/dist/file-db.js +1 -1
- package/dist/http-client.d.ts +14 -0
- package/dist/http-client.d.ts.map +1 -1
- package/dist/http-client.js +23 -5
- package/dist/http-db.d.ts +119 -1
- package/dist/http-db.d.ts.map +1 -1
- package/dist/http-db.js +346 -31
- package/dist/index-core.d.ts +2 -0
- package/dist/index-core.d.ts.map +1 -1
- package/dist/index-core.js +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +9 -0
- package/dist/indexeddb-db.d.ts.map +1 -1
- package/dist/indexeddb-db.js +35 -21
- package/dist/managed-recovery.d.ts +192 -0
- package/dist/managed-recovery.d.ts.map +1 -0
- package/dist/managed-recovery.js +242 -0
- package/dist/memory-db.js +1 -1
- package/dist/studio-app/assets/{feltdb_wasm-DB8cX151.js → feltdb_wasm-CVQWgXO-.js} +1 -1
- package/dist/studio-app/assets/feltdb_wasm_bg-CNVpvaZV.wasm +0 -0
- package/dist/studio-app/assets/index-DwgNAIIX.js +29 -0
- package/dist/studio-app/index.html +1 -1
- package/dist/transaction.d.ts +30 -0
- package/dist/transaction.d.ts.map +1 -1
- package/dist/transaction.js +41 -0
- package/dist/wasm/feltdb_wasm_bg.wasm +0 -0
- package/package.json +1 -1
- package/dist/studio-app/assets/feltdb_wasm_bg-ClhDHp0S.wasm +0 -0
- package/dist/studio-app/assets/index-B0k4UAlI.js +0 -29
|
@@ -468,8 +468,17 @@ mod tests {
|
|
|
468
468
|
StateHash::from_hex("hash0".to_string()),
|
|
469
469
|
);
|
|
470
470
|
|
|
471
|
-
|
|
472
|
-
|
|
471
|
+
// The peer is registered at the state the recovered node actually
|
|
472
|
+
// holds, read back rather than restated. `load_from_disk` recomputes
|
|
473
|
+
// the local replica's hash from the replayed log, so a fabricated
|
|
474
|
+
// literal here would describe a peer that has diverged and the
|
|
475
|
+
// convergence check below would be asserting the opposite of what this
|
|
476
|
+
// test is about.
|
|
477
|
+
let recovered_hash = recovered_executor
|
|
478
|
+
.get_replica_state("nodeA")
|
|
479
|
+
.expect("the recovered node has a local replica")
|
|
480
|
+
.state_hash;
|
|
481
|
+
recovered_executor.register_replica("nodeB".to_string(), recovered_hash);
|
|
473
482
|
|
|
474
483
|
let mut recovery = RecoveryOrchestrator::new(recovered_executor, "nodeB".to_string());
|
|
475
484
|
recovery.reconcile_with_peer(&peer)?;
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
//! Test instrumentation for the collection read paths a bounded query can take.
|
|
2
|
+
//!
|
|
3
|
+
//! PR33 attributed sustained-workload degradation to bounded queries cloning an
|
|
4
|
+
//! entire collection under the state mutex before predicate, ordering and limit
|
|
5
|
+
//! were applied. PR34 removes that clone, and a benchmark alone cannot prove a
|
|
6
|
+
//! clone is gone: a faster number is evidence of speed, not of mechanism.
|
|
7
|
+
//!
|
|
8
|
+
//! These counters therefore record *which traversal a read used*, so a test can
|
|
9
|
+
//! assert that a bounded query never entered `FeltDb::list_collection`. They
|
|
10
|
+
//! are process-global, monotonic, carry no record data, and are not part of the
|
|
11
|
+
//! query contract: nothing in the query path reads them, and no query result
|
|
12
|
+
//! depends on them. The HTTP surface only exposes them when a server is started
|
|
13
|
+
//! with `FELTDB_QUERY_DIAGNOSTICS=1`.
|
|
14
|
+
//!
|
|
15
|
+
//! PR35 extends them with execution attribution. An index is an optimization, so
|
|
16
|
+
//! "the query got faster" is not evidence that it was used; these counters say
|
|
17
|
+
//! which execution a query actually took, how many candidates the index
|
|
18
|
+
//! produced, and how many records the predicate had to evaluate. That last
|
|
19
|
+
//! figure is the one the PR35 claim rests on: for a selective indexed query it
|
|
20
|
+
//! must track the candidate count rather than the collection size.
|
|
21
|
+
|
|
22
|
+
use std::sync::atomic::{AtomicU64, Ordering};
|
|
23
|
+
|
|
24
|
+
static FULL_COLLECTION_MATERIALIZATIONS: AtomicU64 = AtomicU64::new(0);
|
|
25
|
+
static FULL_COLLECTION_RECORDS_CLONED: AtomicU64 = AtomicU64::new(0);
|
|
26
|
+
static COLLECTION_SCANS: AtomicU64 = AtomicU64::new(0);
|
|
27
|
+
static SCAN_RECORDS_VISITED: AtomicU64 = AtomicU64::new(0);
|
|
28
|
+
static SCAN_RECORDS_MATERIALIZED: AtomicU64 = AtomicU64::new(0);
|
|
29
|
+
static QUERIES_TOTAL: AtomicU64 = AtomicU64::new(0);
|
|
30
|
+
static QUERIES_SCAN: AtomicU64 = AtomicU64::new(0);
|
|
31
|
+
static QUERIES_INDEXED: AtomicU64 = AtomicU64::new(0);
|
|
32
|
+
static INDEX_HITS: AtomicU64 = AtomicU64::new(0);
|
|
33
|
+
static INDEX_MISSES: AtomicU64 = AtomicU64::new(0);
|
|
34
|
+
static INDEX_CANDIDATES_EXAMINED: AtomicU64 = AtomicU64::new(0);
|
|
35
|
+
static RECORDS_PREDICATE_EVALUATED: AtomicU64 = AtomicU64::new(0);
|
|
36
|
+
|
|
37
|
+
/// A point-in-time reading of the collection read counters.
|
|
38
|
+
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
|
|
39
|
+
pub struct QueryExecutionCounters {
|
|
40
|
+
/// Calls to `FeltDb::list_collection`, which clones a whole collection.
|
|
41
|
+
pub full_collection_materializations: u64,
|
|
42
|
+
/// Records cloned by those calls.
|
|
43
|
+
pub full_collection_records_cloned: u64,
|
|
44
|
+
/// Calls to `FeltDb::query_collection`, which evaluates borrowed records.
|
|
45
|
+
pub collection_scans: u64,
|
|
46
|
+
/// Records a scan predicate observed, borrowed rather than cloned.
|
|
47
|
+
pub scan_records_visited: u64,
|
|
48
|
+
/// Records a scan actually cloned, which is at most the matching set.
|
|
49
|
+
pub scan_records_materialized: u64,
|
|
50
|
+
/// Collection reads that executed a query: `queries_scan + queries_indexed`.
|
|
51
|
+
pub queries_total: u64,
|
|
52
|
+
/// Queries answered by evaluating the predicate over the whole collection.
|
|
53
|
+
pub queries_scan: u64,
|
|
54
|
+
/// Queries answered from equality-index candidates.
|
|
55
|
+
pub queries_indexed: u64,
|
|
56
|
+
/// Queries for which at least one condition had an applicable index.
|
|
57
|
+
pub index_hits: u64,
|
|
58
|
+
/// Queries for which no condition had an applicable index, which fall back.
|
|
59
|
+
pub index_misses: u64,
|
|
60
|
+
/// Candidate record keys the index produced, after intersection.
|
|
61
|
+
pub index_candidates_examined: u64,
|
|
62
|
+
/// Records the predicate was actually evaluated against, on either path.
|
|
63
|
+
/// For a selective indexed query this tracks the candidate count; for a scan
|
|
64
|
+
/// it is the collection size.
|
|
65
|
+
pub records_predicate_evaluated: u64,
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
pub(crate) fn record_full_materialization(records: usize) {
|
|
69
|
+
FULL_COLLECTION_MATERIALIZATIONS.fetch_add(1, Ordering::Relaxed);
|
|
70
|
+
FULL_COLLECTION_RECORDS_CLONED.fetch_add(records as u64, Ordering::Relaxed);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
pub(crate) fn record_scan(visited: usize, materialized: usize) {
|
|
74
|
+
COLLECTION_SCANS.fetch_add(1, Ordering::Relaxed);
|
|
75
|
+
SCAN_RECORDS_VISITED.fetch_add(visited as u64, Ordering::Relaxed);
|
|
76
|
+
SCAN_RECORDS_MATERIALIZED.fetch_add(materialized as u64, Ordering::Relaxed);
|
|
77
|
+
QUERIES_TOTAL.fetch_add(1, Ordering::Relaxed);
|
|
78
|
+
QUERIES_SCAN.fetch_add(1, Ordering::Relaxed);
|
|
79
|
+
// A scan evaluates the predicate against every record it visits. This is
|
|
80
|
+
// the O(N) figure PR35 exists to reduce, recorded on the same counter the
|
|
81
|
+
// indexed path reports to, so the two are directly comparable.
|
|
82
|
+
RECORDS_PREDICATE_EVALUATED.fetch_add(visited as u64, Ordering::Relaxed);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/// A query for which no condition had an applicable index, so it falls back.
|
|
86
|
+
///
|
|
87
|
+
/// This deliberately does not advance `queries_total`: the scan the caller is
|
|
88
|
+
/// about to run records the query itself, and counting it here would report
|
|
89
|
+
/// every fallback twice.
|
|
90
|
+
pub(crate) fn record_index_miss() {
|
|
91
|
+
INDEX_MISSES.fetch_add(1, Ordering::Relaxed);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/// A query answered from index candidates.
|
|
95
|
+
///
|
|
96
|
+
/// `examined` is the candidate set after intersection, `evaluated` the records
|
|
97
|
+
/// the predicate actually ran against, and `materialized` the matching set that
|
|
98
|
+
/// was cloned. `evaluated` can be below `examined` only if an index entry has no
|
|
99
|
+
/// record behind it, which would be a maintenance defect; the gap is visible
|
|
100
|
+
/// here rather than hidden.
|
|
101
|
+
pub(crate) fn record_indexed_query(examined: usize, evaluated: usize, materialized: usize) {
|
|
102
|
+
QUERIES_TOTAL.fetch_add(1, Ordering::Relaxed);
|
|
103
|
+
QUERIES_INDEXED.fetch_add(1, Ordering::Relaxed);
|
|
104
|
+
INDEX_HITS.fetch_add(1, Ordering::Relaxed);
|
|
105
|
+
INDEX_CANDIDATES_EXAMINED.fetch_add(examined as u64, Ordering::Relaxed);
|
|
106
|
+
RECORDS_PREDICATE_EVALUATED.fetch_add(evaluated as u64, Ordering::Relaxed);
|
|
107
|
+
SCAN_RECORDS_MATERIALIZED.fetch_add(materialized as u64, Ordering::Relaxed);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/// Read the counters. Monotonic since process start, so tests compare deltas.
|
|
111
|
+
pub fn counters() -> QueryExecutionCounters {
|
|
112
|
+
QueryExecutionCounters {
|
|
113
|
+
full_collection_materializations: FULL_COLLECTION_MATERIALIZATIONS.load(Ordering::Relaxed),
|
|
114
|
+
full_collection_records_cloned: FULL_COLLECTION_RECORDS_CLONED.load(Ordering::Relaxed),
|
|
115
|
+
collection_scans: COLLECTION_SCANS.load(Ordering::Relaxed),
|
|
116
|
+
scan_records_visited: SCAN_RECORDS_VISITED.load(Ordering::Relaxed),
|
|
117
|
+
scan_records_materialized: SCAN_RECORDS_MATERIALIZED.load(Ordering::Relaxed),
|
|
118
|
+
queries_total: QUERIES_TOTAL.load(Ordering::Relaxed),
|
|
119
|
+
queries_scan: QUERIES_SCAN.load(Ordering::Relaxed),
|
|
120
|
+
queries_indexed: QUERIES_INDEXED.load(Ordering::Relaxed),
|
|
121
|
+
index_hits: INDEX_HITS.load(Ordering::Relaxed),
|
|
122
|
+
index_misses: INDEX_MISSES.load(Ordering::Relaxed),
|
|
123
|
+
index_candidates_examined: INDEX_CANDIDATES_EXAMINED.load(Ordering::Relaxed),
|
|
124
|
+
records_predicate_evaluated: RECORDS_PREDICATE_EVALUATED.load(Ordering::Relaxed),
|
|
125
|
+
}
|
|
126
|
+
}
|
|
@@ -25,7 +25,8 @@ use crate::{
|
|
|
25
25
|
policy_evaluation::{
|
|
26
26
|
Actor, AuthorizationState, PolicyEvaluator, PolicySubject, RecordAuthorizationContext,
|
|
27
27
|
},
|
|
28
|
-
AtomicMutation, AtomicPrecondition, FeltDb,
|
|
28
|
+
AtomicMutation, AtomicPrecondition, FeltDb, FlowError, PreconditionFailure, RecordPrecondition,
|
|
29
|
+
StoredRow,
|
|
29
30
|
};
|
|
30
31
|
use serde::{Deserialize, Serialize};
|
|
31
32
|
use serde_json::{Map, Value};
|
|
@@ -853,6 +854,12 @@ pub struct StateFailure {
|
|
|
853
854
|
pub transaction: Option<String>,
|
|
854
855
|
#[serde(default)]
|
|
855
856
|
pub causal_position: Option<u64>,
|
|
857
|
+
/// The structured predicate failure, when the refusal was a record
|
|
858
|
+
/// precondition. Named `failure` because that is what the direct
|
|
859
|
+
/// authority's refused transaction already calls it: one refusal, one
|
|
860
|
+
/// shape, whichever endpoint answered.
|
|
861
|
+
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
862
|
+
pub failure: Option<Value>,
|
|
856
863
|
}
|
|
857
864
|
impl StateFailure {
|
|
858
865
|
fn new(code: &str, message: &str) -> Self {
|
|
@@ -864,6 +871,7 @@ impl StateFailure {
|
|
|
864
871
|
resource: None,
|
|
865
872
|
transaction: None,
|
|
866
873
|
causal_position: None,
|
|
874
|
+
failure: None,
|
|
867
875
|
}
|
|
868
876
|
}
|
|
869
877
|
fn storage(error: impl std::fmt::Display) -> Self {
|
|
@@ -1615,6 +1623,34 @@ pub enum TransactionPrecondition {
|
|
|
1615
1623
|
StateVersion { version: u64 },
|
|
1616
1624
|
ApplicationRevision { revision_id: String },
|
|
1617
1625
|
CausalCursor { cursor: BTreeMap<String, u64> },
|
|
1626
|
+
/// What must be true of one record for the transaction to commit.
|
|
1627
|
+
///
|
|
1628
|
+
/// Distinct from every variant above, and evaluated in a different place
|
|
1629
|
+
/// for a reason: those compare counters that can be read before the commit
|
|
1630
|
+
/// lock is taken, while this one compares the fields a caller reads back —
|
|
1631
|
+
/// the document's `__version`, its authority epoch, its lease — and must
|
|
1632
|
+
/// therefore be evaluated *inside* the same lock as the writes. Checking it
|
|
1633
|
+
/// against the pre-commit snapshot would be a client-side check wearing the
|
|
1634
|
+
/// authority's name: between the read and the write, another writer
|
|
1635
|
+
/// commits.
|
|
1636
|
+
///
|
|
1637
|
+
/// It is also what makes `putIfAbsent` and `updateIfVersion` mean the same
|
|
1638
|
+
/// thing against a managed authority as against the file runtime. Mapping
|
|
1639
|
+
/// a caller's `expectedVersion` onto `if_version` would not: that fences
|
|
1640
|
+
/// the row's internal storage sequence, which is a different number that
|
|
1641
|
+
/// happens to share a name.
|
|
1642
|
+
Record {
|
|
1643
|
+
collection: String,
|
|
1644
|
+
id: String,
|
|
1645
|
+
#[serde(default)]
|
|
1646
|
+
require_absent: bool,
|
|
1647
|
+
#[serde(default)]
|
|
1648
|
+
expected_version: Option<u64>,
|
|
1649
|
+
#[serde(default)]
|
|
1650
|
+
expected_epoch: Option<u64>,
|
|
1651
|
+
#[serde(default)]
|
|
1652
|
+
expected_lease_id: Option<String>,
|
|
1653
|
+
},
|
|
1618
1654
|
}
|
|
1619
1655
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
1620
1656
|
pub struct TransactionRequest {
|
|
@@ -2056,8 +2092,47 @@ fn execute_transaction_with_policies(
|
|
|
2056
2092
|
});
|
|
2057
2093
|
}
|
|
2058
2094
|
let snapshot = db.state_rows().map_err(StateFailure::storage)?;
|
|
2095
|
+
let namespace = request
|
|
2096
|
+
.state_namespace
|
|
2097
|
+
.as_deref()
|
|
2098
|
+
.unwrap_or(&request.application_id)
|
|
2099
|
+
.to_string();
|
|
2100
|
+
// Record preconditions are collected here and evaluated by the store,
|
|
2101
|
+
// inside the same lock as the writes. Evaluating them against `snapshot`
|
|
2102
|
+
// alongside the counters below would reintroduce exactly the gap they
|
|
2103
|
+
// exist to close.
|
|
2104
|
+
let mut record_preconditions: Vec<RecordPrecondition> = Vec::new();
|
|
2059
2105
|
for condition in &request.preconditions {
|
|
2060
2106
|
let (resource, expected, actual, passed) = match condition {
|
|
2107
|
+
TransactionPrecondition::Record {
|
|
2108
|
+
collection,
|
|
2109
|
+
id,
|
|
2110
|
+
require_absent,
|
|
2111
|
+
expected_version,
|
|
2112
|
+
expected_epoch,
|
|
2113
|
+
expected_lease_id,
|
|
2114
|
+
} => {
|
|
2115
|
+
let precondition = RecordPrecondition {
|
|
2116
|
+
capability: format!("{namespace}:{collection}"),
|
|
2117
|
+
key: id.clone(),
|
|
2118
|
+
require_absent: *require_absent,
|
|
2119
|
+
expected_version: *expected_version,
|
|
2120
|
+
expected_epoch: *expected_epoch,
|
|
2121
|
+
expected_lease_id: expected_lease_id.clone(),
|
|
2122
|
+
};
|
|
2123
|
+
if precondition.is_empty() {
|
|
2124
|
+
// Refused rather than ignored: a precondition that
|
|
2125
|
+
// constrains nothing reads as protection and provides none.
|
|
2126
|
+
let mut failure = StateFailure::new(
|
|
2127
|
+
"VALIDATION_FAILED",
|
|
2128
|
+
&format!("precondition on {collection}:{id} constrains nothing"),
|
|
2129
|
+
);
|
|
2130
|
+
failure.resource = Some(format!("{collection}:{id}"));
|
|
2131
|
+
return Err(failure);
|
|
2132
|
+
}
|
|
2133
|
+
record_preconditions.push(precondition);
|
|
2134
|
+
continue;
|
|
2135
|
+
}
|
|
2061
2136
|
TransactionPrecondition::CollectionVersion {
|
|
2062
2137
|
collection,
|
|
2063
2138
|
version,
|
|
@@ -2350,15 +2425,37 @@ fn execute_transaction_with_policies(
|
|
|
2350
2425
|
}
|
|
2351
2426
|
}
|
|
2352
2427
|
let commit = db
|
|
2353
|
-
.
|
|
2428
|
+
.apply_atomic_transaction_guarded(
|
|
2354
2429
|
&storage_transaction_id,
|
|
2355
2430
|
Some(&payload_hash),
|
|
2356
2431
|
request.causal_parent,
|
|
2357
2432
|
&preconditions,
|
|
2433
|
+
&record_preconditions,
|
|
2358
2434
|
&mutations,
|
|
2359
2435
|
Some(serde_json::json!({"transaction_id":transaction_id.clone(),"subject":request.authorization.subject.clone(),"tenant":request.tenant_id.clone(),"application":request.application_id.clone(),"revision":request.revision_id.clone(),"schema_version":request.schema_version,"operations":request.operations.len(),"authorization_decisions":["state:write allowed"],"causal_parent":request.causal_parent,"result":"committed"})),
|
|
2360
2436
|
)
|
|
2361
2437
|
.map_err(|error| {
|
|
2438
|
+
// A record precondition that did not hold is a lost race, and it
|
|
2439
|
+
// says which predicate failed and what the authority holds. That
|
|
2440
|
+
// detail is the difference between "re-read and decide again" and
|
|
2441
|
+
// "this deployment is broken", so it is carried through as
|
|
2442
|
+
// structure rather than flattened into the message.
|
|
2443
|
+
if let FlowError::PreconditionFailed(refusal) = &error {
|
|
2444
|
+
let mut failure =
|
|
2445
|
+
StateFailure::new("PRECONDITION_FAILED", &error.to_string());
|
|
2446
|
+
failure.transaction = Some(transaction_id.clone());
|
|
2447
|
+
failure.causal_position = db.sequence().ok();
|
|
2448
|
+
failure.resource = Some(refusal.key().to_string());
|
|
2449
|
+
failure.failure = serde_json::to_value(refusal.as_ref()).ok();
|
|
2450
|
+
if let PreconditionFailure::Version {
|
|
2451
|
+
expected, actual, ..
|
|
2452
|
+
} = refusal.as_ref()
|
|
2453
|
+
{
|
|
2454
|
+
failure.expected = Some(Value::from(*expected));
|
|
2455
|
+
failure.actual = Some(Value::from(*actual));
|
|
2456
|
+
}
|
|
2457
|
+
return failure;
|
|
2458
|
+
}
|
|
2362
2459
|
let text = error.to_string();
|
|
2363
2460
|
if text.contains("PRECONDITION_FAILED") {
|
|
2364
2461
|
let mut failure = StateFailure::new("PRECONDITION_FAILED", &text);
|
|
@@ -2665,6 +2762,199 @@ mod tests {
|
|
|
2665
2762
|
"CONFLICT"
|
|
2666
2763
|
);
|
|
2667
2764
|
}
|
|
2765
|
+
/// A conditional write through the canonical contract has to mean the same
|
|
2766
|
+
/// thing it means everywhere else in FeltDB, or `putIfAbsent` and
|
|
2767
|
+
/// `updateIfVersion` become file-runtime privileges.
|
|
2768
|
+
///
|
|
2769
|
+
/// The predicate under test is the document's `__version` -- the number a
|
|
2770
|
+
/// caller reads back -- not the row's internal storage sequence. Those are
|
|
2771
|
+
/// two different numbers that happen to share a name, and a fence on the
|
|
2772
|
+
/// wrong one is worse than no fence, because it reads as protection.
|
|
2773
|
+
#[test]
|
|
2774
|
+
fn a_record_precondition_fences_the_document_version() {
|
|
2775
|
+
let db = db("record-precondition");
|
|
2776
|
+
let schema = schema();
|
|
2777
|
+
let write = |id: &str,
|
|
2778
|
+
transaction: &str,
|
|
2779
|
+
title: &str,
|
|
2780
|
+
preconditions: Vec<TransactionPrecondition>,
|
|
2781
|
+
kind: TransactionOperationKind| {
|
|
2782
|
+
execute_transaction(
|
|
2783
|
+
&db,
|
|
2784
|
+
&schema,
|
|
2785
|
+
&TransactionRequest {
|
|
2786
|
+
transaction_id: Some(transaction.into()),
|
|
2787
|
+
tenant_id: "tenant".into(),
|
|
2788
|
+
application_id: "app".into(),
|
|
2789
|
+
revision_id: "rev".into(),
|
|
2790
|
+
schema_version: 1,
|
|
2791
|
+
state_namespace: None,
|
|
2792
|
+
causal_parent: None,
|
|
2793
|
+
authorization: auth(),
|
|
2794
|
+
preconditions,
|
|
2795
|
+
operations: vec![TransactionOperation {
|
|
2796
|
+
kind,
|
|
2797
|
+
collection: "incidents".into(),
|
|
2798
|
+
id: id.into(),
|
|
2799
|
+
value: serde_json::json!({ "title": title }),
|
|
2800
|
+
if_version: None,
|
|
2801
|
+
}],
|
|
2802
|
+
},
|
|
2803
|
+
None,
|
|
2804
|
+
)
|
|
2805
|
+
};
|
|
2806
|
+
let fence = |version: Option<u64>, absent: bool| {
|
|
2807
|
+
vec![TransactionPrecondition::Record {
|
|
2808
|
+
collection: "incidents".into(),
|
|
2809
|
+
id: "i1".into(),
|
|
2810
|
+
require_absent: absent,
|
|
2811
|
+
expected_version: version,
|
|
2812
|
+
expected_epoch: None,
|
|
2813
|
+
expected_lease_id: None,
|
|
2814
|
+
}]
|
|
2815
|
+
};
|
|
2816
|
+
let version = || {
|
|
2817
|
+
db.state_rows()
|
|
2818
|
+
.unwrap()
|
|
2819
|
+
.into_iter()
|
|
2820
|
+
.find(|row| row.capability == "app:incidents" && row.key == "i1" && !row.deleted)
|
|
2821
|
+
.and_then(|row| row.value.get("__version").and_then(Value::as_u64))
|
|
2822
|
+
};
|
|
2823
|
+
|
|
2824
|
+
// A conditional create is a create at version 1.
|
|
2825
|
+
write(
|
|
2826
|
+
"i1",
|
|
2827
|
+
"create",
|
|
2828
|
+
"First",
|
|
2829
|
+
fence(None, true),
|
|
2830
|
+
TransactionOperationKind::Insert,
|
|
2831
|
+
)
|
|
2832
|
+
.expect("the record is absent, so the create commits");
|
|
2833
|
+
assert_eq!(version(), Some(1));
|
|
2834
|
+
|
|
2835
|
+
// The same create again is a lost race. Whichever check catches it,
|
|
2836
|
+
// the answer is a conflict and nothing was written.
|
|
2837
|
+
let refused = write(
|
|
2838
|
+
"i1",
|
|
2839
|
+
"create-again",
|
|
2840
|
+
"Second",
|
|
2841
|
+
fence(None, true),
|
|
2842
|
+
TransactionOperationKind::Insert,
|
|
2843
|
+
)
|
|
2844
|
+
.expect_err("the record now exists");
|
|
2845
|
+
assert!(
|
|
2846
|
+
refused.code == "CONFLICT" || refused.code == "PRECONDITION_FAILED",
|
|
2847
|
+
"a lost creation race is a conflict, got {}",
|
|
2848
|
+
refused.code,
|
|
2849
|
+
);
|
|
2850
|
+
assert_eq!(version(), Some(1), "a refused transaction writes nothing");
|
|
2851
|
+
|
|
2852
|
+
// A guard on a record the transaction does not write is evaluated
|
|
2853
|
+
// inside the commit lock, and says which predicate failed.
|
|
2854
|
+
let guarded = write(
|
|
2855
|
+
"i2",
|
|
2856
|
+
"guarded",
|
|
2857
|
+
"Other",
|
|
2858
|
+
fence(None, true),
|
|
2859
|
+
TransactionOperationKind::Insert,
|
|
2860
|
+
)
|
|
2861
|
+
.expect_err("i1 exists, so the guard fails and i2 is never written");
|
|
2862
|
+
assert_eq!(guarded.code, "PRECONDITION_FAILED");
|
|
2863
|
+
assert_eq!(
|
|
2864
|
+
guarded
|
|
2865
|
+
.failure
|
|
2866
|
+
.as_ref()
|
|
2867
|
+
.and_then(|value| value.get("predicate").and_then(Value::as_str)),
|
|
2868
|
+
Some("present"),
|
|
2869
|
+
);
|
|
2870
|
+
assert!(
|
|
2871
|
+
db.state_rows()
|
|
2872
|
+
.unwrap()
|
|
2873
|
+
.iter()
|
|
2874
|
+
.all(|row| row.key != "i2" || row.deleted),
|
|
2875
|
+
"a refused guard leaves the staged write unwritten",
|
|
2876
|
+
);
|
|
2877
|
+
|
|
2878
|
+
// A stale fence conflicts and names what the authority actually holds.
|
|
2879
|
+
let stale = write(
|
|
2880
|
+
"i1",
|
|
2881
|
+
"stale",
|
|
2882
|
+
"Third",
|
|
2883
|
+
fence(Some(7), false),
|
|
2884
|
+
TransactionOperationKind::Update,
|
|
2885
|
+
)
|
|
2886
|
+
.expect_err("version 7 is not the version on record");
|
|
2887
|
+
assert_eq!(stale.code, "PRECONDITION_FAILED");
|
|
2888
|
+
assert_eq!(stale.expected, Some(Value::from(7)));
|
|
2889
|
+
assert_eq!(stale.actual, Some(Value::from(1)));
|
|
2890
|
+
assert_eq!(version(), Some(1));
|
|
2891
|
+
|
|
2892
|
+
// The fenced write commits and the authority owns the next version.
|
|
2893
|
+
write(
|
|
2894
|
+
"i1",
|
|
2895
|
+
"fenced",
|
|
2896
|
+
"Fourth",
|
|
2897
|
+
fence(Some(1), false),
|
|
2898
|
+
TransactionOperationKind::Update,
|
|
2899
|
+
)
|
|
2900
|
+
.expect("version 1 is the version on record");
|
|
2901
|
+
assert_eq!(version(), Some(2));
|
|
2902
|
+
|
|
2903
|
+
// Replaying a committed transaction id applies nothing, so it cannot
|
|
2904
|
+
// advance the version a second time.
|
|
2905
|
+
write(
|
|
2906
|
+
"i1",
|
|
2907
|
+
"fenced",
|
|
2908
|
+
"Fourth",
|
|
2909
|
+
fence(Some(1), false),
|
|
2910
|
+
TransactionOperationKind::Update,
|
|
2911
|
+
)
|
|
2912
|
+
.expect("a duplicate transaction id is not a failure");
|
|
2913
|
+
assert_eq!(version(), Some(2));
|
|
2914
|
+
}
|
|
2915
|
+
|
|
2916
|
+
/// A precondition that constrains nothing is refused rather than accepted,
|
|
2917
|
+
/// because one that is silently ignored reads as protection and provides
|
|
2918
|
+
/// none.
|
|
2919
|
+
#[test]
|
|
2920
|
+
fn an_empty_record_precondition_is_refused() {
|
|
2921
|
+
let db = db("empty-precondition");
|
|
2922
|
+
let schema = schema();
|
|
2923
|
+
let failure = execute_transaction(
|
|
2924
|
+
&db,
|
|
2925
|
+
&schema,
|
|
2926
|
+
&TransactionRequest {
|
|
2927
|
+
transaction_id: Some("empty".into()),
|
|
2928
|
+
tenant_id: "tenant".into(),
|
|
2929
|
+
application_id: "app".into(),
|
|
2930
|
+
revision_id: "rev".into(),
|
|
2931
|
+
schema_version: 1,
|
|
2932
|
+
state_namespace: None,
|
|
2933
|
+
causal_parent: None,
|
|
2934
|
+
authorization: auth(),
|
|
2935
|
+
preconditions: vec![TransactionPrecondition::Record {
|
|
2936
|
+
collection: "incidents".into(),
|
|
2937
|
+
id: "i1".into(),
|
|
2938
|
+
require_absent: false,
|
|
2939
|
+
expected_version: None,
|
|
2940
|
+
expected_epoch: None,
|
|
2941
|
+
expected_lease_id: None,
|
|
2942
|
+
}],
|
|
2943
|
+
operations: vec![TransactionOperation {
|
|
2944
|
+
kind: TransactionOperationKind::Insert,
|
|
2945
|
+
collection: "incidents".into(),
|
|
2946
|
+
id: "i1".into(),
|
|
2947
|
+
value: serde_json::json!({ "title": "First" }),
|
|
2948
|
+
if_version: None,
|
|
2949
|
+
}],
|
|
2950
|
+
},
|
|
2951
|
+
None,
|
|
2952
|
+
)
|
|
2953
|
+
.expect_err("a precondition that constrains nothing is a caller error");
|
|
2954
|
+
assert_eq!(failure.code, "VALIDATION_FAILED");
|
|
2955
|
+
assert!(db.state_rows().unwrap().iter().all(|row| row.deleted));
|
|
2956
|
+
}
|
|
2957
|
+
|
|
2668
2958
|
#[test]
|
|
2669
2959
|
fn validation_failure_rolls_back_every_operation() {
|
|
2670
2960
|
let db = db("rollback");
|
|
@@ -19,6 +19,18 @@ pub struct ChangeLog {
|
|
|
19
19
|
}
|
|
20
20
|
|
|
21
21
|
impl ChangeLog {
|
|
22
|
+
/// Earliest retained sequence for each operation origin.
|
|
23
|
+
pub fn retained_from(&self) -> HashMap<String, u64> {
|
|
24
|
+
let mut floors: HashMap<String, u64> = HashMap::new();
|
|
25
|
+
for operation in &self.pending {
|
|
26
|
+
floors
|
|
27
|
+
.entry(operation.instance_id.clone())
|
|
28
|
+
.and_modify(|sequence| *sequence = (*sequence).min(operation.sequence))
|
|
29
|
+
.or_insert(operation.sequence);
|
|
30
|
+
}
|
|
31
|
+
floors
|
|
32
|
+
}
|
|
33
|
+
|
|
22
34
|
/// Create a new empty change log
|
|
23
35
|
pub fn new() -> Self {
|
|
24
36
|
Self::default()
|