@feltdb/core 0.6.13 → 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.
- package/dist/agent-registry.js +1 -3
- package/dist/agent-runtime.js +8 -7
- package/dist/analytics-backend.js +3 -1
- package/dist/application-contract.js +1 -0
- package/dist/application-manifest.js +1 -0
- package/dist/artifact.js +2 -0
- package/dist/authorization.js +2 -0
- package/dist/bundle.js +2 -0
- package/dist/capability.js +1 -3
- package/dist/cell.js +9 -4
- package/dist/cli/commands.js +26 -2
- package/dist/cli/index.js +1 -1
- package/dist/collection.js +39 -31
- package/dist/create/package-versions.js +1 -1
- package/dist/create/server-source/crates/feltdb/src/bin/feltdb_node.rs +497 -27
- package/dist/create/server-source/crates/feltdb/src/causal_backlog_bound.rs +452 -0
- package/dist/create/server-source/crates/feltdb/src/causal_dependency_barrier.rs +208 -0
- package/dist/create/server-source/crates/feltdb/src/convergence.rs +16 -0
- package/dist/create/server-source/crates/feltdb/src/dedup_bound_investigation.rs +402 -0
- package/dist/create/server-source/crates/feltdb/src/distributed_transactions.rs +765 -24
- package/dist/create/server-source/crates/feltdb/src/durable_operation_identity.rs +418 -0
- package/dist/create/server-source/crates/feltdb/src/lib.rs +4 -0
- package/dist/create/server-source/crates/feltdb/src/replica_membership.rs +661 -0
- package/dist/db.js +19 -13
- package/dist/development-runtime-bridge.js +1 -1
- package/dist/distributed-indexing.js +7 -5
- package/dist/file-db.js +8 -3
- package/dist/flowspec.js +2 -1
- package/dist/http-client.js +2 -0
- package/dist/http-db.js +16 -1
- package/dist/identity.js +1 -0
- package/dist/index-analytics.js +6 -7
- package/dist/index-backend.js +3 -3
- package/dist/index-dashboard.js +10 -13
- package/dist/index-manager.js +12 -11
- package/dist/index-monitoring.js +9 -4
- package/dist/index-store.js +2 -0
- package/dist/indexeddb-db.js +27 -23
- package/dist/memory-db.js +7 -4
- package/dist/observe.js +2 -0
- package/dist/provider.js +2 -0
- package/dist/query-planner.js +2 -4
- package/dist/reactive-graph.js +6 -8
- package/dist/release.js +2 -0
- package/dist/sharding.js +11 -6
- package/dist/state-contract.js +3 -3
- package/dist/studio-app/assets/{feltdb_wasm-CJv3wHzi.js → feltdb_wasm-C9xpYtna.js} +1 -1
- package/dist/studio-app/assets/feltdb_wasm_bg-BsXHw7eX.wasm +0 -0
- package/dist/studio-app/assets/{index-DospFFYE.js → index-D4RZ44qs.js} +4 -4
- package/dist/studio-app/index.html +1 -1
- package/dist/sync-contract.js +9 -2
- package/dist/telemetry.d.ts.map +1 -1
- package/dist/telemetry.js +32 -12
- package/dist/transaction.js +4 -2
- package/dist/wasm/feltdb_wasm_bg.wasm +0 -0
- package/dist/worker.js +2 -0
- package/dist/workload.js +2 -0
- package/dist/workspace/development-node.js +11 -10
- package/dist/workspace/investigation-lifecycle-manager.js +2 -0
- package/dist/workspace/investigation-supervisor.js +5 -3
- package/dist/workspace/workspace-connection.js +14 -5
- package/package.json +1 -1
- 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
|
-
|
|
284
|
-
|
|
285
|
-
|
|
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
|
|
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
|
-
|
|
706
|
+
envelope.transaction_id.clone(),
|
|
291
707
|
parent_version,
|
|
292
|
-
|
|
293
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
309
|
-
|
|
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
|
-
|
|
313
|
-
|
|
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
|
-
|
|
370
|
-
|
|
371
|
-
|
|
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
|
-
//
|
|
375
|
-
//
|
|
376
|
-
|
|
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
|
}
|