@feltdb/core 0.6.12 → 0.6.14

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 (69) hide show
  1. package/dist/agent-registry.js +1 -3
  2. package/dist/agent-runtime.js +8 -7
  3. package/dist/analytics-backend.js +3 -1
  4. package/dist/application-contract.js +1 -0
  5. package/dist/application-manifest.js +1 -0
  6. package/dist/artifact.js +2 -0
  7. package/dist/authorization.js +2 -0
  8. package/dist/bundle.js +2 -0
  9. package/dist/capability.js +1 -3
  10. package/dist/cell.js +9 -4
  11. package/dist/cli/application.js +1 -1
  12. package/dist/cli/browser-opening.js +35 -0
  13. package/dist/cli/commands.js +50 -17
  14. package/dist/cli/index.js +1 -1
  15. package/dist/collection.js +39 -31
  16. package/dist/create/cli.js +7 -5
  17. package/dist/create/create.js +9 -7
  18. package/dist/create/development-handoff.js +4 -0
  19. package/dist/create/docker-compose-generator.js +4 -4
  20. package/dist/create/package-versions.js +1 -1
  21. package/dist/create/server-source/crates/feltdb/src/bin/feltdb_node.rs +497 -27
  22. package/dist/create/server-source/crates/feltdb/src/causal_backlog_bound.rs +452 -0
  23. package/dist/create/server-source/crates/feltdb/src/causal_dependency_barrier.rs +208 -0
  24. package/dist/create/server-source/crates/feltdb/src/convergence.rs +16 -0
  25. package/dist/create/server-source/crates/feltdb/src/dedup_bound_investigation.rs +402 -0
  26. package/dist/create/server-source/crates/feltdb/src/distributed_transactions.rs +765 -24
  27. package/dist/create/server-source/crates/feltdb/src/durable_operation_identity.rs +418 -0
  28. package/dist/create/server-source/crates/feltdb/src/lib.rs +4 -0
  29. package/dist/create/server-source/crates/feltdb/src/replica_membership.rs +661 -0
  30. package/dist/db.js +19 -13
  31. package/dist/development-runtime-bridge.js +1 -1
  32. package/dist/distributed-indexing.js +7 -5
  33. package/dist/file-db.js +8 -3
  34. package/dist/flowspec.js +2 -1
  35. package/dist/http-client.js +2 -0
  36. package/dist/http-db.js +16 -1
  37. package/dist/identity.js +1 -0
  38. package/dist/index-analytics.js +6 -7
  39. package/dist/index-backend.js +3 -3
  40. package/dist/index-dashboard.js +10 -13
  41. package/dist/index-manager.js +12 -11
  42. package/dist/index-monitoring.js +9 -4
  43. package/dist/index-store.js +2 -0
  44. package/dist/indexeddb-db.js +27 -23
  45. package/dist/memory-db.js +7 -4
  46. package/dist/observe.js +2 -0
  47. package/dist/provider.js +2 -0
  48. package/dist/query-planner.js +2 -4
  49. package/dist/reactive-graph.js +6 -8
  50. package/dist/release.js +2 -0
  51. package/dist/sharding.js +11 -6
  52. package/dist/state-contract.js +3 -3
  53. package/dist/studio-app/assets/{feltdb_wasm-CJv3wHzi.js → feltdb_wasm-C9xpYtna.js} +1 -1
  54. package/dist/studio-app/assets/feltdb_wasm_bg-BsXHw7eX.wasm +0 -0
  55. package/dist/studio-app/assets/{index-DospFFYE.js → index-D4RZ44qs.js} +4 -4
  56. package/dist/studio-app/index.html +1 -1
  57. package/dist/sync-contract.js +9 -2
  58. package/dist/telemetry.d.ts.map +1 -1
  59. package/dist/telemetry.js +32 -12
  60. package/dist/transaction.js +4 -2
  61. package/dist/wasm/feltdb_wasm_bg.wasm +0 -0
  62. package/dist/worker.js +2 -0
  63. package/dist/workload.js +2 -0
  64. package/dist/workspace/development-node.js +11 -10
  65. package/dist/workspace/investigation-lifecycle-manager.js +2 -0
  66. package/dist/workspace/investigation-supervisor.js +5 -3
  67. package/dist/workspace/workspace-connection.js +14 -5
  68. package/package.json +1 -1
  69. package/dist/studio-app/assets/feltdb_wasm_bg-C8TG8r2n.wasm +0 -0
@@ -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,147 @@ 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 next identity this node would issue.
389
+ pub fn next_origin_sequence(&self) -> u64 {
390
+ self.next_origin_sequence
391
+ }
392
+
393
+ /// Claim the next identity for an operation this node originates.
394
+ ///
395
+ /// Advancing on the attempt rather than on success is deliberate: it
396
+ /// preserves the sparse-sequence semantics the convergence contract froze,
397
+ /// where a refused transaction burns a number. Making the sequence dense is
398
+ /// a separate decision and is not made here as a side effect.
399
+ ///
400
+ /// A number burned and then lost to a crash is safe to re-issue, because
401
+ /// nothing carrying it was ever persisted: recovery derives from what the
402
+ /// log holds, so an identity is only reserved forever once an envelope
403
+ /// bearing it is durable. That is the invariant -- no two *persisted*
404
+ /// envelopes from one origin share a sequence -- and it is the one
405
+ /// receivers depend on.
406
+ pub fn allocate_origin_sequence(&mut self) -> u64 {
407
+ let claimed = self.next_origin_sequence;
408
+ self.next_origin_sequence = claimed.saturating_add(1);
409
+ claimed
410
+ }
411
+
412
+ /// Derive the next identity from a durable log: one past the highest this
413
+ /// node has already committed to it.
414
+ ///
415
+ /// Filtered to locally-originated envelopes, because the log also holds
416
+ /// replicated ones and another node's sequence says nothing about this
417
+ /// node's identity space.
418
+ fn next_sequence_after(log: &OperationLog, node_id: &str) -> Result<u64, std::io::Error> {
419
+ let highest = log
420
+ .load_all()?
421
+ .iter()
422
+ .filter(|envelope| envelope.envelope_id.originating_node == node_id)
423
+ .map(|envelope| envelope.envelope_id.sequence)
424
+ .max()
425
+ .unwrap_or(0);
426
+ Ok(highest.saturating_add(1))
427
+ }
428
+
429
+ /// Submit local work, allocating its identity from durable state.
430
+ ///
431
+ /// This is the entry point a node should use: it owns the allocation, so
432
+ /// the identity cannot be assigned by one caller and persisted by another,
433
+ /// and there is no counter outside the executor to fall out of step with
434
+ /// the log.
435
+ pub fn submit_local_transaction(
436
+ &mut self,
437
+ transaction_id: String,
438
+ parent_version: StateVersion,
439
+ operations: Vec<Operation>,
440
+ consistency_contract: ConsistencyContract,
441
+ ) -> Result<SubmitOutcome, String> {
442
+ // Allocation happens after the capacity check inside
443
+ // `try_execute_local_transaction`, so a refusal does not burn an
444
+ // identity: nothing was admitted, and there is nothing to account for.
445
+ if self.would_breach_bound(Self::operations_bytes(&operations)) {
446
+ return Ok(SubmitOutcome::CapacityExceeded {
447
+ pending_entries: self.causal_barrier.pending_count(),
448
+ pending_bytes: self.causal_barrier.pending_payload_bytes(),
449
+ limit_entries: self.causal_capacity.max_pending_entries,
450
+ limit_bytes: self.causal_capacity.max_pending_bytes,
451
+ });
452
+ }
453
+ let sequence = self.allocate_origin_sequence();
454
+ self.execute_local_transaction(
455
+ sequence,
456
+ transaction_id,
457
+ parent_version,
458
+ operations,
459
+ consistency_contract,
460
+ )
461
+ .map(SubmitOutcome::Admitted)
462
+ }
463
+
464
+ /// Submit local work, subject to the causal backlog bound.
465
+ ///
466
+ /// The bound is checked *before* anything is executed, persisted or
467
+ /// applied, so a refusal leaves nothing to undo and nothing to recover:
468
+ /// the operation was never admitted. That is what makes refusal the right
469
+ /// answer on this path and the wrong one for a replicated arrival, which
470
+ /// was already admitted and made durable somewhere else.
471
+ pub fn try_execute_local_transaction(
472
+ &mut self,
473
+ sequence: u64,
474
+ transaction_id: String,
475
+ parent_version: StateVersion,
476
+ operations: Vec<Operation>,
477
+ consistency_contract: ConsistencyContract,
478
+ ) -> Result<SubmitOutcome, String> {
479
+ let projected_bytes = Self::operations_bytes(&operations);
480
+ if self.would_breach_bound(projected_bytes) {
481
+ return Ok(SubmitOutcome::CapacityExceeded {
482
+ pending_entries: self.causal_barrier.pending_count(),
483
+ pending_bytes: self.causal_barrier.pending_payload_bytes(),
484
+ limit_entries: self.causal_capacity.max_pending_entries,
485
+ limit_bytes: self.causal_capacity.max_pending_bytes,
486
+ });
487
+ }
488
+ self.execute_local_transaction(
489
+ sequence,
490
+ transaction_id,
491
+ parent_version,
492
+ operations,
493
+ consistency_contract,
494
+ )
495
+ .map(SubmitOutcome::Admitted)
496
+ }
497
+
498
+ /// The size local work would contribute, measured before it is executed.
499
+ fn operations_bytes(operations: &[Operation]) -> usize {
500
+ serde_json::to_string(operations).map(|json| json.len()).unwrap_or(0)
501
+ }
502
+
503
+ /// Execute local work unconditionally.
504
+ ///
505
+ /// This does not consult the backlog bound; `try_execute_local_transaction`
506
+ /// is the admitting entry point and the one the node binary uses. This
507
+ /// remains for callers that have already made the admission decision.
231
508
  pub fn execute_local_transaction(
232
509
  &mut self,
233
510
  sequence: u64,
@@ -249,7 +526,7 @@ impl DistributedTransactionExecutor {
249
526
  }
250
527
 
251
528
  // Step 2: Create envelope for replication
252
- let envelope = TransactionEnvelope::new(
529
+ let mut envelope = TransactionEnvelope::new(
253
530
  self.local_node_id.clone(),
254
531
  sequence,
255
532
  transaction_id,
@@ -257,6 +534,19 @@ impl DistributedTransactionExecutor {
257
534
  consistency_contract,
258
535
  );
259
536
 
537
+ // Step 2a: Stamp the envelope with this node's causal knowledge.
538
+ //
539
+ // `TransactionEnvelope::new` starts from an empty clock and increments
540
+ // only the origin, so every envelope it built carried {origin: 1}
541
+ // whatever the node had already seen. That is a position, not a
542
+ // history: a receiver given those clocks has no dependencies to
543
+ // enforce, which is why causal ordering could not have worked on this
544
+ // path even once the barrier was wired to it. The clock must carry the
545
+ // applied frontier this operation was produced on top of.
546
+ let mut stamped = self.causal_barrier.get_frontier().clone();
547
+ stamped.increment(&self.local_node_id);
548
+ envelope.vector_clock = stamped;
549
+
260
550
  // Step 3: Durably persist operation to log (with fsync)
261
551
  // This must succeed before we update in-memory state
262
552
  self.persist_envelope(&envelope)?;
@@ -270,6 +560,12 @@ impl DistributedTransactionExecutor {
270
560
  // Step 5: Record envelope to prevent duplicates (in-memory, derived from log)
271
561
  self.envelopes_seen.insert(envelope.envelope_id.clone());
272
562
 
563
+ // Step 6: A locally executed operation is applied, so it advances the
564
+ // frontier exactly as a replicated one does. Own writes are always
565
+ // causally eligible: they are built on the frontier they advance.
566
+ self.causal_barrier
567
+ .record_applied(&Self::barrier_key(&envelope.envelope_id), &envelope.vector_clock);
568
+
273
569
  Ok(envelope)
274
570
  }
275
571
 
@@ -280,37 +576,419 @@ impl DistributedTransactionExecutor {
280
576
  message: ReplicationMessage,
281
577
  parent_version: StateVersion,
282
578
  ) -> 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());
579
+ match self.receive_replicated(message, parent_version)? {
580
+ ReceiveOutcome::Applied { state_hash } => Ok(state_hash),
581
+ ReceiveOutcome::PendingDependencies { missing } => Err(format!(
582
+ "Envelope is durable but waiting on dependencies: {}",
583
+ missing.join(", ")
584
+ )),
585
+ ReceiveOutcome::AlreadyKnown => Err("Duplicate envelope".to_string()),
586
+ ReceiveOutcome::CapacityDeferred { missing, .. } => Err(format!(
587
+ "CAPACITY_DEFERRED: envelope is durable but the causal backlog is at its bound, waiting on: {}",
588
+ missing.join(", ")
589
+ )),
590
+ }
591
+ }
592
+
593
+ /// The causal receive path.
594
+ ///
595
+ /// Ordering is deliberate and is the substance of this change:
596
+ ///
597
+ /// dedup -> persist -> causal eligibility -> apply -> release
598
+ ///
599
+ /// Persisting *before* deciding eligibility is what makes "held back" a
600
+ /// durable state rather than an in-memory one. An envelope that arrives
601
+ /// ahead of its dependencies is fsynced to the operation log and then
602
+ /// retained in the barrier by identity alone; if the process dies at that
603
+ /// moment, recovery replays the log and rediscovers it as pending. This is
604
+ /// also what catch-up will require, because a peer sending A3, A1, A2 in
605
+ /// transport order needs the receiver to be able to say "I have A3
606
+ /// durably, and I cannot apply it yet".
607
+ pub fn receive_replicated(
608
+ &mut self,
609
+ message: ReplicationMessage,
610
+ parent_version: StateVersion,
611
+ ) -> Result<ReceiveOutcome, String> {
612
+ let envelope_id = message.envelope.envelope_id.clone();
613
+ let key = Self::barrier_key(&envelope_id);
614
+
615
+ // Step 1: Duplicate delivery is a no-op, whether the original was
616
+ // applied or is still waiting. Both states are recorded before this
617
+ // point is reached again, so one logical operation stays one.
618
+ if self.envelopes_seen.contains(&envelope_id)
619
+ || self.causal_barrier.is_pending(&key)
620
+ || self.deferred.contains(&envelope_id)
621
+ {
622
+ return Ok(ReceiveOutcome::AlreadyKnown);
623
+ }
624
+
625
+ // Step 2: Durability first. The log is the source of truth, so an
626
+ // envelope becomes durable on receipt and not on application.
627
+ self.persist_envelope(&message.envelope)?;
628
+
629
+ // Step 3: Eligibility. The dependencies are everything the origin had
630
+ // seen when it produced this operation, which is its clock with its own
631
+ // entry stepped back by one; the operation itself is not its own
632
+ // dependency.
633
+ let dependencies = Self::dependencies_of(&message.envelope);
634
+ if !self.causal_barrier.is_eligible(&dependencies) {
635
+ let missing = self.missing_dependencies(&dependencies);
636
+
637
+ // Step 3a: The bound. Buffering costs an entry and holds a payload
638
+ // outstanding in the log; if either would breach its limit, the
639
+ // metadata entry is withheld and the envelope stays durable and
640
+ // deferred. It is never refused and never evicted -- refusing a
641
+ // peer means silent loss or a retransmission protocol that does not
642
+ // exist here, and eviction is silent loss under another name.
643
+ let payload_bytes = Self::envelope_bytes(&message.envelope);
644
+ if self.would_breach_bound(payload_bytes) {
645
+ self.deferred.insert(envelope_id);
646
+ return Ok(ReceiveOutcome::CapacityDeferred {
647
+ missing,
648
+ pending_entries: self.causal_barrier.pending_count(),
649
+ pending_bytes: self.causal_barrier.pending_payload_bytes(),
650
+ });
651
+ }
652
+
653
+ self.causal_barrier
654
+ .retain_pending_sized(key, dependencies, payload_bytes);
655
+ return Ok(ReceiveOutcome::PendingDependencies { missing });
286
656
  }
287
657
 
288
- // Step 2: Execute transaction through 14C
658
+ // Step 4: Apply, which advances the frontier.
659
+ let state_hash = self.apply_envelope(&message.envelope, parent_version.clone())?;
660
+
661
+ // Step 5: This application may have unblocked others, and may have
662
+ // freed the capacity a deferred envelope was waiting for. Drain both.
663
+ self.drain(parent_version)?;
664
+
665
+ Ok(ReceiveOutcome::Applied { state_hash })
666
+ }
667
+
668
+ /// Drive the backlog forward from outside the receive path.
669
+ ///
670
+ /// A deferred envelope is durable but unbuffered, and the event that frees
671
+ /// capacity for it may arrive on a different connection than the one that
672
+ /// was deferred. A caller applying transport backpressure needs a way to
673
+ /// make progress without reading from the backpressured peer, and this is
674
+ /// it.
675
+ pub fn drain_backlog(&mut self, parent_version: StateVersion) -> Result<usize, String> {
676
+ self.drain(parent_version)
677
+ }
678
+
679
+ /// Whether admitting one more entry of this size would breach either bound.
680
+ ///
681
+ /// Inclusive, as the contract specifies: filling exactly to the bound is
682
+ /// accepted, and the breach is the step past it.
683
+ fn would_breach_bound(&self, payload_bytes: usize) -> bool {
684
+ let entries = self.causal_barrier.pending_count();
685
+ let bytes = self.causal_barrier.pending_payload_bytes();
686
+ entries + 1 > self.causal_capacity.max_pending_entries
687
+ || bytes + payload_bytes > self.causal_capacity.max_pending_bytes
688
+ }
689
+
690
+ /// The envelope's size as the durable log stores it.
691
+ ///
692
+ /// Measured with the log's own serialisation rather than a nominal
693
+ /// per-operation constant, so a large envelope consumes its actual byte
694
+ /// count.
695
+ fn envelope_bytes(envelope: &TransactionEnvelope) -> usize {
696
+ serde_json::to_string(envelope).map(|json| json.len()).unwrap_or(0)
697
+ }
698
+
699
+ /// Apply one envelope and advance the applied frontier.
700
+ fn apply_envelope(
701
+ &mut self,
702
+ envelope: &TransactionEnvelope,
703
+ parent_version: StateVersion,
704
+ ) -> Result<StateHash, String> {
289
705
  let transition = self.transaction_executor.execute_transaction(
290
- message.envelope.transaction_id.clone(),
706
+ envelope.transaction_id.clone(),
291
707
  parent_version,
292
- message.envelope.operations.clone(),
293
- message.envelope.consistency_contract.clone(),
708
+ envelope.operations.clone(),
709
+ envelope.consistency_contract.clone(),
294
710
  );
295
711
 
296
712
  if !transition.is_successful() {
297
713
  return Err("Transaction execution failed".to_string());
298
714
  }
299
715
 
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());
716
+ self.envelopes_seen.insert(envelope.envelope_id.clone());
305
717
 
306
- // Step 5: Update this replica's state (receiver updates itself)
718
+ let state_hash = StateHash::from_hex(transition.to_version.state_hash.clone());
307
719
  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);
720
+ replica.apply_transaction(envelope, state_hash.clone());
721
+ }
722
+
723
+ // The frontier advances here and only here, so it describes what has
724
+ // been successfully applied rather than what was claimed or received.
725
+ self.causal_barrier
726
+ .record_applied(&Self::barrier_key(&envelope.envelope_id), &envelope.vector_clock);
727
+
728
+ Ok(state_hash)
729
+ }
730
+
731
+ /// Drive the backlog forward until it stops moving.
732
+ ///
733
+ /// Two things can make progress and each can enable the other: applying a
734
+ /// pending envelope advances the frontier, which may make other pending
735
+ /// envelopes eligible; and applying anything frees capacity, which may let
736
+ /// a deferred envelope be admitted from the log. So this alternates until
737
+ /// neither step changes anything, which terminates because every iteration
738
+ /// that continues has strictly reduced the number of unapplied envelopes.
739
+ fn drain(&mut self, parent_version: StateVersion) -> Result<usize, String> {
740
+ let mut progressed_total = 0;
741
+ loop {
742
+ let released = self.release_eligible(parent_version.clone())?;
743
+ let admitted = self.admit_deferred(parent_version.clone())?;
744
+ progressed_total += released + admitted;
745
+ if released == 0 && admitted == 0 {
746
+ return Ok(progressed_total);
747
+ }
310
748
  }
749
+ }
750
+
751
+ /// Apply every pending envelope the frontier now satisfies, repeatedly,
752
+ /// because releasing one can make the next eligible.
753
+ ///
754
+ /// The log is read once per pass rather than once per key. It used to be
755
+ /// once per key: `replay_from_log` called `load_all()` for every eligible
756
+ /// entry, so a release cascade over a backlog of n entries against a log of
757
+ /// m records did n full scans. That is the exact shape of load this change
758
+ /// makes reachable -- a bounded backlog is one that is allowed to get
759
+ /// large -- so the quadratic scan had to go with it.
760
+ fn release_eligible(&mut self, parent_version: StateVersion) -> Result<usize, String> {
761
+ let mut released = 0;
762
+ loop {
763
+ let ready = self.causal_barrier.eligible_pending();
764
+ if ready.is_empty() {
765
+ return Ok(released);
766
+ }
767
+ let by_key = self.log_by_key()?;
768
+ let mut progressed = false;
769
+ for key in ready {
770
+ let Some(envelope) = by_key.get(&key) else {
771
+ // The barrier holds an identity whose payload is not in the
772
+ // log. Dropping the entry would be silent loss, so leave it
773
+ // pending and surface it rather than pretend it applied.
774
+ continue;
775
+ };
776
+ self.apply_envelope(&envelope.clone(), parent_version.clone())?;
777
+ released += 1;
778
+ progressed = true;
779
+ }
780
+ if !progressed {
781
+ return Ok(released);
782
+ }
783
+ }
784
+ }
785
+
786
+ /// Move envelopes that are durable but unbuffered into the backlog, as far
787
+ /// as the bound now allows.
788
+ ///
789
+ /// This is what makes `CapacityDeferred` safe: the envelope was fsynced
790
+ /// before it was deferred, so admitting it later needs no cooperation from
791
+ /// the sender and survives a restart in between. Entries are admitted in
792
+ /// key order so that two replicas draining the same set drain it
793
+ /// identically -- behaviour at capacity must not depend on iteration order
794
+ /// or timing.
795
+ fn admit_deferred(&mut self, parent_version: StateVersion) -> Result<usize, String> {
796
+ if self.deferred.is_empty() {
797
+ return Ok(0);
798
+ }
799
+ let by_key = self.log_by_key()?;
800
+ let mut keys: Vec<String> = self
801
+ .deferred
802
+ .iter()
803
+ .map(Self::barrier_key)
804
+ .collect();
805
+ keys.sort();
806
+
807
+ let mut admitted = 0;
808
+ for key in keys {
809
+ let Some(envelope) = by_key.get(&key).cloned() else { continue };
810
+ let dependencies = Self::dependencies_of(&envelope);
811
+ let payload_bytes = Self::envelope_bytes(&envelope);
812
+
813
+ if self.causal_barrier.is_eligible(&dependencies) {
814
+ self.deferred.remove(&envelope.envelope_id);
815
+ self.apply_envelope(&envelope, parent_version.clone())?;
816
+ admitted += 1;
817
+ continue;
818
+ }
819
+ if self.would_breach_bound(payload_bytes) {
820
+ // Still no room. Leave it deferred; it is durable and will be
821
+ // reconsidered on the next drain or after a restart.
822
+ continue;
823
+ }
824
+ self.deferred.remove(&envelope.envelope_id);
825
+ self.causal_barrier
826
+ .retain_pending_sized(key, dependencies, payload_bytes);
827
+ admitted += 1;
828
+ }
829
+ Ok(admitted)
830
+ }
311
831
 
312
- // Return the state hash
313
- Ok(StateHash::from_hex(transition.to_version.state_hash))
832
+ /// The durable log, indexed by barrier key.
833
+ ///
834
+ /// This is why the barrier never needs the payload: it holds the identity,
835
+ /// and the bytes stay where they were already fsynced.
836
+ fn log_by_key(&self) -> Result<HashMap<String, TransactionEnvelope>, String> {
837
+ let Some(ref log) = self.operation_log else {
838
+ return Ok(HashMap::new());
839
+ };
840
+ let envelopes = log
841
+ .load_all()
842
+ .map_err(|e| format!("Failed to replay operation log: {}", e))?;
843
+ Ok(envelopes
844
+ .into_iter()
845
+ .map(|envelope| (Self::barrier_key(&envelope.envelope_id), envelope))
846
+ .collect())
847
+ }
848
+
849
+ /// The stable string identity the barrier is keyed by.
850
+ fn barrier_key(envelope_id: &EnvelopeId) -> String {
851
+ format!("{}:{}", envelope_id.originating_node, envelope_id.sequence)
852
+ }
853
+
854
+ /// The causal dependencies of an envelope: what its origin had applied
855
+ /// before producing it.
856
+ fn dependencies_of(envelope: &TransactionEnvelope) -> VectorClock {
857
+ let mut dependencies = envelope.vector_clock.clone();
858
+ let own = dependencies.get(&envelope.originating_node);
859
+ dependencies
860
+ .clocks
861
+ .insert(envelope.originating_node.clone(), own.saturating_sub(1));
862
+ dependencies
863
+ }
864
+
865
+ /// Which dimensions of a dependency clock the frontier has not reached.
866
+ fn missing_dependencies(&self, dependencies: &VectorClock) -> Vec<String> {
867
+ let frontier = self.causal_barrier.get_frontier();
868
+ let mut missing: Vec<String> = dependencies
869
+ .clocks
870
+ .iter()
871
+ .filter(|(node, required)| frontier.get(node) < **required)
872
+ .map(|(node, required)| {
873
+ format!("{}@{} (have {})", node, required, frontier.get(node))
874
+ })
875
+ .collect();
876
+ missing.sort();
877
+ missing
878
+ }
879
+
880
+ /// How many envelopes are durable but waiting on dependencies.
881
+ ///
882
+ /// Instrumentation only; no limit is enforced. See
883
+ /// docs/architecture/causal-backlog-bound.md.
884
+ pub fn pending_causal_count(&self) -> usize {
885
+ self.causal_barrier.pending_count()
886
+ }
887
+
888
+ /// Approximate heap cost of the pending causal metadata, in bytes.
889
+ pub fn pending_causal_metadata_bytes(&self) -> usize {
890
+ self.causal_barrier.pending_metadata_bytes()
891
+ }
892
+
893
+ /// Durable payload bytes the backlog is waiting on. The byte bound is
894
+ /// expressed in these.
895
+ pub fn pending_causal_payload_bytes(&self) -> usize {
896
+ self.causal_barrier.pending_payload_bytes()
897
+ }
898
+
899
+ /// Envelopes that are durable but not buffered, because the bound was
900
+ /// reached when they arrived. They are not lost and not evicted.
901
+ pub fn deferred_causal_count(&self) -> usize {
902
+ self.deferred.len()
903
+ }
904
+
905
+ /// Keys of envelopes that are durable but not buffered, sorted.
906
+ pub fn deferred_causal_keys(&self) -> Vec<String> {
907
+ let mut keys: Vec<String> = self.deferred.iter().map(Self::barrier_key).collect();
908
+ keys.sort();
909
+ keys
910
+ }
911
+
912
+ /// Maximum occupancy ever reached, for entries and payload bytes.
913
+ ///
914
+ /// Reported alongside the current values because a backlog that filled and
915
+ /// drained between two reads is invisible to a current-value gauge, and the
916
+ /// question an operator has is whether the bound was ever approached.
917
+ pub fn max_causal_occupancy(&self) -> (usize, usize) {
918
+ (
919
+ self.causal_barrier.max_pending_entries_seen(),
920
+ self.causal_barrier.max_pending_payload_bytes_seen(),
921
+ )
922
+ }
923
+
924
+ /// The bound in force on this executor.
925
+ pub fn causal_capacity(&self) -> CausalCapacity {
926
+ self.causal_capacity
927
+ }
928
+
929
+ /// Set the bound. Used by the node binary so an acceptance test can reach
930
+ /// the bound through the production path rather than by constructing a
931
+ /// barrier directly.
932
+ pub fn set_causal_capacity(&mut self, capacity: CausalCapacity) {
933
+ self.causal_capacity = capacity;
934
+ }
935
+
936
+ /// The applied causal frontier: what this replica has successfully applied.
937
+ pub fn causal_frontier(&self) -> &VectorClock {
938
+ self.causal_barrier.get_frontier()
939
+ }
940
+
941
+ /// Identities of envelopes durable but not yet applied.
942
+ pub fn pending_causal_keys(&self) -> Vec<String> {
943
+ self.causal_barrier.pending_keys()
944
+ }
945
+
946
+ /// Materialize the applied records, order-independently.
947
+ ///
948
+ /// This exists because the chained `H(parent, command)` state hash is a
949
+ /// missing-operation detector for an ordered history, not a convergence
950
+ /// identity: two replicas that applied the same set of concurrent
951
+ /// operations in different orders hold the same data and report different
952
+ /// chained hashes. `CanonicalState` is a BTreeMap keyed by
953
+ /// (collection, record_id), so its hash depends on the resulting records
954
+ /// and not on the path taken to them, which is what "the replicas agree"
955
+ /// should mean.
956
+ ///
957
+ /// Built from the durable log, filtered to what the barrier records as
958
+ /// applied and taken in application order, so a pending envelope's
959
+ /// operations are excluded until it is actually released.
960
+ pub fn canonical_state(&self) -> Result<crate::state_hash::CanonicalState, String> {
961
+ let mut state = crate::state_hash::CanonicalState::new();
962
+ let Some(ref log) = self.operation_log else {
963
+ return Ok(state);
964
+ };
965
+ let envelopes = log
966
+ .load_all()
967
+ .map_err(|e| format!("Failed to read operation log: {}", e))?;
968
+ let by_key: HashMap<String, &TransactionEnvelope> = envelopes
969
+ .iter()
970
+ .map(|envelope| (Self::barrier_key(&envelope.envelope_id), envelope))
971
+ .collect();
972
+
973
+ for key in self.causal_barrier.applied_keys() {
974
+ let Some(envelope) = by_key.get(key) else {
975
+ continue;
976
+ };
977
+ for operation in &envelope.operations {
978
+ let command = &operation.command;
979
+ let fields: serde_json::Map<String, serde_json::Value> = command
980
+ .fields
981
+ .iter()
982
+ .map(|(name, value)| (name.clone(), value.clone()))
983
+ .collect();
984
+ state.set_record(
985
+ command.collection.clone(),
986
+ command.record_id.clone(),
987
+ serde_json::Value::Object(fields),
988
+ );
989
+ }
990
+ }
991
+ Ok(state)
314
992
  }
315
993
 
316
994
  /// Get current state of a replica
@@ -366,14 +1044,77 @@ impl DistributedTransactionExecutor {
366
1044
  let log = OperationLog::new(&log_path)?;
367
1045
  let envelopes = log.load_all()?;
368
1046
 
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());
1047
+ self.operation_log = Some(log);
1048
+
1049
+ // Rebuild the causal state, not just the dedup set.
1050
+ //
1051
+ // The log records what was *received*, in arrival order, and arrival
1052
+ // order is not causal order -- that is the whole reason the barrier
1053
+ // exists. So recovery cannot simply mark everything seen: it has to
1054
+ // replay eligibility, exactly as the live path does, and come out with
1055
+ // the same split between applied and still-pending. This is what makes
1056
+ // "deliver B, restart, deliver A" work: B is durable across the
1057
+ // restart, is rediscovered as pending rather than as applied, and is
1058
+ // released when A finally arrives.
1059
+ let mut undecided: Vec<TransactionEnvelope> = envelopes.clone();
1060
+ loop {
1061
+ let mut progressed = false;
1062
+ let mut still_undecided = Vec::new();
1063
+ for envelope in undecided.into_iter() {
1064
+ let dependencies = Self::dependencies_of(&envelope);
1065
+ if self.causal_barrier.is_eligible(&dependencies) {
1066
+ self.envelopes_seen.insert(envelope.envelope_id.clone());
1067
+ self.causal_barrier.record_applied(
1068
+ &Self::barrier_key(&envelope.envelope_id),
1069
+ &envelope.vector_clock,
1070
+ );
1071
+ progressed = true;
1072
+ } else {
1073
+ still_undecided.push(envelope);
1074
+ }
1075
+ }
1076
+ undecided = still_undecided;
1077
+ if !progressed || undecided.is_empty() {
1078
+ break;
1079
+ }
372
1080
  }
373
1081
 
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);
1082
+ // Whatever could not be made eligible is durable and waiting, and is
1083
+ // restored to the barrier in that state -- as far as the bound allows.
1084
+ //
1085
+ // Recovery has to respect the same bound the live path does, or a node
1086
+ // could restart into an over-full backlog that it would never have
1087
+ // accepted while running, and the bound would hold only until the first
1088
+ // crash. Entries are restored in key order so that recovery is
1089
+ // deterministic: the same log and the same bound produce the same split
1090
+ // between pending and deferred every time.
1091
+ undecided.sort_by_key(|envelope| Self::barrier_key(&envelope.envelope_id));
1092
+ for envelope in undecided {
1093
+ let dependencies = Self::dependencies_of(&envelope);
1094
+ let payload_bytes = Self::envelope_bytes(&envelope);
1095
+ if self.would_breach_bound(payload_bytes) {
1096
+ self.deferred.insert(envelope.envelope_id.clone());
1097
+ continue;
1098
+ }
1099
+ self.causal_barrier.retain_pending_sized(
1100
+ Self::barrier_key(&envelope.envelope_id),
1101
+ dependencies,
1102
+ payload_bytes,
1103
+ );
1104
+ }
1105
+
1106
+ // Identity is recovered from the same log, and this is the half that
1107
+ // was missing: the causal frontier was already rebuilt here, so a
1108
+ // restarted origin came back knowing its causal position and not its
1109
+ // identity position, and issued colliding identities on top of a
1110
+ // correct clock.
1111
+ self.next_origin_sequence = envelopes
1112
+ .iter()
1113
+ .filter(|envelope| envelope.envelope_id.originating_node == self.local_node_id)
1114
+ .map(|envelope| envelope.envelope_id.sequence)
1115
+ .max()
1116
+ .unwrap_or(0)
1117
+ .saturating_add(1);
377
1118
 
378
1119
  Ok(envelopes.len())
379
1120
  }