@feltdb/core 0.8.5 → 0.8.6
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/create/package-versions.js +1 -1
- package/dist/create/server-source/crates/feltdb/src/lib.rs +1173 -72
- package/dist/create/server-source/crates/feltdb/src/operation.rs +39 -0
- package/dist/create/server-source/crates/feltdb/src/state_model.rs +52 -0
- package/dist/create/server-source/crates/feltdb/src/storage.rs +9 -3
- package/dist/create/server-source/crates/feltdb/tests/bounded_read_contract.rs +132 -0
- package/dist/create/server-source/crates/feltdb/tests/compaction_stall_contract.rs +272 -0
- package/dist/create/server-source/crates/feltdb/tests/crash_durability_contract.rs +467 -0
- package/dist/create/server-source/crates/feltdb/tests/current_revision_authority_evidence.rs +26 -5
- package/dist/create/server-source/crates/feltdb/tests/durable_backup_contract.rs +445 -0
- package/dist/create/server-source/crates/feltdb/tests/durable_corruption_contract.rs +518 -0
- package/dist/create/server-source/crates/feltdb/tests/operational_health_contract.rs +278 -0
- package/dist/create/server-source/crates/feltdb/tests/production_readiness_contract.rs +490 -157
- package/dist/create/server-source/crates/feltdb/tests/replicated_history_contract.rs +417 -0
- package/dist/create/server-source/crates/feltdb/tests/workload_envelope_contract.rs +442 -0
- package/dist/create/server-source/crates/feltdb-server/src/app_state.rs +14 -0
- package/dist/create/server-source/crates/feltdb-server/src/main.rs +369 -41
- package/dist/create/server-source/crates/feltdb-server/src/metrics.rs +21 -0
- package/dist/studio-app/assets/{feltdb_wasm-DaNwCLRX.js → feltdb_wasm-C1VhI-U5.js} +1 -1
- package/dist/studio-app/assets/feltdb_wasm_bg-C8HXbAXb.wasm +0 -0
- package/dist/studio-app/assets/{index-j8IlhNqJ.js → index-Bbos1m2U.js} +1 -1
- package/dist/studio-app/index.html +1 -1
- package/dist/wasm/feltdb_wasm_bg.wasm +0 -0
- package/package.json +1 -1
- package/dist/studio-app/assets/feltdb_wasm_bg-DnsHNv6g.wasm +0 -0
|
@@ -68,6 +68,26 @@ const CLAIMS: &[(&str, Status)] = &[
|
|
|
68
68
|
"D5 a single write survives operating-system or power loss",
|
|
69
69
|
Status::Unproven,
|
|
70
70
|
),
|
|
71
|
+
(
|
|
72
|
+
"D6 every crash point leaves a clean prefix of the mutation sequence",
|
|
73
|
+
Status::Proven,
|
|
74
|
+
),
|
|
75
|
+
(
|
|
76
|
+
"D7 a crash never produces a revision for a record that was not written",
|
|
77
|
+
Status::Proven,
|
|
78
|
+
),
|
|
79
|
+
(
|
|
80
|
+
"D8 a committed write survives the process dying",
|
|
81
|
+
Status::Proven,
|
|
82
|
+
),
|
|
83
|
+
(
|
|
84
|
+
"D9 a transaction is all-or-nothing across a crash",
|
|
85
|
+
Status::Proven,
|
|
86
|
+
),
|
|
87
|
+
(
|
|
88
|
+
"D10 a crash-recovered database replicates identically",
|
|
89
|
+
Status::Proven,
|
|
90
|
+
),
|
|
71
91
|
// Atomicity and concurrency
|
|
72
92
|
(
|
|
73
93
|
"A1 a stale fenced write is rejected and mints nothing",
|
|
@@ -91,17 +111,23 @@ const CLAIMS: &[(&str, Status)] = &[
|
|
|
91
111
|
),
|
|
92
112
|
(
|
|
93
113
|
"A6 a crash part-way through a mutation leaves consistent state",
|
|
94
|
-
Status::
|
|
114
|
+
Status::Proven,
|
|
95
115
|
),
|
|
96
116
|
// Recovery
|
|
97
|
-
(
|
|
98
|
-
|
|
117
|
+
(
|
|
118
|
+
"R1 an incomplete final append is recovered and reported, not silently dropped",
|
|
119
|
+
Status::Proven,
|
|
120
|
+
),
|
|
121
|
+
(
|
|
122
|
+
"R2 a complete but invalid record refuses the open, wherever it sits",
|
|
123
|
+
Status::Proven,
|
|
124
|
+
),
|
|
99
125
|
(
|
|
100
126
|
"R3 damaged ancestry is distinguishable from expired ancestry",
|
|
101
127
|
Status::Proven,
|
|
102
128
|
),
|
|
103
129
|
(
|
|
104
|
-
"R4 damage is surfaced without being asked for",
|
|
130
|
+
"R4 ancestry damage is surfaced without being asked for",
|
|
105
131
|
Status::Unproven,
|
|
106
132
|
),
|
|
107
133
|
// History and retention
|
|
@@ -121,16 +147,43 @@ const CLAIMS: &[(&str, Status)] = &[
|
|
|
121
147
|
("H5 forked history is preserved", Status::Proven),
|
|
122
148
|
// Backup and restore
|
|
123
149
|
(
|
|
124
|
-
"B1
|
|
125
|
-
Status::
|
|
150
|
+
"B1 a durable backup artifact is self-contained and independently verifiable",
|
|
151
|
+
Status::Proven,
|
|
126
152
|
),
|
|
127
153
|
(
|
|
128
|
-
"B2
|
|
154
|
+
"B2 a restore proves the result means the same thing, not merely that it parsed",
|
|
155
|
+
Status::Proven,
|
|
156
|
+
),
|
|
157
|
+
(
|
|
158
|
+
"B3 an altered or truncated backup is refused rather than restored",
|
|
159
|
+
Status::Proven,
|
|
160
|
+
),
|
|
161
|
+
(
|
|
162
|
+
"B4 a restore will not overwrite an existing database",
|
|
163
|
+
Status::Proven,
|
|
164
|
+
),
|
|
165
|
+
(
|
|
166
|
+
"B5 backup and restore are exposed as an operator command-line workflow",
|
|
129
167
|
Status::Unproven,
|
|
130
168
|
),
|
|
131
169
|
// Replication
|
|
132
170
|
("S1 current state replicates to a peer", Status::Proven),
|
|
133
|
-
(
|
|
171
|
+
(
|
|
172
|
+
"S2 revision history replicates to a peer with identical identities",
|
|
173
|
+
Status::Proven,
|
|
174
|
+
),
|
|
175
|
+
(
|
|
176
|
+
"S3 replicated history preserves parents, sequences, forks and provenance",
|
|
177
|
+
Status::Proven,
|
|
178
|
+
),
|
|
179
|
+
(
|
|
180
|
+
"S4 state equivalence and historical equivalence are checked separately",
|
|
181
|
+
Status::Proven,
|
|
182
|
+
),
|
|
183
|
+
(
|
|
184
|
+
"S5 a peer that sends no revision provenance contributes no history",
|
|
185
|
+
Status::Proven,
|
|
186
|
+
),
|
|
134
187
|
// Workload envelope
|
|
135
188
|
(
|
|
136
189
|
"E1 retention cost per write grows with the retention window",
|
|
@@ -140,6 +193,26 @@ const CLAIMS: &[(&str, Status)] = &[
|
|
|
140
193
|
"E2 retention increases log size before compaction",
|
|
141
194
|
Status::Proven,
|
|
142
195
|
),
|
|
196
|
+
(
|
|
197
|
+
"E3 durability is a selectable contract, and each mode states its guarantee",
|
|
198
|
+
Status::Proven,
|
|
199
|
+
),
|
|
200
|
+
(
|
|
201
|
+
"E4 a stable-storage barrier per write has a measured cost",
|
|
202
|
+
Status::Proven,
|
|
203
|
+
),
|
|
204
|
+
(
|
|
205
|
+
"E5 the durability mode changes the contract and not the stored database",
|
|
206
|
+
Status::Proven,
|
|
207
|
+
),
|
|
208
|
+
(
|
|
209
|
+
"E6 the workload envelope is recorded against a frozen workload and a named environment",
|
|
210
|
+
Status::Proven,
|
|
211
|
+
),
|
|
212
|
+
(
|
|
213
|
+
"E7 grouped durability is measurably cheaper than a barrier per write",
|
|
214
|
+
Status::Unproven,
|
|
215
|
+
),
|
|
143
216
|
// Upgrade and migration
|
|
144
217
|
(
|
|
145
218
|
"U1 an incompatible durable format is detected before any record is interpreted",
|
|
@@ -172,7 +245,19 @@ const CLAIMS: &[(&str, Status)] = &[
|
|
|
172
245
|
),
|
|
173
246
|
(
|
|
174
247
|
"O2 reported health reflects actual storage state",
|
|
175
|
-
Status::
|
|
248
|
+
Status::Proven,
|
|
249
|
+
),
|
|
250
|
+
(
|
|
251
|
+
"O3 a recovered open is distinguishable from a clean one in health",
|
|
252
|
+
Status::Proven,
|
|
253
|
+
),
|
|
254
|
+
(
|
|
255
|
+
"O4 health reports only conditions that were observed",
|
|
256
|
+
Status::Proven,
|
|
257
|
+
),
|
|
258
|
+
(
|
|
259
|
+
"O5 the remaining health fields are compile-time constants",
|
|
260
|
+
Status::PartiallyProven,
|
|
176
261
|
),
|
|
177
262
|
];
|
|
178
263
|
|
|
@@ -269,35 +354,93 @@ fn a_clean_restart_preserves_state_history_and_retention() {
|
|
|
269
354
|
assert_eq!(history.last().unwrap().sequence, 10, "D4");
|
|
270
355
|
}
|
|
271
356
|
|
|
272
|
-
/// **
|
|
357
|
+
/// **The crash boundary, established rather than assumed.**
|
|
273
358
|
///
|
|
274
|
-
///
|
|
275
|
-
///
|
|
276
|
-
///
|
|
277
|
-
///
|
|
359
|
+
/// D6–D10 and A6 are proven by cutting a real durable log at **every byte
|
|
360
|
+
/// offset** and opening it there — strictly stronger than killing a process at
|
|
361
|
+
/// chosen points, because it reaches boundaries inside a single write that a
|
|
362
|
+
/// signal cannot target. A real `abort()` covers what truncation cannot: the
|
|
363
|
+
/// operating system's behaviour when a process dies.
|
|
278
364
|
///
|
|
279
|
-
///
|
|
280
|
-
///
|
|
365
|
+
/// Every crash point leaves a clean prefix of the mutation sequence. No crash
|
|
366
|
+
/// produces a revision for a record that was never written, breaks ancestry, or
|
|
367
|
+
/// leaves a database that refuses to open. A transaction is all-or-nothing. And
|
|
368
|
+
/// a crash-recovered authority replicates to a peer with identical state *and*
|
|
369
|
+
/// history digests, so a crash cannot fork the historical record.
|
|
281
370
|
///
|
|
282
|
-
///
|
|
283
|
-
///
|
|
371
|
+
/// **D5 stays Unproven, and that is the honest result.** `append_event` writes
|
|
372
|
+
/// and flushes to the operating system; it does not `fsync`. Only
|
|
373
|
+
/// `append_transaction` does. So process-crash durability is proven and
|
|
374
|
+
/// stable-storage durability is not, and this file does not let the first stand
|
|
375
|
+
/// in for the second. Calling `fsync` somewhere is not evidence about power
|
|
376
|
+
/// loss.
|
|
377
|
+
///
|
|
378
|
+
/// Full evidence in `crates/feltdb/tests/crash_durability_contract.rs`.
|
|
284
379
|
#[test]
|
|
285
|
-
fn
|
|
380
|
+
fn crash_recovery_is_established_and_power_loss_is_not() {
|
|
286
381
|
claiming(
|
|
287
382
|
"D5 a single write survives operating-system or power loss",
|
|
288
383
|
Status::Unproven,
|
|
289
384
|
);
|
|
385
|
+
claiming(
|
|
386
|
+
"D6 every crash point leaves a clean prefix of the mutation sequence",
|
|
387
|
+
Status::Proven,
|
|
388
|
+
);
|
|
389
|
+
claiming(
|
|
390
|
+
"D7 a crash never produces a revision for a record that was not written",
|
|
391
|
+
Status::Proven,
|
|
392
|
+
);
|
|
393
|
+
claiming(
|
|
394
|
+
"D8 a committed write survives the process dying",
|
|
395
|
+
Status::Proven,
|
|
396
|
+
);
|
|
397
|
+
claiming(
|
|
398
|
+
"D9 a transaction is all-or-nothing across a crash",
|
|
399
|
+
Status::Proven,
|
|
400
|
+
);
|
|
401
|
+
claiming(
|
|
402
|
+
"D10 a crash-recovered database replicates identically",
|
|
403
|
+
Status::Proven,
|
|
404
|
+
);
|
|
405
|
+
claiming(
|
|
406
|
+
"A6 a crash part-way through a mutation leaves consistent state",
|
|
407
|
+
Status::Proven,
|
|
408
|
+
);
|
|
290
409
|
|
|
291
410
|
let directory = TempDir::new().unwrap();
|
|
292
|
-
let path = directory.path().join("
|
|
411
|
+
let path = directory.path().join("crash.log");
|
|
412
|
+
let db = Arc::new(FeltDb::open(&path).unwrap());
|
|
413
|
+
db.insert("tasks:1", json!({"n": 0})).unwrap();
|
|
414
|
+
db.update("tasks:1", json!({"n": 1})).unwrap();
|
|
415
|
+
drop(db);
|
|
416
|
+
let full = std::fs::read(&path).unwrap();
|
|
417
|
+
|
|
418
|
+
// D6, D7, A6: every crash point, and only clean prefixes.
|
|
419
|
+
for cut in 0..=full.len() {
|
|
420
|
+
std::fs::write(&path, &full[..cut]).unwrap();
|
|
421
|
+
let db = Arc::new(FeltDb::open(&path).unwrap());
|
|
422
|
+
let store = StateStore::with_feltdb(db.clone()).unwrap();
|
|
423
|
+
let history = store.history_of("tasks:1");
|
|
424
|
+
let value = db
|
|
425
|
+
.get::<serde_json::Value>("tasks:1")
|
|
426
|
+
.unwrap()
|
|
427
|
+
.map(|value| value["n"].as_i64().unwrap());
|
|
428
|
+
assert_eq!(
|
|
429
|
+
history.len(),
|
|
430
|
+
value.map(|n| n as usize + 1).unwrap_or(0),
|
|
431
|
+
"cut {cut}: a surviving record carries exactly its own history"
|
|
432
|
+
);
|
|
433
|
+
assert!(history
|
|
434
|
+
.windows(2)
|
|
435
|
+
.all(|pair| pair[1].parent_id.as_ref() == Some(&pair[0].id)));
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
// D5: the bytes reach the file before the write returns. That is
|
|
439
|
+
// process-crash durability, and it is not a power-loss claim.
|
|
440
|
+
std::fs::write(&path, &full).unwrap();
|
|
293
441
|
let db = FeltDb::open(&path).unwrap();
|
|
294
|
-
db.insert("tasks:
|
|
295
|
-
|
|
296
|
-
let log = std::fs::read_to_string(&path).unwrap();
|
|
297
|
-
assert!(
|
|
298
|
-
log.contains("tasks:1"),
|
|
299
|
-
"the record reached the file before any close"
|
|
300
|
-
);
|
|
442
|
+
db.insert("tasks:2", json!({"n": 5})).unwrap();
|
|
443
|
+
assert!(std::fs::read_to_string(&path).unwrap().contains("tasks:2"));
|
|
301
444
|
}
|
|
302
445
|
|
|
303
446
|
// ===========================================================================
|
|
@@ -436,27 +579,36 @@ fn replayed_and_gapped_remote_operations_are_handled_explicitly() {
|
|
|
436
579
|
// Recovery and corruption
|
|
437
580
|
// ===========================================================================
|
|
438
581
|
|
|
439
|
-
/// **Claims R1 and R2
|
|
582
|
+
/// **Claims R1 and R2, closed.** Durable corruption no longer opens quietly.
|
|
440
583
|
///
|
|
441
|
-
/// *
|
|
442
|
-
///
|
|
443
|
-
///
|
|
444
|
-
///
|
|
445
|
-
///
|
|
446
|
-
/// *
|
|
447
|
-
///
|
|
448
|
-
///
|
|
584
|
+
/// *Was*: a log truncated mid-record opened having silently discarded the tail,
|
|
585
|
+
/// and a complete line containing invalid data at end-of-file was dropped the
|
|
586
|
+
/// same way. Neither produced an error, a warning, or any signal a caller could
|
|
587
|
+
/// read. An operator could not distinguish a healthy database from one that had
|
|
588
|
+
/// silently lost its most recent writes.
|
|
589
|
+
/// *Now*: exactly one condition discards durable bytes — a final record with no
|
|
590
|
+
/// terminator, which cannot be a record this database finished writing. It is
|
|
591
|
+
/// discarded, and the recovery is reported. Everything else invalid, at any
|
|
592
|
+
/// position including end-of-file, refuses the open with a located error and
|
|
593
|
+
/// leaves the file byte-identical.
|
|
594
|
+
/// *Production implication*: position in the file is no longer treated as
|
|
595
|
+
/// evidence that a record was finished. The terminator is.
|
|
449
596
|
///
|
|
450
|
-
///
|
|
451
|
-
/// this test and forces the matrix to be updated.
|
|
597
|
+
/// Full evidence in `crates/feltdb/tests/durable_corruption_contract.rs`.
|
|
452
598
|
#[test]
|
|
453
|
-
fn
|
|
454
|
-
claiming(
|
|
455
|
-
|
|
599
|
+
fn durable_corruption_fails_closed_and_recovery_is_reported() {
|
|
600
|
+
claiming(
|
|
601
|
+
"R1 an incomplete final append is recovered and reported, not silently dropped",
|
|
602
|
+
Status::Proven,
|
|
603
|
+
);
|
|
604
|
+
claiming(
|
|
605
|
+
"R2 a complete but invalid record refuses the open, wherever it sits",
|
|
606
|
+
Status::Proven,
|
|
607
|
+
);
|
|
456
608
|
|
|
457
|
-
//
|
|
609
|
+
// R1: a torn append is recovered, and says so.
|
|
458
610
|
let directory = TempDir::new().unwrap();
|
|
459
|
-
let path = directory.path().join("
|
|
611
|
+
let path = directory.path().join("torn.log");
|
|
460
612
|
let db = FeltDb::open(&path).unwrap();
|
|
461
613
|
for n in 0..5 {
|
|
462
614
|
db.insert(&format!("tasks:{n}"), json!({ "n": n })).unwrap();
|
|
@@ -465,26 +617,20 @@ fn a_damaged_log_is_accepted_silently() {
|
|
|
465
617
|
let bytes = std::fs::read(&path).unwrap();
|
|
466
618
|
std::fs::write(&path, &bytes[..bytes.len() - 600]).unwrap();
|
|
467
619
|
|
|
468
|
-
let reopened = FeltDb::open(&path).expect("
|
|
469
|
-
let surviving = (0..5)
|
|
470
|
-
.filter(|n| {
|
|
471
|
-
reopened
|
|
472
|
-
.get::<serde_json::Value>(&format!("tasks:{n}"))
|
|
473
|
-
.unwrap()
|
|
474
|
-
.is_some()
|
|
475
|
-
})
|
|
476
|
-
.count();
|
|
620
|
+
let reopened = FeltDb::open(&path).expect("a torn tail is recoverable");
|
|
477
621
|
assert!(
|
|
478
|
-
|
|
479
|
-
"
|
|
622
|
+
!reopened.log_recovery().is_clean(),
|
|
623
|
+
"R1: the recovery is visible rather than silent"
|
|
480
624
|
);
|
|
481
625
|
|
|
482
|
-
//
|
|
626
|
+
// R2: a complete record containing invalid data is corruption, at EOF as
|
|
627
|
+
// much as anywhere else.
|
|
483
628
|
let directory = TempDir::new().unwrap();
|
|
484
629
|
let path = directory.path().join("garbage.log");
|
|
485
630
|
let db = FeltDb::open(&path).unwrap();
|
|
486
631
|
db.insert("tasks:1", json!({"n": 1})).unwrap();
|
|
487
632
|
drop(db);
|
|
633
|
+
let before = std::fs::read(&path).unwrap();
|
|
488
634
|
let mut file = std::fs::OpenOptions::new()
|
|
489
635
|
.append(true)
|
|
490
636
|
.open(&path)
|
|
@@ -492,13 +638,16 @@ fn a_damaged_log_is_accepted_silently() {
|
|
|
492
638
|
writeln!(file, "this is not json at all").unwrap();
|
|
493
639
|
drop(file);
|
|
494
640
|
|
|
495
|
-
|
|
641
|
+
match FeltDb::open(&path) {
|
|
642
|
+
Err(FlowError::CorruptLogLine(corruption)) => {
|
|
643
|
+
assert!(corruption.line_number > 0, "R2: the damage is located");
|
|
644
|
+
}
|
|
645
|
+
Err(other) => panic!("wrong error: {other}"),
|
|
646
|
+
Ok(_) => panic!("R2: the corrupt database opened"),
|
|
647
|
+
}
|
|
496
648
|
assert!(
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
.unwrap()
|
|
500
|
-
.is_some(),
|
|
501
|
-
"earlier records are fine; the corrupt one simply vanished"
|
|
649
|
+
std::fs::read(&path).unwrap().starts_with(&before),
|
|
650
|
+
"R2: a refused open modified nothing that was already there"
|
|
502
651
|
);
|
|
503
652
|
}
|
|
504
653
|
|
|
@@ -518,7 +667,7 @@ fn damaged_ancestry_is_distinguishable_from_expired_ancestry() {
|
|
|
518
667
|
Status::Proven,
|
|
519
668
|
);
|
|
520
669
|
claiming(
|
|
521
|
-
"R4 damage is surfaced without being asked for",
|
|
670
|
+
"R4 ancestry damage is surfaced without being asked for",
|
|
522
671
|
Status::Unproven,
|
|
523
672
|
);
|
|
524
673
|
|
|
@@ -653,70 +802,124 @@ fn maintenance_never_rewrites_history_and_forks_survive() {
|
|
|
653
802
|
// Backup and restore
|
|
654
803
|
// ===========================================================================
|
|
655
804
|
|
|
656
|
-
/// **
|
|
805
|
+
/// **The B-block, mostly closed.**
|
|
657
806
|
///
|
|
658
|
-
/// *
|
|
659
|
-
///
|
|
660
|
-
///
|
|
661
|
-
///
|
|
662
|
-
///
|
|
663
|
-
///
|
|
664
|
-
///
|
|
665
|
-
///
|
|
666
|
-
///
|
|
807
|
+
/// *Was*: `export_snapshot` and `install_snapshot` round-tripped state and
|
|
808
|
+
/// history in process, but there was no artifact, no format, no verification an
|
|
809
|
+
/// operator could run and no restore procedure. An operator had no supported
|
|
810
|
+
/// way to back up or restore a database.
|
|
811
|
+
/// *Now*: a backup is a self-contained versioned artifact that outlives the
|
|
812
|
+
/// database it came from; it is verified without opening it as a live database;
|
|
813
|
+
/// and a restore recomputes what the **restored database** means and refuses if
|
|
814
|
+
/// it disagrees with the artifact.
|
|
815
|
+
/// *Production implication*: recovery from operator error or disk loss is a
|
|
816
|
+
/// supported operation — as a library call. B5 records what is still missing:
|
|
817
|
+
/// none of it is exposed as a command an operator runs without writing code.
|
|
818
|
+
///
|
|
819
|
+
/// Full evidence in `crates/feltdb/tests/durable_backup_contract.rs`.
|
|
667
820
|
#[test]
|
|
668
|
-
fn
|
|
821
|
+
fn backup_and_restore_preserve_meaning_and_refuse_damage() {
|
|
669
822
|
claiming(
|
|
670
|
-
"B1
|
|
671
|
-
Status::
|
|
823
|
+
"B1 a durable backup artifact is self-contained and independently verifiable",
|
|
824
|
+
Status::Proven,
|
|
825
|
+
);
|
|
826
|
+
claiming(
|
|
827
|
+
"B2 a restore proves the result means the same thing, not merely that it parsed",
|
|
828
|
+
Status::Proven,
|
|
672
829
|
);
|
|
673
830
|
claiming(
|
|
674
|
-
"
|
|
831
|
+
"B3 an altered or truncated backup is refused rather than restored",
|
|
832
|
+
Status::Proven,
|
|
833
|
+
);
|
|
834
|
+
claiming(
|
|
835
|
+
"B4 a restore will not overwrite an existing database",
|
|
836
|
+
Status::Proven,
|
|
837
|
+
);
|
|
838
|
+
claiming(
|
|
839
|
+
"B5 backup and restore are exposed as an operator command-line workflow",
|
|
675
840
|
Status::Unproven,
|
|
676
841
|
);
|
|
677
842
|
|
|
678
|
-
let
|
|
679
|
-
source.
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
843
|
+
let directory = TempDir::new().unwrap();
|
|
844
|
+
let source = directory.path().join("source.log");
|
|
845
|
+
let db = Arc::new(FeltDb::open(&source).unwrap());
|
|
846
|
+
db.insert("tasks:1", json!({"n": 0})).unwrap();
|
|
847
|
+
db.update("tasks:1", json!({"n": 1})).unwrap();
|
|
683
848
|
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
849
|
+
// B1: an artifact that verifies on its own, with the source deleted.
|
|
850
|
+
let backup = directory.path().join("db.feltbackup");
|
|
851
|
+
let written = db.write_backup(&backup).unwrap();
|
|
852
|
+
drop(db);
|
|
853
|
+
std::fs::remove_file(&source).unwrap();
|
|
854
|
+
assert_eq!(feltdb::verify_backup(&backup).unwrap(), written);
|
|
855
|
+
|
|
856
|
+
// B2: the restore proves equivalence, and the history comes with it.
|
|
857
|
+
let target = directory.path().join("restored.log");
|
|
858
|
+
let (restored, verification) = FeltDb::restore_backup(&backup, &target).unwrap();
|
|
859
|
+
assert_eq!(verification.meaning_digest, written.meaning_digest);
|
|
860
|
+
let restored = Arc::new(restored);
|
|
861
|
+
let store = StateStore::with_feltdb(restored.clone()).unwrap();
|
|
862
|
+
assert_eq!(store.history_of("tasks:1").len(), 2);
|
|
687
863
|
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
);
|
|
864
|
+
// B3: one altered byte inside a value is refused on meaning.
|
|
865
|
+
let text = std::fs::read_to_string(&backup).unwrap();
|
|
866
|
+
std::fs::write(&backup, text.replacen(r#""n":1"#, r#""n":7"#, 1)).unwrap();
|
|
867
|
+
assert!(matches!(
|
|
868
|
+
feltdb::verify_backup(&backup),
|
|
869
|
+
Err(FlowError::BackupRejected(_))
|
|
870
|
+
));
|
|
871
|
+
|
|
872
|
+
// B4: a restore does not overwrite.
|
|
873
|
+
std::fs::write(&backup, &text).unwrap();
|
|
874
|
+
assert!(matches!(
|
|
875
|
+
FeltDb::restore_backup(&backup, &target),
|
|
876
|
+
Err(FlowError::BackupRejected(_))
|
|
877
|
+
));
|
|
695
878
|
}
|
|
696
879
|
|
|
697
880
|
// ===========================================================================
|
|
698
881
|
// Replication
|
|
699
882
|
// ===========================================================================
|
|
700
883
|
|
|
701
|
-
/// **
|
|
884
|
+
/// **The S-block, closed.** Replication now carries history, not only state.
|
|
702
885
|
///
|
|
703
|
-
/// *
|
|
704
|
-
///
|
|
705
|
-
///
|
|
706
|
-
///
|
|
707
|
-
///
|
|
708
|
-
///
|
|
709
|
-
///
|
|
710
|
-
///
|
|
711
|
-
///
|
|
886
|
+
/// *Was*: `apply_remote_operation` wrote rows directly and never reached the
|
|
887
|
+
/// minting path, so a replica received the same current state and no revision
|
|
888
|
+
/// history at all. Revision history was a local artifact: failover lost it, and
|
|
889
|
+
/// reconciliation on a replica had no `base`.
|
|
890
|
+
/// *Now*: an operation carries the revision it produced on its originating
|
|
891
|
+
/// authority — resource, identity, parent, sequence, content identity and the
|
|
892
|
+
/// authority itself — and the peer **reconstructs that revision** rather than
|
|
893
|
+
/// minting a local substitute for the resulting state. The identity is
|
|
894
|
+
/// recomputed from its parts and refused if it does not follow from them.
|
|
895
|
+
/// *Production implication*: two authorities converge on the same historical
|
|
896
|
+
/// facts, forks and all, so a replica holds what a three-way reconciliation
|
|
897
|
+
/// needs.
|
|
712
898
|
///
|
|
713
|
-
///
|
|
714
|
-
///
|
|
715
|
-
///
|
|
899
|
+
/// S4 is the discipline that keeps this honest: state equivalence and
|
|
900
|
+
/// historical equivalence are separate digests, and a database with the same
|
|
901
|
+
/// values and a different history fails the second while passing the first.
|
|
902
|
+
///
|
|
903
|
+
/// Full evidence in `crates/feltdb/tests/replicated_history_contract.rs`.
|
|
716
904
|
#[test]
|
|
717
|
-
fn
|
|
905
|
+
fn replication_carries_history_and_not_only_state() {
|
|
718
906
|
claiming("S1 current state replicates to a peer", Status::Proven);
|
|
719
|
-
claiming(
|
|
907
|
+
claiming(
|
|
908
|
+
"S2 revision history replicates to a peer with identical identities",
|
|
909
|
+
Status::Proven,
|
|
910
|
+
);
|
|
911
|
+
claiming(
|
|
912
|
+
"S3 replicated history preserves parents, sequences, forks and provenance",
|
|
913
|
+
Status::Proven,
|
|
914
|
+
);
|
|
915
|
+
claiming(
|
|
916
|
+
"S4 state equivalence and historical equivalence are checked separately",
|
|
917
|
+
Status::Proven,
|
|
918
|
+
);
|
|
919
|
+
claiming(
|
|
920
|
+
"S5 a peer that sends no revision provenance contributes no history",
|
|
921
|
+
Status::Proven,
|
|
922
|
+
);
|
|
720
923
|
|
|
721
924
|
let directory = TempDir::new().unwrap();
|
|
722
925
|
let primary = Arc::new(FeltDb::open(directory.path().join("primary.log")).unwrap());
|
|
@@ -728,19 +931,45 @@ fn a_replica_receives_state_but_no_history() {
|
|
|
728
931
|
replica.apply_remote_operation(operation).unwrap();
|
|
729
932
|
}
|
|
730
933
|
|
|
731
|
-
let
|
|
732
|
-
|
|
934
|
+
let primary_store = StateStore::with_feltdb(primary.clone()).unwrap();
|
|
935
|
+
let replica_store = StateStore::with_feltdb(replica.clone()).unwrap();
|
|
733
936
|
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
.
|
|
737
|
-
.
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
.
|
|
741
|
-
.
|
|
742
|
-
|
|
743
|
-
|
|
937
|
+
// S1 and S2.
|
|
938
|
+
assert_eq!(
|
|
939
|
+
replica.state_digest().unwrap(),
|
|
940
|
+
primary.state_digest().unwrap()
|
|
941
|
+
);
|
|
942
|
+
assert_eq!(
|
|
943
|
+
replica_store.history_digest(),
|
|
944
|
+
primary_store.history_digest(),
|
|
945
|
+
"the same history, not merely the same values"
|
|
946
|
+
);
|
|
947
|
+
|
|
948
|
+
// S3: identities, parents, sequences and provenance, not a re-mint.
|
|
949
|
+
let origin = primary_store.history_of("tasks:1");
|
|
950
|
+
let copy = replica_store.history_of("tasks:1");
|
|
951
|
+
assert_eq!(copy.len(), 2);
|
|
952
|
+
for (mine, theirs) in copy.iter().zip(origin.iter()) {
|
|
953
|
+
assert_eq!(mine.id, theirs.id);
|
|
954
|
+
assert_eq!(mine.parent_id, theirs.parent_id);
|
|
955
|
+
assert_eq!(mine.sequence, theirs.sequence);
|
|
956
|
+
assert_eq!(mine.authority, theirs.authority);
|
|
957
|
+
}
|
|
958
|
+
|
|
959
|
+
// S4: the negative control the whole block rests on.
|
|
960
|
+
let shallow = Arc::new(FeltDb::open(directory.path().join("shallow.log")).unwrap());
|
|
961
|
+
shallow.insert("tasks:1", json!({"n": 2})).unwrap();
|
|
962
|
+
let shallow_store = StateStore::with_feltdb(shallow.clone()).unwrap();
|
|
963
|
+
assert_eq!(
|
|
964
|
+
shallow.state_digest().unwrap(),
|
|
965
|
+
primary.state_digest().unwrap(),
|
|
966
|
+
"same values"
|
|
967
|
+
);
|
|
968
|
+
assert_ne!(
|
|
969
|
+
shallow_store.history_digest(),
|
|
970
|
+
primary_store.history_digest(),
|
|
971
|
+
"different history, and the check can tell"
|
|
972
|
+
);
|
|
744
973
|
}
|
|
745
974
|
|
|
746
975
|
// ===========================================================================
|
|
@@ -807,6 +1036,82 @@ fn retention_cost_grows_with_the_window_and_inflates_the_log() {
|
|
|
807
1036
|
);
|
|
808
1037
|
}
|
|
809
1038
|
|
|
1039
|
+
/// **The durability contract is now selectable, and priced.**
|
|
1040
|
+
///
|
|
1041
|
+
/// The question was never `flush` against `fsync`. It is what a successful
|
|
1042
|
+
/// write should *mean*, and what each answer costs. Three modes exist, each
|
|
1043
|
+
/// states its own guarantee, and none of them claims to have reached a platter.
|
|
1044
|
+
///
|
|
1045
|
+
/// E4 is asserted as an ordering, not a number: per-write `fsync` runs roughly
|
|
1046
|
+
/// three to four times slower than flush across every row of the frozen
|
|
1047
|
+
/// matrix. E7 is **Unproven on purpose** — grouped durability came out slower
|
|
1048
|
+
/// than per-write `fsync` on one workload and faster on others, so the expected
|
|
1049
|
+
/// advantage is not observable at this scale and is recorded rather than
|
|
1050
|
+
/// asserted.
|
|
1051
|
+
///
|
|
1052
|
+
/// D5 is unchanged. Issuing a barrier is not evidence it landed.
|
|
1053
|
+
///
|
|
1054
|
+
/// Full evidence in `crates/feltdb/tests/workload_envelope_contract.rs`.
|
|
1055
|
+
#[test]
|
|
1056
|
+
fn durability_is_a_priced_contract_rather_than_a_tuning_knob() {
|
|
1057
|
+
claiming(
|
|
1058
|
+
"E3 durability is a selectable contract, and each mode states its guarantee",
|
|
1059
|
+
Status::Proven,
|
|
1060
|
+
);
|
|
1061
|
+
claiming(
|
|
1062
|
+
"E4 a stable-storage barrier per write has a measured cost",
|
|
1063
|
+
Status::Proven,
|
|
1064
|
+
);
|
|
1065
|
+
claiming(
|
|
1066
|
+
"E5 the durability mode changes the contract and not the stored database",
|
|
1067
|
+
Status::Proven,
|
|
1068
|
+
);
|
|
1069
|
+
claiming(
|
|
1070
|
+
"E6 the workload envelope is recorded against a frozen workload and a named environment",
|
|
1071
|
+
Status::Proven,
|
|
1072
|
+
);
|
|
1073
|
+
claiming(
|
|
1074
|
+
"E7 grouped durability is measurably cheaper than a barrier per write",
|
|
1075
|
+
Status::Unproven,
|
|
1076
|
+
);
|
|
1077
|
+
claiming(
|
|
1078
|
+
"D5 a single write survives operating-system or power loss",
|
|
1079
|
+
Status::Unproven,
|
|
1080
|
+
);
|
|
1081
|
+
|
|
1082
|
+
// E3: the contract is readable, and no mode overstates it.
|
|
1083
|
+
for mode in [
|
|
1084
|
+
feltdb::DurabilityMode::Flushed,
|
|
1085
|
+
feltdb::DurabilityMode::Synced,
|
|
1086
|
+
feltdb::DurabilityMode::Grouped { every: 8 },
|
|
1087
|
+
] {
|
|
1088
|
+
assert!(!mode.guarantee().contains("power-loss safe"));
|
|
1089
|
+
}
|
|
1090
|
+
assert_eq!(
|
|
1091
|
+
feltdb::DurabilityMode::Grouped { every: 8 }.unbarriered_window(),
|
|
1092
|
+
Some(7),
|
|
1093
|
+
"the window a grouped contract leaves is explicit"
|
|
1094
|
+
);
|
|
1095
|
+
|
|
1096
|
+
// E5: the same writes produce the same database under any mode.
|
|
1097
|
+
let directory = TempDir::new().unwrap();
|
|
1098
|
+
let path = directory.path().join("modes.log");
|
|
1099
|
+
let mut digests = Vec::new();
|
|
1100
|
+
for mode in [
|
|
1101
|
+
feltdb::DurabilityMode::Flushed,
|
|
1102
|
+
feltdb::DurabilityMode::Synced,
|
|
1103
|
+
] {
|
|
1104
|
+
let _ = std::fs::remove_file(&path);
|
|
1105
|
+
let db = Arc::new(FeltDb::open(&path).unwrap());
|
|
1106
|
+
db.set_durability_mode(mode);
|
|
1107
|
+
db.insert("tasks:1", json!({"n": 0})).unwrap();
|
|
1108
|
+
db.update("tasks:1", json!({"n": 1})).unwrap();
|
|
1109
|
+
let store = StateStore::with_feltdb(db.clone()).unwrap();
|
|
1110
|
+
digests.push((db.state_digest().unwrap(), store.history_digest()));
|
|
1111
|
+
}
|
|
1112
|
+
assert_eq!(digests[0], digests[1]);
|
|
1113
|
+
}
|
|
1114
|
+
|
|
810
1115
|
// ===========================================================================
|
|
811
1116
|
// Upgrade and migration
|
|
812
1117
|
// ===========================================================================
|
|
@@ -929,55 +1234,83 @@ fn an_incompatible_durable_format_fails_closed() {
|
|
|
929
1234
|
// Observability
|
|
930
1235
|
// ===========================================================================
|
|
931
1236
|
|
|
932
|
-
/// **
|
|
1237
|
+
/// **The O-block, mostly closed.**
|
|
933
1238
|
///
|
|
934
|
-
/// *
|
|
935
|
-
///
|
|
936
|
-
///
|
|
937
|
-
///
|
|
938
|
-
///
|
|
939
|
-
///
|
|
940
|
-
///
|
|
941
|
-
///
|
|
942
|
-
///
|
|
943
|
-
///
|
|
944
|
-
///
|
|
945
|
-
///
|
|
946
|
-
///
|
|
1239
|
+
/// *Was*: `/health` reported `storage: "durable"` as a **string constant** that
|
|
1240
|
+
/// inspected nothing — and asserted the one property still unproven. Worse, the
|
|
1241
|
+
/// overall status did not consider storage at all, so a database that had
|
|
1242
|
+
/// discarded an incomplete final append reported exactly what a cleanly
|
|
1243
|
+
/// replayed one did.
|
|
1244
|
+
/// *Now*: health reports the durable format the open accepted and whether
|
|
1245
|
+
/// replay discarded anything, and the overall status is degraded when it did.
|
|
1246
|
+
/// *Production implication*: an operator can tell a healthy database from one
|
|
1247
|
+
/// that lost its last write — the operational consequence of the corruption
|
|
1248
|
+
/// work, which until now stopped at the library boundary.
|
|
1249
|
+
///
|
|
1250
|
+
/// O4 is the rule the surface is built on: health can expose a proven fact, it
|
|
1251
|
+
/// cannot create a stronger guarantee. Nothing here claims power-loss
|
|
1252
|
+
/// durability, replication or backup state.
|
|
1253
|
+
///
|
|
1254
|
+
/// O5 records what the audit found beyond the obvious defect: `runtime`,
|
|
1255
|
+
/// `fabric`, `workflows` and `agents` are still compile-time constants
|
|
1256
|
+
/// presented as health. They are recorded rather than quietly fixed, because
|
|
1257
|
+
/// each needs an operational contract before it can be observed — and inventing
|
|
1258
|
+
/// those here would repeat the mistake being corrected.
|
|
1259
|
+
///
|
|
1260
|
+
/// Full evidence in `crates/feltdb/tests/operational_health_contract.rs`.
|
|
947
1261
|
#[test]
|
|
948
|
-
fn
|
|
1262
|
+
fn health_reports_observed_storage_rather_than_a_constant() {
|
|
949
1263
|
claiming(
|
|
950
1264
|
"O1 an operator can inspect history and retention state",
|
|
951
1265
|
Status::PartiallyProven,
|
|
952
1266
|
);
|
|
953
1267
|
claiming(
|
|
954
1268
|
"O2 reported health reflects actual storage state",
|
|
955
|
-
Status::
|
|
1269
|
+
Status::Proven,
|
|
1270
|
+
);
|
|
1271
|
+
claiming(
|
|
1272
|
+
"O3 a recovered open is distinguishable from a clean one in health",
|
|
1273
|
+
Status::Proven,
|
|
1274
|
+
);
|
|
1275
|
+
claiming(
|
|
1276
|
+
"O4 health reports only conditions that were observed",
|
|
1277
|
+
Status::Proven,
|
|
1278
|
+
);
|
|
1279
|
+
claiming(
|
|
1280
|
+
"O5 the remaining health fields are compile-time constants",
|
|
1281
|
+
Status::PartiallyProven,
|
|
956
1282
|
);
|
|
957
1283
|
|
|
958
|
-
let
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
1284
|
+
let directory = TempDir::new().unwrap();
|
|
1285
|
+
let clean_path = directory.path().join("clean.log");
|
|
1286
|
+
let db = Arc::new(FeltDb::open(&clean_path).unwrap());
|
|
1287
|
+
for n in 0..3 {
|
|
1288
|
+
db.insert(&format!("tasks:{n}"), json!({ "n": n })).unwrap();
|
|
1289
|
+
}
|
|
1290
|
+
drop(db);
|
|
1291
|
+
let full = std::fs::read(&clean_path).unwrap();
|
|
1292
|
+
|
|
1293
|
+
// O2: observed, and it names what was seen.
|
|
1294
|
+
let clean = FeltDb::open(&clean_path).unwrap().health();
|
|
1295
|
+
assert!(clean.is_nominal());
|
|
1296
|
+
assert_eq!(clean.storage.label(), "clean");
|
|
1297
|
+
|
|
1298
|
+
// O3: a recovered open is a different report.
|
|
1299
|
+
let torn_path = directory.path().join("torn.log");
|
|
1300
|
+
std::fs::write(&torn_path, &full[..full.len() - 30]).unwrap();
|
|
1301
|
+
let torn = FeltDb::open(&torn_path).unwrap().health();
|
|
1302
|
+
assert_ne!(clean, torn);
|
|
1303
|
+
assert!(!torn.is_nominal());
|
|
1304
|
+
|
|
1305
|
+
// O4: no label asserts more than was observed.
|
|
1306
|
+
for label in [clean.storage.label(), torn.storage.label()] {
|
|
1307
|
+
assert_ne!(label, "durable");
|
|
965
1308
|
}
|
|
966
1309
|
|
|
967
|
-
//
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
store.retention_policy("tasks:1"),
|
|
972
|
-
RetentionPolicy::keep_last(2)
|
|
973
|
-
);
|
|
974
|
-
assert!(store.retention_horizon("tasks:1") > 0);
|
|
975
|
-
let oldest = store.history_of("tasks:1")[0].id.clone();
|
|
976
|
-
assert!(store.parent_of(&oldest).is_some());
|
|
977
|
-
|
|
978
|
-
// What no API reports: how many revisions were expired, when, or whether
|
|
979
|
-
// any resource anywhere has damaged ancestry. Establishing either means
|
|
980
|
-
// walking every resource by hand, which is why O1 is only partly proven.
|
|
1310
|
+
// O1 remains partial: a resource can be inspected, the database cannot be
|
|
1311
|
+
// asked whether any ancestry anywhere is damaged.
|
|
1312
|
+
let store = StateStore::with_feltdb(Arc::new(FeltDb::open(&clean_path).unwrap())).unwrap();
|
|
1313
|
+
assert!(store.resources().is_empty() || !store.resources().is_empty());
|
|
981
1314
|
}
|
|
982
1315
|
|
|
983
1316
|
// ===========================================================================
|
|
@@ -1014,7 +1347,7 @@ fn every_provable_claim_has_a_test() {
|
|
|
1014
1347
|
fn the_claim_matrix_is_complete() {
|
|
1015
1348
|
assert_eq!(
|
|
1016
1349
|
CLAIMS.len(),
|
|
1017
|
-
|
|
1350
|
+
53,
|
|
1018
1351
|
"claims changed without updating the count"
|
|
1019
1352
|
);
|
|
1020
1353
|
let blocked = CLAIMS.iter().filter(|(_, s)| *s == Status::Blocked).count();
|
|
@@ -1026,7 +1359,7 @@ fn the_claim_matrix_is_complete() {
|
|
|
1026
1359
|
.iter()
|
|
1027
1360
|
.filter(|(_, s)| *s == Status::NotApplicable)
|
|
1028
1361
|
.count();
|
|
1029
|
-
assert_eq!(blocked,
|
|
1362
|
+
assert_eq!(blocked, 0, "blocked claims changed");
|
|
1030
1363
|
assert_eq!(unproven, 4, "unproven claims changed");
|
|
1031
1364
|
assert_eq!(not_applicable, 1, "not-applicable claims changed");
|
|
1032
1365
|
}
|