@feltdb/core 0.6.13 → 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.
Files changed (83) hide show
  1. package/README.md +32 -0
  2. package/dist/agent-registry.js +1 -3
  3. package/dist/agent-runtime.js +8 -7
  4. package/dist/analytics-backend.js +3 -1
  5. package/dist/application-contract.js +1 -0
  6. package/dist/application-manifest.js +1 -0
  7. package/dist/artifact.js +2 -0
  8. package/dist/authorization.js +2 -0
  9. package/dist/bundle.js +2 -0
  10. package/dist/capability.js +1 -3
  11. package/dist/cell.js +9 -4
  12. package/dist/cli/commands.js +26 -2
  13. package/dist/cli/index.js +1 -1
  14. package/dist/collection.js +39 -31
  15. package/dist/create/package-versions.js +1 -1
  16. package/dist/create/server-source/crates/feltdb/src/bin/feltdb_node.rs +613 -27
  17. package/dist/create/server-source/crates/feltdb/src/causal_backlog_bound.rs +452 -0
  18. package/dist/create/server-source/crates/feltdb/src/causal_dependency_barrier.rs +208 -0
  19. package/dist/create/server-source/crates/feltdb/src/convergence.rs +16 -0
  20. package/dist/create/server-source/crates/feltdb/src/dedup_bound_investigation.rs +402 -0
  21. package/dist/create/server-source/crates/feltdb/src/distributed_transactions.rs +784 -24
  22. package/dist/create/server-source/crates/feltdb/src/durable_operation_identity.rs +418 -0
  23. package/dist/create/server-source/crates/feltdb/src/lib.rs +284 -0
  24. package/dist/create/server-source/crates/feltdb/src/replica_acknowledgements.rs +471 -0
  25. package/dist/create/server-source/crates/feltdb/src/replica_membership.rs +661 -0
  26. package/dist/create/server-source/crates/feltdb/src/tcp_transport.rs +72 -3
  27. package/dist/create/server-source/crates/feltdb/src/transaction_preconditions.rs +899 -0
  28. package/dist/create/server-source/crates/feltdb-server/src/main.rs +116 -4
  29. package/dist/db.d.ts +2 -2
  30. package/dist/db.d.ts.map +1 -1
  31. package/dist/db.js +48 -15
  32. package/dist/development-runtime-bridge.js +1 -1
  33. package/dist/distributed-indexing.js +7 -5
  34. package/dist/embedded-transaction.d.ts +9 -0
  35. package/dist/embedded-transaction.d.ts.map +1 -1
  36. package/dist/embedded-transaction.js +95 -3
  37. package/dist/feltdb.d.ts +16 -0
  38. package/dist/feltdb.d.ts.map +1 -1
  39. package/dist/file-db.d.ts.map +1 -1
  40. package/dist/file-db.js +9 -3
  41. package/dist/flowspec.js +2 -1
  42. package/dist/http-client.js +2 -0
  43. package/dist/http-db.d.ts +11 -0
  44. package/dist/http-db.d.ts.map +1 -1
  45. package/dist/http-db.js +32 -3
  46. package/dist/identity.js +1 -0
  47. package/dist/index-analytics.js +6 -7
  48. package/dist/index-backend.js +3 -3
  49. package/dist/index-dashboard.js +10 -13
  50. package/dist/index-manager.js +12 -11
  51. package/dist/index-monitoring.js +9 -4
  52. package/dist/index-store.js +2 -0
  53. package/dist/indexeddb-db.d.ts.map +1 -1
  54. package/dist/indexeddb-db.js +32 -23
  55. package/dist/memory-db.d.ts.map +1 -1
  56. package/dist/memory-db.js +8 -4
  57. package/dist/observe.js +2 -0
  58. package/dist/provider.js +2 -0
  59. package/dist/query-planner.js +2 -4
  60. package/dist/reactive-graph.js +6 -8
  61. package/dist/release.js +2 -0
  62. package/dist/sharding.js +11 -6
  63. package/dist/state-contract.js +3 -3
  64. package/dist/studio-app/assets/{feltdb_wasm-CJv3wHzi.js → feltdb_wasm-CD744e5D.js} +1 -1
  65. package/dist/studio-app/assets/feltdb_wasm_bg-CiIXhOLi.wasm +0 -0
  66. package/dist/studio-app/assets/index-DoROs8yx.js +28 -0
  67. package/dist/studio-app/index.html +1 -1
  68. package/dist/sync-contract.js +9 -2
  69. package/dist/telemetry.d.ts.map +1 -1
  70. package/dist/telemetry.js +32 -12
  71. package/dist/transaction.d.ts +127 -9
  72. package/dist/transaction.d.ts.map +1 -1
  73. package/dist/transaction.js +91 -5
  74. package/dist/wasm/feltdb_wasm_bg.wasm +0 -0
  75. package/dist/worker.js +2 -0
  76. package/dist/workload.js +2 -0
  77. package/dist/workspace/development-node.js +11 -10
  78. package/dist/workspace/investigation-lifecycle-manager.js +2 -0
  79. package/dist/workspace/investigation-supervisor.js +5 -3
  80. package/dist/workspace/workspace-connection.js +14 -5
  81. package/package.json +1 -1
  82. package/dist/studio-app/assets/feltdb_wasm_bg-C8TG8r2n.wasm +0 -0
  83. package/dist/studio-app/assets/index-DospFFYE.js +0 -28
@@ -8,6 +8,7 @@
8
8
  ///
9
9
  /// Invariant: All replicas converge to identical state_hash for same operations
10
10
 
11
+ use crate::causal_dependency_barrier::CausalDependencyBarrier;
11
12
  use crate::convergence::VectorClock;
12
13
  use crate::durable_operation_log::OperationLog;
13
14
  use crate::state_hash::StateHash;
@@ -168,6 +169,124 @@ pub struct DistributedTransactionExecutor {
168
169
  pub envelopes_seen: std::collections::HashSet<EnvelopeId>,
169
170
  pub transaction_executor: TransactionExecutor,
170
171
  pub operation_log: Option<OperationLog>,
172
+ /// Causal delivery gate for replicated envelopes.
173
+ ///
174
+ /// Holds identifiers and dependency clocks only. A blocked envelope's
175
+ /// payload stays in the durable operation log and is replayed from there
176
+ /// when its dependencies land.
177
+ pub causal_barrier: CausalDependencyBarrier,
178
+ /// The bound on the causal backlog.
179
+ pub causal_capacity: CausalCapacity,
180
+ /// The next identity this node will issue for its own operations.
181
+ ///
182
+ /// Recovered from the durable log rather than reset, because
183
+ /// `(origin, sequence)` is an identity and receivers deduplicate on it. A
184
+ /// counter that restarts re-issues identities that already exist, and the
185
+ /// receiver discards the new operation as a duplicate.
186
+ next_origin_sequence: u64,
187
+ /// Envelopes that are durable in the log but not buffered, because the
188
+ /// backlog was at its bound when they arrived.
189
+ ///
190
+ /// Derived state, like `envelopes_seen`: it is rebuilt from the log on
191
+ /// recovery and exists so that a redelivery of a deferred envelope is
192
+ /// recognised without a log scan.
193
+ pub deferred: std::collections::HashSet<EnvelopeId>,
194
+ }
195
+
196
+ /// What the receiver did with a replicated envelope.
197
+ ///
198
+ /// This is a typed outcome rather than a string, because a caller has to be
199
+ /// able to tell "applied" from "held back" from "already known" without
200
+ /// matching on prose. The previous signature returned
201
+ /// `Result<StateHash, String>` and signalled a duplicate as
202
+ /// `Err("Duplicate envelope")`.
203
+ #[derive(Clone, Debug, PartialEq)]
204
+ pub enum ReceiveOutcome {
205
+ /// Dependencies were satisfied; the envelope was applied.
206
+ Applied { state_hash: StateHash },
207
+ /// Durable, but not yet applied: some dependency has not arrived.
208
+ /// The payload is in the operation log; the barrier holds its identity.
209
+ PendingDependencies { missing: Vec<String> },
210
+ /// This envelope has been seen before. Applying it again is a no-op.
211
+ AlreadyKnown,
212
+ /// Durable, but not buffered: the backlog is at its bound.
213
+ ///
214
+ /// This is *not* a refusal and nothing is lost. The envelope was fsynced to
215
+ /// the operation log before this was decided, exactly as a buffered one is;
216
+ /// what is withheld is the in-memory metadata entry. The caller's obligation
217
+ /// is transport backpressure -- stop reading from that peer so TCP blocks
218
+ /// the sender -- because returning a refusal to a peer means either silent
219
+ /// loss or a retransmission protocol this repository does not have.
220
+ ///
221
+ /// The envelope is admitted from the log once the backlog drains.
222
+ CapacityDeferred {
223
+ missing: Vec<String>,
224
+ pending_entries: usize,
225
+ pending_bytes: usize,
226
+ },
227
+ }
228
+
229
+ /// What the executor did with a locally submitted transaction.
230
+ ///
231
+ /// Capacity refusal is a distinct outcome rather than an `Err(String)`, because
232
+ /// a caller has to tell "this replica is saturated, retry" from "this
233
+ /// transaction is invalid". The two need opposite responses and the previous
234
+ /// signature made them the same value.
235
+ #[derive(Clone, Debug)]
236
+ pub enum SubmitOutcome {
237
+ Admitted(TransactionEnvelope),
238
+ /// The causal backlog is at its bound, so no new local work is admitted.
239
+ /// Nothing was executed, persisted, or applied: there is nothing to undo
240
+ /// and nothing to recover.
241
+ CapacityExceeded {
242
+ pending_entries: usize,
243
+ pending_bytes: usize,
244
+ limit_entries: usize,
245
+ limit_bytes: usize,
246
+ },
247
+ }
248
+
249
+ /// The bound on the causal backlog, as decided in
250
+ /// `docs/architecture/causal-backlog-bound.md`.
251
+ ///
252
+ /// Two limits, because they are driven by different variables. Bytes bound the
253
+ /// outstanding payload volume; entries bound the metadata, whose per-entry cost
254
+ /// scales with cluster size rather than with operation size, so a byte bound
255
+ /// alone would score ten thousand pending one-byte operations as nearly free.
256
+ ///
257
+ /// Both are inclusive: the bound is `<=`, so filling exactly to it is accepted.
258
+ #[derive(Clone, Copy, Debug, PartialEq)]
259
+ pub struct CausalCapacity {
260
+ pub max_pending_entries: usize,
261
+ pub max_pending_bytes: usize,
262
+ }
263
+
264
+ impl CausalCapacity {
265
+ /// The shipping default.
266
+ ///
267
+ /// These are policy, not measurements, and are deliberately not derived
268
+ /// from a benchmark constant -- the figure that used to justify backlog
269
+ /// sizing here was `backlog_depth * 1500`, which was the benchmark's own
270
+ /// input rather than an observation. What the acceptance test establishes
271
+ /// is that the bound is *enforced through the production path*, and it
272
+ /// reports the occupancy it actually observed rather than asserting a
273
+ /// predicted footprint.
274
+ pub const DEFAULT_MAX_PENDING_ENTRIES: usize = 10_000;
275
+ pub const DEFAULT_MAX_PENDING_BYTES: usize = 64 * 1024 * 1024;
276
+
277
+ /// A bound small enough to be reached by a test through the real path.
278
+ pub fn new(max_pending_entries: usize, max_pending_bytes: usize) -> Self {
279
+ Self { max_pending_entries, max_pending_bytes }
280
+ }
281
+ }
282
+
283
+ impl Default for CausalCapacity {
284
+ fn default() -> Self {
285
+ Self {
286
+ max_pending_entries: Self::DEFAULT_MAX_PENDING_ENTRIES,
287
+ max_pending_bytes: Self::DEFAULT_MAX_PENDING_BYTES,
288
+ }
289
+ }
171
290
  }
172
291
 
173
292
  impl DistributedTransactionExecutor {
@@ -185,6 +304,10 @@ impl DistributedTransactionExecutor {
185
304
  envelopes_seen: std::collections::HashSet::new(),
186
305
  transaction_executor: TransactionExecutor::new(node_id),
187
306
  operation_log: None,
307
+ causal_barrier: CausalDependencyBarrier::new(),
308
+ causal_capacity: CausalCapacity::default(),
309
+ next_origin_sequence: 1,
310
+ deferred: std::collections::HashSet::new(),
188
311
  }
189
312
  }
190
313
 
@@ -209,12 +332,25 @@ impl DistributedTransactionExecutor {
209
332
  None
210
333
  };
211
334
 
335
+ // Identity is recovered here as well as in `load_from_disk`, because a
336
+ // caller that opens an existing log and issues an operation without
337
+ // replaying it must still not re-issue an identity that log already
338
+ // contains.
339
+ let next_origin_sequence = match &operation_log {
340
+ Some(log) => Self::next_sequence_after(log, &node_id)?,
341
+ None => 1,
342
+ };
343
+
212
344
  Ok(Self {
213
345
  local_node_id: node_id.clone(),
214
346
  replicas,
215
347
  envelopes_seen: std::collections::HashSet::new(),
216
348
  transaction_executor: TransactionExecutor::new(node_id),
217
349
  operation_log,
350
+ causal_barrier: CausalDependencyBarrier::new(),
351
+ causal_capacity: CausalCapacity::default(),
352
+ next_origin_sequence,
353
+ deferred: std::collections::HashSet::new(),
218
354
  })
219
355
  }
220
356
 
@@ -228,6 +364,166 @@ impl DistributedTransactionExecutor {
228
364
 
229
365
  /// Execute transaction locally and prepare for replication
230
366
  /// Ordering: execute → persist → update state → record dedup
367
+ // ---------------------------------------------------------------------
368
+ // Durable operation identity
369
+ //
370
+ // `(origin, sequence)` is an identity, not a counter. Receivers
371
+ // deduplicate on it, so two operations must never be assigned the same
372
+ // one -- and "must never" has to hold across a process restart, which is
373
+ // where it previously did not: `feltdb_node` kept the counter in memory
374
+ // and started it at 1, so a restarted origin re-issued identities its own
375
+ // earlier operations already held and its peers discarded the new writes
376
+ // as duplicates. See `dedup_bound_investigation.rs`.
377
+ //
378
+ // The authority is the durable log, and specifically the log rather than
379
+ // the causal clock. Those are different identity domains: the clock
380
+ // component advances only on a successful apply, while the sequence is
381
+ // burned by an attempt, so the two disagree by exactly the number of
382
+ // refused transactions. `replication-convergence.md` fixes that the
383
+ // sequence is sparse and that making it dense is a separate deferred
384
+ // decision, so this derives identity from the sequence domain and leaves
385
+ // that decision alone.
386
+ // ---------------------------------------------------------------------
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
+
407
+ /// The next identity this node would issue.
408
+ pub fn next_origin_sequence(&self) -> u64 {
409
+ self.next_origin_sequence
410
+ }
411
+
412
+ /// Claim the next identity for an operation this node originates.
413
+ ///
414
+ /// Advancing on the attempt rather than on success is deliberate: it
415
+ /// preserves the sparse-sequence semantics the convergence contract froze,
416
+ /// where a refused transaction burns a number. Making the sequence dense is
417
+ /// a separate decision and is not made here as a side effect.
418
+ ///
419
+ /// A number burned and then lost to a crash is safe to re-issue, because
420
+ /// nothing carrying it was ever persisted: recovery derives from what the
421
+ /// log holds, so an identity is only reserved forever once an envelope
422
+ /// bearing it is durable. That is the invariant -- no two *persisted*
423
+ /// envelopes from one origin share a sequence -- and it is the one
424
+ /// receivers depend on.
425
+ pub fn allocate_origin_sequence(&mut self) -> u64 {
426
+ let claimed = self.next_origin_sequence;
427
+ self.next_origin_sequence = claimed.saturating_add(1);
428
+ claimed
429
+ }
430
+
431
+ /// Derive the next identity from a durable log: one past the highest this
432
+ /// node has already committed to it.
433
+ ///
434
+ /// Filtered to locally-originated envelopes, because the log also holds
435
+ /// replicated ones and another node's sequence says nothing about this
436
+ /// node's identity space.
437
+ fn next_sequence_after(log: &OperationLog, node_id: &str) -> Result<u64, std::io::Error> {
438
+ let highest = log
439
+ .load_all()?
440
+ .iter()
441
+ .filter(|envelope| envelope.envelope_id.originating_node == node_id)
442
+ .map(|envelope| envelope.envelope_id.sequence)
443
+ .max()
444
+ .unwrap_or(0);
445
+ Ok(highest.saturating_add(1))
446
+ }
447
+
448
+ /// Submit local work, allocating its identity from durable state.
449
+ ///
450
+ /// This is the entry point a node should use: it owns the allocation, so
451
+ /// the identity cannot be assigned by one caller and persisted by another,
452
+ /// and there is no counter outside the executor to fall out of step with
453
+ /// the log.
454
+ pub fn submit_local_transaction(
455
+ &mut self,
456
+ transaction_id: String,
457
+ parent_version: StateVersion,
458
+ operations: Vec<Operation>,
459
+ consistency_contract: ConsistencyContract,
460
+ ) -> Result<SubmitOutcome, String> {
461
+ // Allocation happens after the capacity check inside
462
+ // `try_execute_local_transaction`, so a refusal does not burn an
463
+ // identity: nothing was admitted, and there is nothing to account for.
464
+ if self.would_breach_bound(Self::operations_bytes(&operations)) {
465
+ return Ok(SubmitOutcome::CapacityExceeded {
466
+ pending_entries: self.causal_barrier.pending_count(),
467
+ pending_bytes: self.causal_barrier.pending_payload_bytes(),
468
+ limit_entries: self.causal_capacity.max_pending_entries,
469
+ limit_bytes: self.causal_capacity.max_pending_bytes,
470
+ });
471
+ }
472
+ let sequence = self.allocate_origin_sequence();
473
+ self.execute_local_transaction(
474
+ sequence,
475
+ transaction_id,
476
+ parent_version,
477
+ operations,
478
+ consistency_contract,
479
+ )
480
+ .map(SubmitOutcome::Admitted)
481
+ }
482
+
483
+ /// Submit local work, subject to the causal backlog bound.
484
+ ///
485
+ /// The bound is checked *before* anything is executed, persisted or
486
+ /// applied, so a refusal leaves nothing to undo and nothing to recover:
487
+ /// the operation was never admitted. That is what makes refusal the right
488
+ /// answer on this path and the wrong one for a replicated arrival, which
489
+ /// was already admitted and made durable somewhere else.
490
+ pub fn try_execute_local_transaction(
491
+ &mut self,
492
+ sequence: u64,
493
+ transaction_id: String,
494
+ parent_version: StateVersion,
495
+ operations: Vec<Operation>,
496
+ consistency_contract: ConsistencyContract,
497
+ ) -> Result<SubmitOutcome, String> {
498
+ let projected_bytes = Self::operations_bytes(&operations);
499
+ if self.would_breach_bound(projected_bytes) {
500
+ return Ok(SubmitOutcome::CapacityExceeded {
501
+ pending_entries: self.causal_barrier.pending_count(),
502
+ pending_bytes: self.causal_barrier.pending_payload_bytes(),
503
+ limit_entries: self.causal_capacity.max_pending_entries,
504
+ limit_bytes: self.causal_capacity.max_pending_bytes,
505
+ });
506
+ }
507
+ self.execute_local_transaction(
508
+ sequence,
509
+ transaction_id,
510
+ parent_version,
511
+ operations,
512
+ consistency_contract,
513
+ )
514
+ .map(SubmitOutcome::Admitted)
515
+ }
516
+
517
+ /// The size local work would contribute, measured before it is executed.
518
+ fn operations_bytes(operations: &[Operation]) -> usize {
519
+ serde_json::to_string(operations).map(|json| json.len()).unwrap_or(0)
520
+ }
521
+
522
+ /// Execute local work unconditionally.
523
+ ///
524
+ /// This does not consult the backlog bound; `try_execute_local_transaction`
525
+ /// is the admitting entry point and the one the node binary uses. This
526
+ /// remains for callers that have already made the admission decision.
231
527
  pub fn execute_local_transaction(
232
528
  &mut self,
233
529
  sequence: u64,
@@ -249,7 +545,7 @@ impl DistributedTransactionExecutor {
249
545
  }
250
546
 
251
547
  // Step 2: Create envelope for replication
252
- let envelope = TransactionEnvelope::new(
548
+ let mut envelope = TransactionEnvelope::new(
253
549
  self.local_node_id.clone(),
254
550
  sequence,
255
551
  transaction_id,
@@ -257,6 +553,19 @@ impl DistributedTransactionExecutor {
257
553
  consistency_contract,
258
554
  );
259
555
 
556
+ // Step 2a: Stamp the envelope with this node's causal knowledge.
557
+ //
558
+ // `TransactionEnvelope::new` starts from an empty clock and increments
559
+ // only the origin, so every envelope it built carried {origin: 1}
560
+ // whatever the node had already seen. That is a position, not a
561
+ // history: a receiver given those clocks has no dependencies to
562
+ // enforce, which is why causal ordering could not have worked on this
563
+ // path even once the barrier was wired to it. The clock must carry the
564
+ // applied frontier this operation was produced on top of.
565
+ let mut stamped = self.causal_barrier.get_frontier().clone();
566
+ stamped.increment(&self.local_node_id);
567
+ envelope.vector_clock = stamped;
568
+
260
569
  // Step 3: Durably persist operation to log (with fsync)
261
570
  // This must succeed before we update in-memory state
262
571
  self.persist_envelope(&envelope)?;
@@ -270,6 +579,12 @@ impl DistributedTransactionExecutor {
270
579
  // Step 5: Record envelope to prevent duplicates (in-memory, derived from log)
271
580
  self.envelopes_seen.insert(envelope.envelope_id.clone());
272
581
 
582
+ // Step 6: A locally executed operation is applied, so it advances the
583
+ // frontier exactly as a replicated one does. Own writes are always
584
+ // causally eligible: they are built on the frontier they advance.
585
+ self.causal_barrier
586
+ .record_applied(&Self::barrier_key(&envelope.envelope_id), &envelope.vector_clock);
587
+
273
588
  Ok(envelope)
274
589
  }
275
590
 
@@ -280,37 +595,419 @@ impl DistributedTransactionExecutor {
280
595
  message: ReplicationMessage,
281
596
  parent_version: StateVersion,
282
597
  ) -> Result<StateHash, String> {
283
- // Step 1: Check for duplicate (prevent re-application)
284
- if self.envelopes_seen.contains(&message.envelope.envelope_id) {
285
- return Err("Duplicate envelope".to_string());
598
+ match self.receive_replicated(message, parent_version)? {
599
+ ReceiveOutcome::Applied { state_hash } => Ok(state_hash),
600
+ ReceiveOutcome::PendingDependencies { missing } => Err(format!(
601
+ "Envelope is durable but waiting on dependencies: {}",
602
+ missing.join(", ")
603
+ )),
604
+ ReceiveOutcome::AlreadyKnown => Err("Duplicate envelope".to_string()),
605
+ ReceiveOutcome::CapacityDeferred { missing, .. } => Err(format!(
606
+ "CAPACITY_DEFERRED: envelope is durable but the causal backlog is at its bound, waiting on: {}",
607
+ missing.join(", ")
608
+ )),
609
+ }
610
+ }
611
+
612
+ /// The causal receive path.
613
+ ///
614
+ /// Ordering is deliberate and is the substance of this change:
615
+ ///
616
+ /// dedup -> persist -> causal eligibility -> apply -> release
617
+ ///
618
+ /// Persisting *before* deciding eligibility is what makes "held back" a
619
+ /// durable state rather than an in-memory one. An envelope that arrives
620
+ /// ahead of its dependencies is fsynced to the operation log and then
621
+ /// retained in the barrier by identity alone; if the process dies at that
622
+ /// moment, recovery replays the log and rediscovers it as pending. This is
623
+ /// also what catch-up will require, because a peer sending A3, A1, A2 in
624
+ /// transport order needs the receiver to be able to say "I have A3
625
+ /// durably, and I cannot apply it yet".
626
+ pub fn receive_replicated(
627
+ &mut self,
628
+ message: ReplicationMessage,
629
+ parent_version: StateVersion,
630
+ ) -> Result<ReceiveOutcome, String> {
631
+ let envelope_id = message.envelope.envelope_id.clone();
632
+ let key = Self::barrier_key(&envelope_id);
633
+
634
+ // Step 1: Duplicate delivery is a no-op, whether the original was
635
+ // applied or is still waiting. Both states are recorded before this
636
+ // point is reached again, so one logical operation stays one.
637
+ if self.envelopes_seen.contains(&envelope_id)
638
+ || self.causal_barrier.is_pending(&key)
639
+ || self.deferred.contains(&envelope_id)
640
+ {
641
+ return Ok(ReceiveOutcome::AlreadyKnown);
642
+ }
643
+
644
+ // Step 2: Durability first. The log is the source of truth, so an
645
+ // envelope becomes durable on receipt and not on application.
646
+ self.persist_envelope(&message.envelope)?;
647
+
648
+ // Step 3: Eligibility. The dependencies are everything the origin had
649
+ // seen when it produced this operation, which is its clock with its own
650
+ // entry stepped back by one; the operation itself is not its own
651
+ // dependency.
652
+ let dependencies = Self::dependencies_of(&message.envelope);
653
+ if !self.causal_barrier.is_eligible(&dependencies) {
654
+ let missing = self.missing_dependencies(&dependencies);
655
+
656
+ // Step 3a: The bound. Buffering costs an entry and holds a payload
657
+ // outstanding in the log; if either would breach its limit, the
658
+ // metadata entry is withheld and the envelope stays durable and
659
+ // deferred. It is never refused and never evicted -- refusing a
660
+ // peer means silent loss or a retransmission protocol that does not
661
+ // exist here, and eviction is silent loss under another name.
662
+ let payload_bytes = Self::envelope_bytes(&message.envelope);
663
+ if self.would_breach_bound(payload_bytes) {
664
+ self.deferred.insert(envelope_id);
665
+ return Ok(ReceiveOutcome::CapacityDeferred {
666
+ missing,
667
+ pending_entries: self.causal_barrier.pending_count(),
668
+ pending_bytes: self.causal_barrier.pending_payload_bytes(),
669
+ });
670
+ }
671
+
672
+ self.causal_barrier
673
+ .retain_pending_sized(key, dependencies, payload_bytes);
674
+ return Ok(ReceiveOutcome::PendingDependencies { missing });
286
675
  }
287
676
 
288
- // Step 2: Execute transaction through 14C
677
+ // Step 4: Apply, which advances the frontier.
678
+ let state_hash = self.apply_envelope(&message.envelope, parent_version.clone())?;
679
+
680
+ // Step 5: This application may have unblocked others, and may have
681
+ // freed the capacity a deferred envelope was waiting for. Drain both.
682
+ self.drain(parent_version)?;
683
+
684
+ Ok(ReceiveOutcome::Applied { state_hash })
685
+ }
686
+
687
+ /// Drive the backlog forward from outside the receive path.
688
+ ///
689
+ /// A deferred envelope is durable but unbuffered, and the event that frees
690
+ /// capacity for it may arrive on a different connection than the one that
691
+ /// was deferred. A caller applying transport backpressure needs a way to
692
+ /// make progress without reading from the backpressured peer, and this is
693
+ /// it.
694
+ pub fn drain_backlog(&mut self, parent_version: StateVersion) -> Result<usize, String> {
695
+ self.drain(parent_version)
696
+ }
697
+
698
+ /// Whether admitting one more entry of this size would breach either bound.
699
+ ///
700
+ /// Inclusive, as the contract specifies: filling exactly to the bound is
701
+ /// accepted, and the breach is the step past it.
702
+ fn would_breach_bound(&self, payload_bytes: usize) -> bool {
703
+ let entries = self.causal_barrier.pending_count();
704
+ let bytes = self.causal_barrier.pending_payload_bytes();
705
+ entries + 1 > self.causal_capacity.max_pending_entries
706
+ || bytes + payload_bytes > self.causal_capacity.max_pending_bytes
707
+ }
708
+
709
+ /// The envelope's size as the durable log stores it.
710
+ ///
711
+ /// Measured with the log's own serialisation rather than a nominal
712
+ /// per-operation constant, so a large envelope consumes its actual byte
713
+ /// count.
714
+ fn envelope_bytes(envelope: &TransactionEnvelope) -> usize {
715
+ serde_json::to_string(envelope).map(|json| json.len()).unwrap_or(0)
716
+ }
717
+
718
+ /// Apply one envelope and advance the applied frontier.
719
+ fn apply_envelope(
720
+ &mut self,
721
+ envelope: &TransactionEnvelope,
722
+ parent_version: StateVersion,
723
+ ) -> Result<StateHash, String> {
289
724
  let transition = self.transaction_executor.execute_transaction(
290
- message.envelope.transaction_id.clone(),
725
+ envelope.transaction_id.clone(),
291
726
  parent_version,
292
- message.envelope.operations.clone(),
293
- message.envelope.consistency_contract.clone(),
727
+ envelope.operations.clone(),
728
+ envelope.consistency_contract.clone(),
294
729
  );
295
730
 
296
731
  if !transition.is_successful() {
297
732
  return Err("Transaction execution failed".to_string());
298
733
  }
299
734
 
300
- // Step 3: Durably persist operation to log (with fsync)
301
- self.persist_envelope(&message.envelope)?;
302
-
303
- // Step 4: Record envelope to prevent duplicates (in-memory)
304
- self.envelopes_seen.insert(message.envelope.envelope_id.clone());
735
+ self.envelopes_seen.insert(envelope.envelope_id.clone());
305
736
 
306
- // Step 5: Update this replica's state (receiver updates itself)
737
+ let state_hash = StateHash::from_hex(transition.to_version.state_hash.clone());
307
738
  if let Some(replica) = self.replicas.get_mut(&self.local_node_id) {
308
- let state_hash = StateHash::from_hex(transition.to_version.state_hash.clone());
309
- replica.apply_transaction(&message.envelope, state_hash);
739
+ replica.apply_transaction(envelope, state_hash.clone());
740
+ }
741
+
742
+ // The frontier advances here and only here, so it describes what has
743
+ // been successfully applied rather than what was claimed or received.
744
+ self.causal_barrier
745
+ .record_applied(&Self::barrier_key(&envelope.envelope_id), &envelope.vector_clock);
746
+
747
+ Ok(state_hash)
748
+ }
749
+
750
+ /// Drive the backlog forward until it stops moving.
751
+ ///
752
+ /// Two things can make progress and each can enable the other: applying a
753
+ /// pending envelope advances the frontier, which may make other pending
754
+ /// envelopes eligible; and applying anything frees capacity, which may let
755
+ /// a deferred envelope be admitted from the log. So this alternates until
756
+ /// neither step changes anything, which terminates because every iteration
757
+ /// that continues has strictly reduced the number of unapplied envelopes.
758
+ fn drain(&mut self, parent_version: StateVersion) -> Result<usize, String> {
759
+ let mut progressed_total = 0;
760
+ loop {
761
+ let released = self.release_eligible(parent_version.clone())?;
762
+ let admitted = self.admit_deferred(parent_version.clone())?;
763
+ progressed_total += released + admitted;
764
+ if released == 0 && admitted == 0 {
765
+ return Ok(progressed_total);
766
+ }
767
+ }
768
+ }
769
+
770
+ /// Apply every pending envelope the frontier now satisfies, repeatedly,
771
+ /// because releasing one can make the next eligible.
772
+ ///
773
+ /// The log is read once per pass rather than once per key. It used to be
774
+ /// once per key: `replay_from_log` called `load_all()` for every eligible
775
+ /// entry, so a release cascade over a backlog of n entries against a log of
776
+ /// m records did n full scans. That is the exact shape of load this change
777
+ /// makes reachable -- a bounded backlog is one that is allowed to get
778
+ /// large -- so the quadratic scan had to go with it.
779
+ fn release_eligible(&mut self, parent_version: StateVersion) -> Result<usize, String> {
780
+ let mut released = 0;
781
+ loop {
782
+ let ready = self.causal_barrier.eligible_pending();
783
+ if ready.is_empty() {
784
+ return Ok(released);
785
+ }
786
+ let by_key = self.log_by_key()?;
787
+ let mut progressed = false;
788
+ for key in ready {
789
+ let Some(envelope) = by_key.get(&key) else {
790
+ // The barrier holds an identity whose payload is not in the
791
+ // log. Dropping the entry would be silent loss, so leave it
792
+ // pending and surface it rather than pretend it applied.
793
+ continue;
794
+ };
795
+ self.apply_envelope(&envelope.clone(), parent_version.clone())?;
796
+ released += 1;
797
+ progressed = true;
798
+ }
799
+ if !progressed {
800
+ return Ok(released);
801
+ }
802
+ }
803
+ }
804
+
805
+ /// Move envelopes that are durable but unbuffered into the backlog, as far
806
+ /// as the bound now allows.
807
+ ///
808
+ /// This is what makes `CapacityDeferred` safe: the envelope was fsynced
809
+ /// before it was deferred, so admitting it later needs no cooperation from
810
+ /// the sender and survives a restart in between. Entries are admitted in
811
+ /// key order so that two replicas draining the same set drain it
812
+ /// identically -- behaviour at capacity must not depend on iteration order
813
+ /// or timing.
814
+ fn admit_deferred(&mut self, parent_version: StateVersion) -> Result<usize, String> {
815
+ if self.deferred.is_empty() {
816
+ return Ok(0);
817
+ }
818
+ let by_key = self.log_by_key()?;
819
+ let mut keys: Vec<String> = self
820
+ .deferred
821
+ .iter()
822
+ .map(Self::barrier_key)
823
+ .collect();
824
+ keys.sort();
825
+
826
+ let mut admitted = 0;
827
+ for key in keys {
828
+ let Some(envelope) = by_key.get(&key).cloned() else { continue };
829
+ let dependencies = Self::dependencies_of(&envelope);
830
+ let payload_bytes = Self::envelope_bytes(&envelope);
831
+
832
+ if self.causal_barrier.is_eligible(&dependencies) {
833
+ self.deferred.remove(&envelope.envelope_id);
834
+ self.apply_envelope(&envelope, parent_version.clone())?;
835
+ admitted += 1;
836
+ continue;
837
+ }
838
+ if self.would_breach_bound(payload_bytes) {
839
+ // Still no room. Leave it deferred; it is durable and will be
840
+ // reconsidered on the next drain or after a restart.
841
+ continue;
842
+ }
843
+ self.deferred.remove(&envelope.envelope_id);
844
+ self.causal_barrier
845
+ .retain_pending_sized(key, dependencies, payload_bytes);
846
+ admitted += 1;
310
847
  }
848
+ Ok(admitted)
849
+ }
850
+
851
+ /// The durable log, indexed by barrier key.
852
+ ///
853
+ /// This is why the barrier never needs the payload: it holds the identity,
854
+ /// and the bytes stay where they were already fsynced.
855
+ fn log_by_key(&self) -> Result<HashMap<String, TransactionEnvelope>, String> {
856
+ let Some(ref log) = self.operation_log else {
857
+ return Ok(HashMap::new());
858
+ };
859
+ let envelopes = log
860
+ .load_all()
861
+ .map_err(|e| format!("Failed to replay operation log: {}", e))?;
862
+ Ok(envelopes
863
+ .into_iter()
864
+ .map(|envelope| (Self::barrier_key(&envelope.envelope_id), envelope))
865
+ .collect())
866
+ }
867
+
868
+ /// The stable string identity the barrier is keyed by.
869
+ fn barrier_key(envelope_id: &EnvelopeId) -> String {
870
+ format!("{}:{}", envelope_id.originating_node, envelope_id.sequence)
871
+ }
872
+
873
+ /// The causal dependencies of an envelope: what its origin had applied
874
+ /// before producing it.
875
+ fn dependencies_of(envelope: &TransactionEnvelope) -> VectorClock {
876
+ let mut dependencies = envelope.vector_clock.clone();
877
+ let own = dependencies.get(&envelope.originating_node);
878
+ dependencies
879
+ .clocks
880
+ .insert(envelope.originating_node.clone(), own.saturating_sub(1));
881
+ dependencies
882
+ }
883
+
884
+ /// Which dimensions of a dependency clock the frontier has not reached.
885
+ fn missing_dependencies(&self, dependencies: &VectorClock) -> Vec<String> {
886
+ let frontier = self.causal_barrier.get_frontier();
887
+ let mut missing: Vec<String> = dependencies
888
+ .clocks
889
+ .iter()
890
+ .filter(|(node, required)| frontier.get(node) < **required)
891
+ .map(|(node, required)| {
892
+ format!("{}@{} (have {})", node, required, frontier.get(node))
893
+ })
894
+ .collect();
895
+ missing.sort();
896
+ missing
897
+ }
898
+
899
+ /// How many envelopes are durable but waiting on dependencies.
900
+ ///
901
+ /// Instrumentation only; no limit is enforced. See
902
+ /// docs/architecture/causal-backlog-bound.md.
903
+ pub fn pending_causal_count(&self) -> usize {
904
+ self.causal_barrier.pending_count()
905
+ }
906
+
907
+ /// Approximate heap cost of the pending causal metadata, in bytes.
908
+ pub fn pending_causal_metadata_bytes(&self) -> usize {
909
+ self.causal_barrier.pending_metadata_bytes()
910
+ }
911
+
912
+ /// Durable payload bytes the backlog is waiting on. The byte bound is
913
+ /// expressed in these.
914
+ pub fn pending_causal_payload_bytes(&self) -> usize {
915
+ self.causal_barrier.pending_payload_bytes()
916
+ }
917
+
918
+ /// Envelopes that are durable but not buffered, because the bound was
919
+ /// reached when they arrived. They are not lost and not evicted.
920
+ pub fn deferred_causal_count(&self) -> usize {
921
+ self.deferred.len()
922
+ }
923
+
924
+ /// Keys of envelopes that are durable but not buffered, sorted.
925
+ pub fn deferred_causal_keys(&self) -> Vec<String> {
926
+ let mut keys: Vec<String> = self.deferred.iter().map(Self::barrier_key).collect();
927
+ keys.sort();
928
+ keys
929
+ }
930
+
931
+ /// Maximum occupancy ever reached, for entries and payload bytes.
932
+ ///
933
+ /// Reported alongside the current values because a backlog that filled and
934
+ /// drained between two reads is invisible to a current-value gauge, and the
935
+ /// question an operator has is whether the bound was ever approached.
936
+ pub fn max_causal_occupancy(&self) -> (usize, usize) {
937
+ (
938
+ self.causal_barrier.max_pending_entries_seen(),
939
+ self.causal_barrier.max_pending_payload_bytes_seen(),
940
+ )
941
+ }
942
+
943
+ /// The bound in force on this executor.
944
+ pub fn causal_capacity(&self) -> CausalCapacity {
945
+ self.causal_capacity
946
+ }
311
947
 
312
- // Return the state hash
313
- Ok(StateHash::from_hex(transition.to_version.state_hash))
948
+ /// Set the bound. Used by the node binary so an acceptance test can reach
949
+ /// the bound through the production path rather than by constructing a
950
+ /// barrier directly.
951
+ pub fn set_causal_capacity(&mut self, capacity: CausalCapacity) {
952
+ self.causal_capacity = capacity;
953
+ }
954
+
955
+ /// The applied causal frontier: what this replica has successfully applied.
956
+ pub fn causal_frontier(&self) -> &VectorClock {
957
+ self.causal_barrier.get_frontier()
958
+ }
959
+
960
+ /// Identities of envelopes durable but not yet applied.
961
+ pub fn pending_causal_keys(&self) -> Vec<String> {
962
+ self.causal_barrier.pending_keys()
963
+ }
964
+
965
+ /// Materialize the applied records, order-independently.
966
+ ///
967
+ /// This exists because the chained `H(parent, command)` state hash is a
968
+ /// missing-operation detector for an ordered history, not a convergence
969
+ /// identity: two replicas that applied the same set of concurrent
970
+ /// operations in different orders hold the same data and report different
971
+ /// chained hashes. `CanonicalState` is a BTreeMap keyed by
972
+ /// (collection, record_id), so its hash depends on the resulting records
973
+ /// and not on the path taken to them, which is what "the replicas agree"
974
+ /// should mean.
975
+ ///
976
+ /// Built from the durable log, filtered to what the barrier records as
977
+ /// applied and taken in application order, so a pending envelope's
978
+ /// operations are excluded until it is actually released.
979
+ pub fn canonical_state(&self) -> Result<crate::state_hash::CanonicalState, String> {
980
+ let mut state = crate::state_hash::CanonicalState::new();
981
+ let Some(ref log) = self.operation_log else {
982
+ return Ok(state);
983
+ };
984
+ let envelopes = log
985
+ .load_all()
986
+ .map_err(|e| format!("Failed to read operation log: {}", e))?;
987
+ let by_key: HashMap<String, &TransactionEnvelope> = envelopes
988
+ .iter()
989
+ .map(|envelope| (Self::barrier_key(&envelope.envelope_id), envelope))
990
+ .collect();
991
+
992
+ for key in self.causal_barrier.applied_keys() {
993
+ let Some(envelope) = by_key.get(key) else {
994
+ continue;
995
+ };
996
+ for operation in &envelope.operations {
997
+ let command = &operation.command;
998
+ let fields: serde_json::Map<String, serde_json::Value> = command
999
+ .fields
1000
+ .iter()
1001
+ .map(|(name, value)| (name.clone(), value.clone()))
1002
+ .collect();
1003
+ state.set_record(
1004
+ command.collection.clone(),
1005
+ command.record_id.clone(),
1006
+ serde_json::Value::Object(fields),
1007
+ );
1008
+ }
1009
+ }
1010
+ Ok(state)
314
1011
  }
315
1012
 
316
1013
  /// Get current state of a replica
@@ -366,14 +1063,77 @@ impl DistributedTransactionExecutor {
366
1063
  let log = OperationLog::new(&log_path)?;
367
1064
  let envelopes = log.load_all()?;
368
1065
 
369
- // Reconstruct dedup set from loaded envelopes (deterministic, atomic with log)
370
- for envelope in &envelopes {
371
- self.envelopes_seen.insert(envelope.envelope_id.clone());
1066
+ self.operation_log = Some(log);
1067
+
1068
+ // Rebuild the causal state, not just the dedup set.
1069
+ //
1070
+ // The log records what was *received*, in arrival order, and arrival
1071
+ // order is not causal order -- that is the whole reason the barrier
1072
+ // exists. So recovery cannot simply mark everything seen: it has to
1073
+ // replay eligibility, exactly as the live path does, and come out with
1074
+ // the same split between applied and still-pending. This is what makes
1075
+ // "deliver B, restart, deliver A" work: B is durable across the
1076
+ // restart, is rediscovered as pending rather than as applied, and is
1077
+ // released when A finally arrives.
1078
+ let mut undecided: Vec<TransactionEnvelope> = envelopes.clone();
1079
+ loop {
1080
+ let mut progressed = false;
1081
+ let mut still_undecided = Vec::new();
1082
+ for envelope in undecided.into_iter() {
1083
+ let dependencies = Self::dependencies_of(&envelope);
1084
+ if self.causal_barrier.is_eligible(&dependencies) {
1085
+ self.envelopes_seen.insert(envelope.envelope_id.clone());
1086
+ self.causal_barrier.record_applied(
1087
+ &Self::barrier_key(&envelope.envelope_id),
1088
+ &envelope.vector_clock,
1089
+ );
1090
+ progressed = true;
1091
+ } else {
1092
+ still_undecided.push(envelope);
1093
+ }
1094
+ }
1095
+ undecided = still_undecided;
1096
+ if !progressed || undecided.is_empty() {
1097
+ break;
1098
+ }
372
1099
  }
373
1100
 
374
- // Note: Replica state is not rebuilt from log in this initial version
375
- // It will be recovered through normal replication when this node rejoins the cluster
376
- self.operation_log = Some(log);
1101
+ // Whatever could not be made eligible is durable and waiting, and is
1102
+ // restored to the barrier in that state -- as far as the bound allows.
1103
+ //
1104
+ // Recovery has to respect the same bound the live path does, or a node
1105
+ // could restart into an over-full backlog that it would never have
1106
+ // accepted while running, and the bound would hold only until the first
1107
+ // crash. Entries are restored in key order so that recovery is
1108
+ // deterministic: the same log and the same bound produce the same split
1109
+ // between pending and deferred every time.
1110
+ undecided.sort_by_key(|envelope| Self::barrier_key(&envelope.envelope_id));
1111
+ for envelope in undecided {
1112
+ let dependencies = Self::dependencies_of(&envelope);
1113
+ let payload_bytes = Self::envelope_bytes(&envelope);
1114
+ if self.would_breach_bound(payload_bytes) {
1115
+ self.deferred.insert(envelope.envelope_id.clone());
1116
+ continue;
1117
+ }
1118
+ self.causal_barrier.retain_pending_sized(
1119
+ Self::barrier_key(&envelope.envelope_id),
1120
+ dependencies,
1121
+ payload_bytes,
1122
+ );
1123
+ }
1124
+
1125
+ // Identity is recovered from the same log, and this is the half that
1126
+ // was missing: the causal frontier was already rebuilt here, so a
1127
+ // restarted origin came back knowing its causal position and not its
1128
+ // identity position, and issued colliding identities on top of a
1129
+ // correct clock.
1130
+ self.next_origin_sequence = envelopes
1131
+ .iter()
1132
+ .filter(|envelope| envelope.envelope_id.originating_node == self.local_node_id)
1133
+ .map(|envelope| envelope.envelope_id.sequence)
1134
+ .max()
1135
+ .unwrap_or(0)
1136
+ .saturating_add(1);
377
1137
 
378
1138
  Ok(envelopes.len())
379
1139
  }