@feltdb/core 0.6.14 → 0.7.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/README.md +32 -0
- package/dist/create/server-source/crates/feltdb/src/bin/feltdb_node.rs +116 -0
- package/dist/create/server-source/crates/feltdb/src/distributed_transactions.rs +19 -0
- package/dist/create/server-source/crates/feltdb/src/lib.rs +280 -0
- package/dist/create/server-source/crates/feltdb/src/replica_acknowledgements.rs +471 -0
- package/dist/create/server-source/crates/feltdb/src/tcp_transport.rs +72 -3
- package/dist/create/server-source/crates/feltdb/src/transaction_preconditions.rs +899 -0
- package/dist/create/server-source/crates/feltdb-server/src/main.rs +116 -4
- package/dist/db.d.ts +2 -2
- package/dist/db.d.ts.map +1 -1
- package/dist/db.js +29 -2
- package/dist/embedded-transaction.d.ts +9 -0
- package/dist/embedded-transaction.d.ts.map +1 -1
- package/dist/embedded-transaction.js +95 -3
- package/dist/feltdb.d.ts +16 -0
- package/dist/feltdb.d.ts.map +1 -1
- package/dist/file-db.d.ts.map +1 -1
- package/dist/file-db.js +1 -0
- package/dist/http-db.d.ts +11 -0
- package/dist/http-db.d.ts.map +1 -1
- package/dist/http-db.js +16 -2
- package/dist/indexeddb-db.d.ts.map +1 -1
- package/dist/indexeddb-db.js +5 -0
- package/dist/memory-db.d.ts.map +1 -1
- package/dist/memory-db.js +1 -0
- package/dist/studio-app/assets/{feltdb_wasm-C9xpYtna.js → feltdb_wasm-CD744e5D.js} +1 -1
- package/dist/studio-app/assets/feltdb_wasm_bg-CiIXhOLi.wasm +0 -0
- package/dist/studio-app/assets/index-DoROs8yx.js +28 -0
- package/dist/studio-app/index.html +1 -1
- package/dist/transaction.d.ts +127 -9
- package/dist/transaction.d.ts.map +1 -1
- package/dist/transaction.js +87 -3
- package/dist/wasm/feltdb_wasm_bg.wasm +0 -0
- package/package.json +1 -1
- package/dist/studio-app/assets/feltdb_wasm_bg-BsXHw7eX.wasm +0 -0
- package/dist/studio-app/assets/index-D4RZ44qs.js +0 -28
package/README.md
CHANGED
|
@@ -75,6 +75,38 @@ Browser mutations resolve after their IndexedDB transaction commits. The
|
|
|
75
75
|
durable change journal replays after reload and coordinates live collections
|
|
76
76
|
across tabs through `BroadcastChannel` when available.
|
|
77
77
|
|
|
78
|
+
## Atomic conditional transactions
|
|
79
|
+
|
|
80
|
+
Core 0.7.0 can fence a multi-record transaction on the exact version read by
|
|
81
|
+
the caller. The transaction either commits every operation or writes nothing:
|
|
82
|
+
|
|
83
|
+
```typescript
|
|
84
|
+
import { ConditionalConflictError } from '@feltdb/core';
|
|
85
|
+
|
|
86
|
+
const current = await db.collection('accounts').get('primary');
|
|
87
|
+
|
|
88
|
+
try {
|
|
89
|
+
await db.transaction({
|
|
90
|
+
transactionId: 'transfer-42',
|
|
91
|
+
preconditions: [
|
|
92
|
+
{ collection: 'accounts', id: 'primary', ifVersion: current.__version },
|
|
93
|
+
],
|
|
94
|
+
operations: [
|
|
95
|
+
{ collection: 'accounts', id: 'primary', value: { balance: 90 } },
|
|
96
|
+
{ collection: 'ledger', id: 'transfer-42', requireAbsent: true, value: { amount: 10 } },
|
|
97
|
+
],
|
|
98
|
+
});
|
|
99
|
+
} catch (error) {
|
|
100
|
+
if (error instanceof ConditionalConflictError) {
|
|
101
|
+
console.log(error.conflict);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
Reusing a successful `transactionId` safely replays its result without
|
|
107
|
+
advancing record versions twice. This API requires a FeltDB authority that
|
|
108
|
+
supports transaction-level `ifVersion` preconditions.
|
|
109
|
+
|
|
78
110
|
## Durable Operation Management
|
|
79
111
|
|
|
80
112
|
FeltDB provides atomic operation admission and lifecycle management for systems that need to survive process crashes with guaranteed identity stability.
|
|
@@ -51,6 +51,7 @@ use feltdb::distributed_transactions::{
|
|
|
51
51
|
SubmitOutcome,
|
|
52
52
|
};
|
|
53
53
|
use feltdb::replication_protocol::ProtocolTransport;
|
|
54
|
+
use feltdb::replica_acknowledgements::{AcknowledgementStore, ReclamationEvidence, ReplicaAcknowledgement};
|
|
54
55
|
use feltdb::replica_membership::MembershipStore;
|
|
55
56
|
use feltdb::state_hash::StateHash;
|
|
56
57
|
use feltdb::tcp_transport::TcpTransport;
|
|
@@ -252,6 +253,18 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|
|
252
253
|
)
|
|
253
254
|
.map_err(|e| e.to_string())?;
|
|
254
255
|
|
|
256
|
+
// Acknowledgements are durable and shared with the receive tasks, because
|
|
257
|
+
// they arrive on the socket rather than through a command.
|
|
258
|
+
let acknowledgements = Arc::new(Mutex::new(
|
|
259
|
+
AcknowledgementStore::open(
|
|
260
|
+
data_dir
|
|
261
|
+
.as_ref()
|
|
262
|
+
.map(|dir| dir.join("acknowledgements.json"))
|
|
263
|
+
.unwrap_or_else(|| PathBuf::from("acknowledgements.json")),
|
|
264
|
+
)
|
|
265
|
+
.map_err(|e| e.to_string())?,
|
|
266
|
+
));
|
|
267
|
+
|
|
255
268
|
let initial_state = StateHash::from_hex("0".repeat(64));
|
|
256
269
|
let mut executor =
|
|
257
270
|
DistributedTransactionExecutor::with_log(node_id.clone(), initial_state.clone(), log_path.clone())?;
|
|
@@ -354,6 +367,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|
|
354
367
|
let peer = link.peer.clone();
|
|
355
368
|
let held = held.clone();
|
|
356
369
|
let backpressured = backpressured.clone();
|
|
370
|
+
let acknowledgements = acknowledgements.clone();
|
|
357
371
|
tokio::spawn(async move {
|
|
358
372
|
while !exit.load(Ordering::Relaxed) {
|
|
359
373
|
{
|
|
@@ -396,6 +410,15 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|
|
396
410
|
|
|
397
411
|
match transport.receive().await {
|
|
398
412
|
Ok(messages) => {
|
|
413
|
+
// Acknowledgements arrive interleaved with
|
|
414
|
+
// envelopes on the same connection. They are
|
|
415
|
+
// durable before they count as evidence.
|
|
416
|
+
for ack in transport.take_acknowledgements().await {
|
|
417
|
+
let mut store = acknowledgements.lock().await;
|
|
418
|
+
if let Err(error) = store.record(ack) {
|
|
419
|
+
eprintln!("acknowledgement not recorded: {error}");
|
|
420
|
+
}
|
|
421
|
+
}
|
|
399
422
|
for message in messages {
|
|
400
423
|
{
|
|
401
424
|
let mut hold = held.lock().await;
|
|
@@ -792,6 +815,99 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|
|
792
815
|
io::stdout().flush().ok();
|
|
793
816
|
}
|
|
794
817
|
|
|
818
|
+
// Report what this node has durably applied, to every peer.
|
|
819
|
+
//
|
|
820
|
+
// The frontier is read from the executor, where it advances only in
|
|
821
|
+
// record_applied -- after an fsynced envelope is applied. So the
|
|
822
|
+
// statement "durably applied through this frontier" is true by
|
|
823
|
+
// construction rather than by assertion.
|
|
824
|
+
"ack-broadcast" => {
|
|
825
|
+
let (frontier, marks) = {
|
|
826
|
+
let guard = core.lock().await;
|
|
827
|
+
let (executor, _) = &*guard;
|
|
828
|
+
(executor.causal_frontier().clone(), executor.origin_sequence_high_water())
|
|
829
|
+
};
|
|
830
|
+
let view: Vec<String> = membership
|
|
831
|
+
.get_members()
|
|
832
|
+
.into_iter()
|
|
833
|
+
.map(|replica| replica.replica_id)
|
|
834
|
+
.collect();
|
|
835
|
+
let replica_id = membership
|
|
836
|
+
.get_members()
|
|
837
|
+
.into_iter()
|
|
838
|
+
.find(|replica| replica.node_id == node_id)
|
|
839
|
+
.map(|replica| replica.replica_id)
|
|
840
|
+
.unwrap_or_else(|| node_id.clone());
|
|
841
|
+
|
|
842
|
+
let ack = ReplicaAcknowledgement::new(
|
|
843
|
+
replica_id.clone(),
|
|
844
|
+
node_id.clone(),
|
|
845
|
+
frontier,
|
|
846
|
+
marks,
|
|
847
|
+
view,
|
|
848
|
+
);
|
|
849
|
+
|
|
850
|
+
// A node's own acknowledgement is evidence it holds first-hand:
|
|
851
|
+
// it knows its own durable frontier without being told. Without
|
|
852
|
+
// this a node would be permanently blocked on itself, since
|
|
853
|
+
// nothing else can report on its behalf.
|
|
854
|
+
{
|
|
855
|
+
let mut store = acknowledgements.lock().await;
|
|
856
|
+
let _ = store.record(ack.clone());
|
|
857
|
+
}
|
|
858
|
+
|
|
859
|
+
let mut delivered = Vec::new();
|
|
860
|
+
for link in &send_links {
|
|
861
|
+
if link.io.send_acknowledgement(ack.clone()).await.is_ok() {
|
|
862
|
+
delivered.push(link.peer.clone());
|
|
863
|
+
} else if connect_with_retry(link, 3).await
|
|
864
|
+
&& link.io.send_acknowledgement(ack.clone()).await.is_ok()
|
|
865
|
+
{
|
|
866
|
+
delivered.push(link.peer.clone());
|
|
867
|
+
}
|
|
868
|
+
}
|
|
869
|
+
println!(
|
|
870
|
+
"ACK_SENT {}",
|
|
871
|
+
json!({
|
|
872
|
+
"replica_id": replica_id,
|
|
873
|
+
"frontier": ack.applied_frontier.clocks,
|
|
874
|
+
"delivered": delivered,
|
|
875
|
+
})
|
|
876
|
+
);
|
|
877
|
+
io::stdout().flush().ok();
|
|
878
|
+
}
|
|
879
|
+
|
|
880
|
+
"acknowledgements" => {
|
|
881
|
+
let store = acknowledgements.lock().await;
|
|
882
|
+
println!("ACKS {}", json!({ "acknowledgements": store.all() }));
|
|
883
|
+
io::stdout().flush().ok();
|
|
884
|
+
}
|
|
885
|
+
|
|
886
|
+
// What this node's evidence permits. Computes; reclaims nothing.
|
|
887
|
+
"reclamation-evidence" => {
|
|
888
|
+
let local: Vec<String> = membership
|
|
889
|
+
.get_required_replicas()
|
|
890
|
+
.into_iter()
|
|
891
|
+
.map(|replica| replica.replica_id)
|
|
892
|
+
.collect();
|
|
893
|
+
let store = acknowledgements.lock().await;
|
|
894
|
+
let report = match store.evidence(&local) {
|
|
895
|
+
ReclamationEvidence::Blocked { waiting_on } => json!({
|
|
896
|
+
"state": "BLOCKED",
|
|
897
|
+
"waiting_on": waiting_on,
|
|
898
|
+
"required_union": store.required_union(&local),
|
|
899
|
+
}),
|
|
900
|
+
ReclamationEvidence::SafeThrough { frontier, covered } => json!({
|
|
901
|
+
"state": "SAFE_THROUGH",
|
|
902
|
+
"frontier": frontier.clocks,
|
|
903
|
+
"covered": covered,
|
|
904
|
+
"required_union": store.required_union(&local),
|
|
905
|
+
}),
|
|
906
|
+
};
|
|
907
|
+
println!("EVIDENCE {}", report);
|
|
908
|
+
io::stdout().flush().ok();
|
|
909
|
+
}
|
|
910
|
+
|
|
795
911
|
"members" => {
|
|
796
912
|
println!(
|
|
797
913
|
"MEMBERS {}",
|
|
@@ -385,6 +385,25 @@ impl DistributedTransactionExecutor {
|
|
|
385
385
|
// that decision alone.
|
|
386
386
|
// ---------------------------------------------------------------------
|
|
387
387
|
|
|
388
|
+
/// The highest identity committed to the durable log, per origin.
|
|
389
|
+
///
|
|
390
|
+
/// For this node it is its own identity high-water mark; for others it is
|
|
391
|
+
/// how far this replica's log carries their identities. A checkpoint that
|
|
392
|
+
/// discards log prefix must preserve these, because the log is also the
|
|
393
|
+
/// source `next_origin_sequence` is recovered from.
|
|
394
|
+
pub fn origin_sequence_high_water(&self) -> std::collections::BTreeMap<String, u64> {
|
|
395
|
+
let mut marks = std::collections::BTreeMap::new();
|
|
396
|
+
let Some(ref log) = self.operation_log else { return marks };
|
|
397
|
+
let Ok(envelopes) = log.load_all() else { return marks };
|
|
398
|
+
for envelope in envelopes {
|
|
399
|
+
let entry = marks
|
|
400
|
+
.entry(envelope.envelope_id.originating_node.clone())
|
|
401
|
+
.or_insert(0u64);
|
|
402
|
+
*entry = (*entry).max(envelope.envelope_id.sequence);
|
|
403
|
+
}
|
|
404
|
+
marks
|
|
405
|
+
}
|
|
406
|
+
|
|
388
407
|
/// The next identity this node would issue.
|
|
389
408
|
pub fn next_origin_sequence(&self) -> u64 {
|
|
390
409
|
self.next_origin_sequence
|
|
@@ -24,6 +24,7 @@ pub mod analytics;
|
|
|
24
24
|
pub mod distributed_indexing;
|
|
25
25
|
pub mod indexing;
|
|
26
26
|
pub mod sharding;
|
|
27
|
+
pub mod transaction_preconditions;
|
|
27
28
|
pub mod transactions;
|
|
28
29
|
pub mod state_hash;
|
|
29
30
|
pub mod crash_injection;
|
|
@@ -52,6 +53,7 @@ pub mod consistency_contract;
|
|
|
52
53
|
pub mod persistence_reality;
|
|
53
54
|
pub mod adversarial_transport;
|
|
54
55
|
pub mod replica_membership;
|
|
56
|
+
pub mod replica_acknowledgements;
|
|
55
57
|
pub mod replication_manager;
|
|
56
58
|
pub mod metrics;
|
|
57
59
|
pub mod query_performance;
|
|
@@ -254,6 +256,12 @@ pub enum FlowError {
|
|
|
254
256
|
Serde(serde_json::Error),
|
|
255
257
|
CorruptLogLine(String),
|
|
256
258
|
CapabilityError(String),
|
|
259
|
+
/// A transaction precondition did not hold. Nothing was written.
|
|
260
|
+
///
|
|
261
|
+
/// Separate from `CapabilityError` because a conflict is an expected
|
|
262
|
+
/// outcome of a race and a caller is meant to branch on it, while a
|
|
263
|
+
/// capability error is not.
|
|
264
|
+
PreconditionFailed(Box<PreconditionFailure>),
|
|
257
265
|
}
|
|
258
266
|
|
|
259
267
|
impl Display for FlowError {
|
|
@@ -263,6 +271,7 @@ impl Display for FlowError {
|
|
|
263
271
|
FlowError::Serde(e) => write!(f, "serde error: {e}"),
|
|
264
272
|
FlowError::CorruptLogLine(line) => write!(f, "corrupt log line: {line}"),
|
|
265
273
|
FlowError::CapabilityError(msg) => write!(f, "capability error: {msg}"),
|
|
274
|
+
FlowError::PreconditionFailed(failure) => write!(f, "PRECONDITION_FAILED: {failure}"),
|
|
266
275
|
}
|
|
267
276
|
}
|
|
268
277
|
}
|
|
@@ -330,6 +339,109 @@ pub struct AtomicPrecondition {
|
|
|
330
339
|
pub key: String,
|
|
331
340
|
pub expected_version: Option<u64>,
|
|
332
341
|
}
|
|
342
|
+
|
|
343
|
+
/// What must be true of one record for a transaction to commit.
|
|
344
|
+
///
|
|
345
|
+
/// Distinct from `AtomicPrecondition`, and deliberately so. That one compares
|
|
346
|
+
/// the row's internal operation *sequence*, and `None` on it means "this record
|
|
347
|
+
/// must not exist". This one compares the fields a caller can actually read
|
|
348
|
+
/// back -- the document's `__version`, its authority epoch, its lease -- which
|
|
349
|
+
/// are the same fields single-record CAS compares. Plumbing a caller's
|
|
350
|
+
/// `expectedVersion` into the sequence check would have compared a document
|
|
351
|
+
/// version against a storage sequence: two different numbers that happen to
|
|
352
|
+
/// share a name.
|
|
353
|
+
///
|
|
354
|
+
/// Every predicate is optional and only a supplied one is checked. That differs
|
|
355
|
+
/// from CAS, where an absent `expected_lease_id` asserts the record is *not*
|
|
356
|
+
/// leased; here an absent predicate asserts nothing at all. The difference is
|
|
357
|
+
/// deliberate -- a transaction precondition is a fence the caller opts into,
|
|
358
|
+
/// not a full description of the record -- and it is why the fields are named
|
|
359
|
+
/// `expected_*` rather than describing a state.
|
|
360
|
+
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
|
361
|
+
pub struct RecordPrecondition {
|
|
362
|
+
pub capability: String,
|
|
363
|
+
pub key: String,
|
|
364
|
+
/// The record must not exist. Mutually exclusive with the predicates below.
|
|
365
|
+
#[serde(default)]
|
|
366
|
+
pub require_absent: bool,
|
|
367
|
+
/// The document's `__version`, as `updateIfVersion` compares it.
|
|
368
|
+
#[serde(default)]
|
|
369
|
+
pub expected_version: Option<u64>,
|
|
370
|
+
/// The record's authority epoch, at `/authority/epoch`.
|
|
371
|
+
#[serde(default)]
|
|
372
|
+
pub expected_epoch: Option<u64>,
|
|
373
|
+
/// The id of an unexpired lease held on the record.
|
|
374
|
+
#[serde(default)]
|
|
375
|
+
pub expected_lease_id: Option<String>,
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
impl RecordPrecondition {
|
|
379
|
+
/// True when this precondition asks for nothing, which is a caller error
|
|
380
|
+
/// rather than a satisfied fence: a precondition that constrains nothing
|
|
381
|
+
/// would read as protection and provide none.
|
|
382
|
+
pub fn is_empty(&self) -> bool {
|
|
383
|
+
!self.require_absent
|
|
384
|
+
&& self.expected_version.is_none()
|
|
385
|
+
&& self.expected_epoch.is_none()
|
|
386
|
+
&& self.expected_lease_id.is_none()
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
/// Which predicate failed, and what the authority actually holds.
|
|
391
|
+
///
|
|
392
|
+
/// Structured rather than a formatted string, because the caller has to tell a
|
|
393
|
+
/// lost race from a broken deployment: the first is retried with fresh state,
|
|
394
|
+
/// the second is not retried at all.
|
|
395
|
+
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
|
396
|
+
#[serde(rename_all = "camelCase", tag = "predicate")]
|
|
397
|
+
pub enum PreconditionFailure {
|
|
398
|
+
/// Required absent, but the record exists.
|
|
399
|
+
Present { collection: String, key: String },
|
|
400
|
+
/// A predicate was given for a record that does not exist.
|
|
401
|
+
Missing { collection: String, key: String },
|
|
402
|
+
Version { collection: String, key: String, expected: u64, actual: u64 },
|
|
403
|
+
Epoch { collection: String, key: String, expected: u64, actual: u64 },
|
|
404
|
+
Lease { collection: String, key: String, expected: String, actual: Option<String> },
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
impl PreconditionFailure {
|
|
408
|
+
pub fn collection(&self) -> &str {
|
|
409
|
+
match self {
|
|
410
|
+
Self::Present { collection, .. }
|
|
411
|
+
| Self::Missing { collection, .. }
|
|
412
|
+
| Self::Version { collection, .. }
|
|
413
|
+
| Self::Epoch { collection, .. }
|
|
414
|
+
| Self::Lease { collection, .. } => collection,
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
/// The storage key, which is namespaced as `collection:id`.
|
|
418
|
+
pub fn key(&self) -> &str {
|
|
419
|
+
match self {
|
|
420
|
+
Self::Present { key, .. }
|
|
421
|
+
| Self::Missing { key, .. }
|
|
422
|
+
| Self::Version { key, .. }
|
|
423
|
+
| Self::Epoch { key, .. }
|
|
424
|
+
| Self::Lease { key, .. } => key,
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
impl Display for PreconditionFailure {
|
|
430
|
+
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
|
|
431
|
+
match self {
|
|
432
|
+
Self::Present { collection, key } =>
|
|
433
|
+
write!(f, "{collection}/{key} was required to be absent but exists"),
|
|
434
|
+
Self::Missing { collection, key } =>
|
|
435
|
+
write!(f, "{collection}/{key} does not exist"),
|
|
436
|
+
Self::Version { collection, key, expected, actual } =>
|
|
437
|
+
write!(f, "{collection}/{key} is at version {actual}, expected {expected}"),
|
|
438
|
+
Self::Epoch { collection, key, expected, actual } =>
|
|
439
|
+
write!(f, "{collection}/{key} is at epoch {actual}, expected {expected}"),
|
|
440
|
+
Self::Lease { collection, key, expected, actual } =>
|
|
441
|
+
write!(f, "{collection}/{key} holds lease {actual:?}, expected {expected}"),
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
}
|
|
333
445
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
334
446
|
pub struct AtomicCommit {
|
|
335
447
|
pub transaction_id: String,
|
|
@@ -931,6 +1043,40 @@ impl FeltDb {
|
|
|
931
1043
|
preconditions: &[AtomicPrecondition],
|
|
932
1044
|
mutations: &[AtomicMutation],
|
|
933
1045
|
audit: Option<Value>,
|
|
1046
|
+
) -> Result<AtomicCommit> {
|
|
1047
|
+
self.apply_atomic_transaction_guarded(
|
|
1048
|
+
transaction_id,
|
|
1049
|
+
payload_hash,
|
|
1050
|
+
expected_parent,
|
|
1051
|
+
preconditions,
|
|
1052
|
+
&[],
|
|
1053
|
+
mutations,
|
|
1054
|
+
audit,
|
|
1055
|
+
)
|
|
1056
|
+
}
|
|
1057
|
+
|
|
1058
|
+
/// Commit a transaction only if every record precondition holds.
|
|
1059
|
+
///
|
|
1060
|
+
/// The preconditions are evaluated inside the same lock as the writes and
|
|
1061
|
+
/// before any of them, so there is no state in which one record is checked
|
|
1062
|
+
/// while the others commit. A failure returns
|
|
1063
|
+
/// `FlowError::PreconditionFailed` and applies nothing -- not a prefix, not
|
|
1064
|
+
/// the guarded record, nothing.
|
|
1065
|
+
///
|
|
1066
|
+
/// A failed precondition does **not** consume the transaction id. The id is
|
|
1067
|
+
/// recorded only after a commit, so a caller that loses a race may retry
|
|
1068
|
+
/// the same id once it has re-read state, and a caller that wants the
|
|
1069
|
+
/// retry to be a distinct decision may use a new one. Both work; the choice
|
|
1070
|
+
/// is the caller's and is asserted in the tests rather than left implied.
|
|
1071
|
+
pub fn apply_atomic_transaction_guarded(
|
|
1072
|
+
&self,
|
|
1073
|
+
transaction_id: &str,
|
|
1074
|
+
payload_hash: Option<&str>,
|
|
1075
|
+
expected_parent: Option<u64>,
|
|
1076
|
+
preconditions: &[AtomicPrecondition],
|
|
1077
|
+
record_preconditions: &[RecordPrecondition],
|
|
1078
|
+
mutations: &[AtomicMutation],
|
|
1079
|
+
audit: Option<Value>,
|
|
934
1080
|
) -> Result<AtomicCommit> {
|
|
935
1081
|
let (commit, events) = {
|
|
936
1082
|
let mut inner = self.inner.lock().expect("lock poisoned");
|
|
@@ -948,6 +1094,140 @@ impl FeltDb {
|
|
|
948
1094
|
duplicate: true,
|
|
949
1095
|
});
|
|
950
1096
|
}
|
|
1097
|
+
// Record preconditions, evaluated before anything is staged and
|
|
1098
|
+
// under the same lock the writes take. A caller reads state,
|
|
1099
|
+
// decides, and commits; this is the fence that makes the decision
|
|
1100
|
+
// still true at the moment of the write.
|
|
1101
|
+
for condition in record_preconditions {
|
|
1102
|
+
if condition.is_empty() {
|
|
1103
|
+
return Err(FlowError::CapabilityError(format!(
|
|
1104
|
+
"EMPTY_PRECONDITION:{}:{}",
|
|
1105
|
+
condition.capability, condition.key
|
|
1106
|
+
)));
|
|
1107
|
+
}
|
|
1108
|
+
let collection = condition.capability.clone();
|
|
1109
|
+
let key = condition.key.clone();
|
|
1110
|
+
let current = inner
|
|
1111
|
+
.rows
|
|
1112
|
+
.get(&condition.capability)
|
|
1113
|
+
.and_then(|bucket| bucket.get(&condition.key));
|
|
1114
|
+
|
|
1115
|
+
if condition.require_absent {
|
|
1116
|
+
if current.is_some() {
|
|
1117
|
+
return Err(FlowError::PreconditionFailed(Box::new(
|
|
1118
|
+
PreconditionFailure::Present { collection, key },
|
|
1119
|
+
)));
|
|
1120
|
+
}
|
|
1121
|
+
continue;
|
|
1122
|
+
}
|
|
1123
|
+
|
|
1124
|
+
let Some(current) = current else {
|
|
1125
|
+
return Err(FlowError::PreconditionFailed(Box::new(
|
|
1126
|
+
PreconditionFailure::Missing { collection, key },
|
|
1127
|
+
)));
|
|
1128
|
+
};
|
|
1129
|
+
|
|
1130
|
+
// The same fields single-record CAS compares, read the same way.
|
|
1131
|
+
if let Some(expected) = condition.expected_version {
|
|
1132
|
+
let actual = current
|
|
1133
|
+
.value
|
|
1134
|
+
.get("__version")
|
|
1135
|
+
.and_then(Value::as_u64)
|
|
1136
|
+
.unwrap_or(1);
|
|
1137
|
+
if actual != expected {
|
|
1138
|
+
return Err(FlowError::PreconditionFailed(Box::new(
|
|
1139
|
+
PreconditionFailure::Version { collection, key, expected, actual },
|
|
1140
|
+
)));
|
|
1141
|
+
}
|
|
1142
|
+
}
|
|
1143
|
+
if let Some(expected) = condition.expected_epoch {
|
|
1144
|
+
let actual = current
|
|
1145
|
+
.value
|
|
1146
|
+
.pointer("/authority/epoch")
|
|
1147
|
+
.and_then(Value::as_u64)
|
|
1148
|
+
.unwrap_or(0);
|
|
1149
|
+
if actual != expected {
|
|
1150
|
+
return Err(FlowError::PreconditionFailed(Box::new(
|
|
1151
|
+
PreconditionFailure::Epoch { collection, key, expected, actual },
|
|
1152
|
+
)));
|
|
1153
|
+
}
|
|
1154
|
+
}
|
|
1155
|
+
if let Some(expected) = &condition.expected_lease_id {
|
|
1156
|
+
let lease = current.value.get("lease").filter(|lease| !lease.is_null());
|
|
1157
|
+
let held = lease.and_then(|lease| {
|
|
1158
|
+
lease.get("leaseId").and_then(Value::as_str).map(str::to_string)
|
|
1159
|
+
});
|
|
1160
|
+
// An expired lease is not held, so naming it is a conflict:
|
|
1161
|
+
// the caller believes it owns something it no longer does.
|
|
1162
|
+
let unexpired = lease
|
|
1163
|
+
.and_then(|lease| lease.get("expiresAt").and_then(Value::as_u64))
|
|
1164
|
+
.is_some_and(|expires_at| expires_at > now_ms() as u64);
|
|
1165
|
+
if held.as_deref() != Some(expected.as_str()) || !unexpired {
|
|
1166
|
+
return Err(FlowError::PreconditionFailed(Box::new(
|
|
1167
|
+
PreconditionFailure::Lease {
|
|
1168
|
+
collection,
|
|
1169
|
+
key,
|
|
1170
|
+
expected: expected.clone(),
|
|
1171
|
+
actual: held,
|
|
1172
|
+
},
|
|
1173
|
+
)));
|
|
1174
|
+
}
|
|
1175
|
+
}
|
|
1176
|
+
}
|
|
1177
|
+
|
|
1178
|
+
// Every precondition held. A write fenced by `expected_version` is a
|
|
1179
|
+
// conditional replacement and behaves as `updateIfVersion` does:
|
|
1180
|
+
// the authority validated version N above and writes N + 1 here,
|
|
1181
|
+
// inside the same lock, so the check and the advance cannot be
|
|
1182
|
+
// separated by another writer.
|
|
1183
|
+
//
|
|
1184
|
+
// Only a version-fenced write advances. An unconditional write
|
|
1185
|
+
// stores the caller's object verbatim, which is what preserves
|
|
1186
|
+
// every existing transaction caller, and a guard-only precondition
|
|
1187
|
+
// writes nothing at all. See
|
|
1188
|
+
// docs/architecture/transaction-version-contract.md.
|
|
1189
|
+
let mut advanced: Vec<AtomicMutation> = Vec::new();
|
|
1190
|
+
let fenced: HashMap<(&str, &str), u64> = record_preconditions
|
|
1191
|
+
.iter()
|
|
1192
|
+
.filter_map(|condition| {
|
|
1193
|
+
condition
|
|
1194
|
+
.expected_version
|
|
1195
|
+
.map(|version| ((condition.capability.as_str(), condition.key.as_str()), version))
|
|
1196
|
+
})
|
|
1197
|
+
.collect();
|
|
1198
|
+
let mutations: &[AtomicMutation] = if fenced.is_empty() {
|
|
1199
|
+
mutations
|
|
1200
|
+
} else {
|
|
1201
|
+
advanced = mutations
|
|
1202
|
+
.iter()
|
|
1203
|
+
.map(|mutation| {
|
|
1204
|
+
let Some(expected) =
|
|
1205
|
+
fenced.get(&(mutation.capability.as_str(), mutation.key.as_str()))
|
|
1206
|
+
else {
|
|
1207
|
+
return mutation.clone();
|
|
1208
|
+
};
|
|
1209
|
+
// A delete has no record left to carry a version, so a
|
|
1210
|
+
// fenced delete is fence-then-remove and nothing more.
|
|
1211
|
+
let Some(Value::Object(fields)) = mutation.value.clone() else {
|
|
1212
|
+
return mutation.clone();
|
|
1213
|
+
};
|
|
1214
|
+
let mut fields = fields;
|
|
1215
|
+
// The authority owns the transition. A `__version` the
|
|
1216
|
+
// caller put in the value is replaced rather than
|
|
1217
|
+
// honoured, exactly as updateIfVersion strips it,
|
|
1218
|
+
// because a caller that computes a different next
|
|
1219
|
+
// version must not be able to install it.
|
|
1220
|
+
fields.insert("__version".to_string(), Value::from(expected + 1));
|
|
1221
|
+
AtomicMutation {
|
|
1222
|
+
capability: mutation.capability.clone(),
|
|
1223
|
+
key: mutation.key.clone(),
|
|
1224
|
+
value: Some(Value::Object(fields)),
|
|
1225
|
+
}
|
|
1226
|
+
})
|
|
1227
|
+
.collect();
|
|
1228
|
+
&advanced
|
|
1229
|
+
};
|
|
1230
|
+
|
|
951
1231
|
if let Some(expected) = expected_parent {
|
|
952
1232
|
if expected != inner.sequence {
|
|
953
1233
|
return Err(FlowError::CapabilityError(format!(
|