@feltdb/core 0.8.5 → 0.8.7
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/common/mod.rs +96 -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/managed_incident_regression.rs +191 -0
- package/dist/create/server-source/crates/feltdb/tests/operational_health_contract.rs +278 -0
- package/dist/create/server-source/crates/feltdb/tests/production_certification.rs +1153 -0
- package/dist/create/server-source/crates/feltdb/tests/production_contract.rs +771 -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
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
//! The managed incident, composed.
|
|
2
|
+
//!
|
|
3
|
+
//! The four remediations each have their own contract: `compaction_stall_contract`
|
|
4
|
+
//! covers the deferred rewrite, `bounded_read_contract` covers scoped reads, and
|
|
5
|
+
//! the server crate's `admission_tests` cover the refusal. Every one of them
|
|
6
|
+
//! exercises its fix in isolation, on an idle database.
|
|
7
|
+
//!
|
|
8
|
+
//! The incident was not any one of them in isolation. It was their composition:
|
|
9
|
+
//! a populated collection, a timer firing compaction into the single lock that
|
|
10
|
+
//! serializes every read and write, and scoped requests arriving throughout.
|
|
11
|
+
//! A database that satisfies each contract separately can still fail the shape
|
|
12
|
+
//! that actually happened, so the shape gets its own test.
|
|
13
|
+
//!
|
|
14
|
+
//! # What this file does not establish
|
|
15
|
+
//!
|
|
16
|
+
//! **It does not measure latency, and it does not prove the incident cannot
|
|
17
|
+
//! recur.** No assertion here bounds how long a read waits, and every read
|
|
18
|
+
//! still queues behind the same mutex. That is claim `M6`, and it is recorded
|
|
19
|
+
//! in the certification matrix as `Unproven` precisely because a test like this
|
|
20
|
+
//! one is not evidence for it. A timing assertion added here would either be
|
|
21
|
+
//! flaky or be tuned until it passed, and both are worse than an honest gap.
|
|
22
|
+
//!
|
|
23
|
+
//! What it does establish is that under the composed load, the timer does not
|
|
24
|
+
//! rewrite, scoped reads stay bounded, and no read observes a torn or missing
|
|
25
|
+
//! database.
|
|
26
|
+
|
|
27
|
+
use feltdb::{CompactionOutcome, CompactionPolicy, FeltDb};
|
|
28
|
+
use serde_json::json;
|
|
29
|
+
use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
|
|
30
|
+
use std::sync::Arc;
|
|
31
|
+
use tempfile::TempDir;
|
|
32
|
+
|
|
33
|
+
const ROWS: usize = 800;
|
|
34
|
+
|
|
35
|
+
/// A database shaped like the affected instance: one large collection, every
|
|
36
|
+
/// operation acknowledged so a compaction always has work to do.
|
|
37
|
+
fn incident_shaped() -> (TempDir, Arc<FeltDb>) {
|
|
38
|
+
let directory = TempDir::new().unwrap();
|
|
39
|
+
let db = Arc::new(FeltDb::open(directory.path().join("incident.log")).unwrap());
|
|
40
|
+
db.set_compaction_policy(CompactionPolicy::default());
|
|
41
|
+
|
|
42
|
+
for index in 0..ROWS {
|
|
43
|
+
db.insert(
|
|
44
|
+
&format!("docs:{index:05}"),
|
|
45
|
+
json!({ "n": index, "body": "x".repeat(200) }),
|
|
46
|
+
)
|
|
47
|
+
.unwrap();
|
|
48
|
+
}
|
|
49
|
+
let versions = db.operation_versions().unwrap();
|
|
50
|
+
db.acknowledge_peer_versions("peer-1".to_string(), versions)
|
|
51
|
+
.unwrap();
|
|
52
|
+
|
|
53
|
+
(directory, db)
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/// **Scoped reads stay correct and bounded while the compaction timer runs.**
|
|
57
|
+
///
|
|
58
|
+
/// The compacting thread calls the same entry point the server's timer calls,
|
|
59
|
+
/// continuously, for the whole run. The reading thread does what the handlers
|
|
60
|
+
/// now do: a keyed lookup and a limited search, never a materializing scan.
|
|
61
|
+
///
|
|
62
|
+
/// Three properties are asserted, and each one is a way the incident could
|
|
63
|
+
/// return:
|
|
64
|
+
///
|
|
65
|
+
/// 1. every read returns the right answer — a compaction running concurrently
|
|
66
|
+
/// never exposes a partially pruned database;
|
|
67
|
+
/// 2. every bounded search visits exactly its limit — the amplifier is still
|
|
68
|
+
/// absent under load, not just on an idle database;
|
|
69
|
+
/// 3. the timer performs no durable rewrite — the expensive half stays deferred
|
|
70
|
+
/// when the policy says it should, and does not creep back in because
|
|
71
|
+
/// something else about the workload changed.
|
|
72
|
+
#[test]
|
|
73
|
+
fn scoped_reads_stay_correct_and_bounded_while_compaction_runs() {
|
|
74
|
+
let (_directory, db) = incident_shaped();
|
|
75
|
+
|
|
76
|
+
let stop = Arc::new(AtomicBool::new(false));
|
|
77
|
+
let rewrites = Arc::new(AtomicUsize::new(0));
|
|
78
|
+
let ticks = Arc::new(AtomicUsize::new(0));
|
|
79
|
+
|
|
80
|
+
let compactor = {
|
|
81
|
+
let db = db.clone();
|
|
82
|
+
let stop = stop.clone();
|
|
83
|
+
let rewrites = rewrites.clone();
|
|
84
|
+
let ticks = ticks.clone();
|
|
85
|
+
std::thread::spawn(move || {
|
|
86
|
+
while !stop.load(Ordering::Relaxed) {
|
|
87
|
+
let outcome = db
|
|
88
|
+
.maybe_compact_operation_log(&["peer-1".to_string()])
|
|
89
|
+
.expect("a timer-driven compaction does not fail");
|
|
90
|
+
ticks.fetch_add(1, Ordering::Relaxed);
|
|
91
|
+
if matches!(outcome, CompactionOutcome::Rewritten { .. }) {
|
|
92
|
+
rewrites.fetch_add(1, Ordering::Relaxed);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
})
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
for round in 0..200 {
|
|
99
|
+
let key = format!("docs:{:05}", round % ROWS);
|
|
100
|
+
let row = db
|
|
101
|
+
.get_collection_record("docs", &key)
|
|
102
|
+
.unwrap()
|
|
103
|
+
.unwrap_or_else(|| panic!("{key} is readable while compaction runs"));
|
|
104
|
+
assert_eq!(row.value["n"], json!(round % ROWS));
|
|
105
|
+
|
|
106
|
+
let visited = AtomicUsize::new(0);
|
|
107
|
+
let matched = db
|
|
108
|
+
.query_collection("docs", Some(10), |row| {
|
|
109
|
+
visited.fetch_add(1, Ordering::Relaxed);
|
|
110
|
+
row.value["body"].is_string()
|
|
111
|
+
})
|
|
112
|
+
.unwrap();
|
|
113
|
+
assert_eq!(matched.len(), 10, "the limit is honoured under load");
|
|
114
|
+
assert_eq!(
|
|
115
|
+
visited.load(Ordering::Relaxed),
|
|
116
|
+
10,
|
|
117
|
+
"and the search did not walk the collection"
|
|
118
|
+
);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
stop.store(true, Ordering::Relaxed);
|
|
122
|
+
compactor.join().unwrap();
|
|
123
|
+
|
|
124
|
+
assert!(
|
|
125
|
+
ticks.load(Ordering::Relaxed) > 0,
|
|
126
|
+
"the compaction thread actually ran"
|
|
127
|
+
);
|
|
128
|
+
assert_eq!(
|
|
129
|
+
rewrites.load(Ordering::Relaxed),
|
|
130
|
+
0,
|
|
131
|
+
"the timer rewrote the durable log during the run; the deferred-rewrite \
|
|
132
|
+
fix does not hold under the composed workload"
|
|
133
|
+
);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/// **The composed run leaves the database intact.**
|
|
137
|
+
///
|
|
138
|
+
/// The incident's worst plausible outcome was not slowness, it was a database
|
|
139
|
+
/// damaged by a rewrite interleaved with traffic. After the concurrent run the
|
|
140
|
+
/// full collection is still present, still correct, and still there after a
|
|
141
|
+
/// reopen — including once a rewrite is forced to happen.
|
|
142
|
+
#[test]
|
|
143
|
+
fn a_concurrent_compaction_leaves_the_database_intact() {
|
|
144
|
+
let directory = TempDir::new().unwrap();
|
|
145
|
+
let path = directory.path().join("intact.log");
|
|
146
|
+
let db = Arc::new(FeltDb::open(&path).unwrap());
|
|
147
|
+
db.set_compaction_policy(CompactionPolicy::default());
|
|
148
|
+
|
|
149
|
+
for index in 0..ROWS {
|
|
150
|
+
db.insert(&format!("docs:{index:05}"), json!({ "n": index }))
|
|
151
|
+
.unwrap();
|
|
152
|
+
}
|
|
153
|
+
let versions = db.operation_versions().unwrap();
|
|
154
|
+
db.acknowledge_peer_versions("peer-1".to_string(), versions)
|
|
155
|
+
.unwrap();
|
|
156
|
+
|
|
157
|
+
let stop = Arc::new(AtomicBool::new(false));
|
|
158
|
+
let compactor = {
|
|
159
|
+
let db = db.clone();
|
|
160
|
+
let stop = stop.clone();
|
|
161
|
+
std::thread::spawn(move || {
|
|
162
|
+
while !stop.load(Ordering::Relaxed) {
|
|
163
|
+
db.maybe_compact_operation_log(&["peer-1".to_string()])
|
|
164
|
+
.unwrap();
|
|
165
|
+
}
|
|
166
|
+
})
|
|
167
|
+
};
|
|
168
|
+
|
|
169
|
+
for index in ROWS..ROWS + 200 {
|
|
170
|
+
db.insert(&format!("docs:{index:05}"), json!({ "n": index }))
|
|
171
|
+
.unwrap();
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
stop.store(true, Ordering::Relaxed);
|
|
175
|
+
compactor.join().unwrap();
|
|
176
|
+
|
|
177
|
+
// An explicit compaction still rewrites — the deferral is a policy, not a
|
|
178
|
+
// disablement — and the rewrite happens over a database that concurrent
|
|
179
|
+
// traffic has just been mutating.
|
|
180
|
+
db.compact_operation_log(&["peer-1".to_string()]).unwrap();
|
|
181
|
+
drop(db);
|
|
182
|
+
|
|
183
|
+
let reopened = FeltDb::open(&path).unwrap();
|
|
184
|
+
for index in 0..ROWS + 200 {
|
|
185
|
+
let value: serde_json::Value = reopened
|
|
186
|
+
.get(&format!("docs:{index:05}"))
|
|
187
|
+
.unwrap()
|
|
188
|
+
.unwrap_or_else(|| panic!("docs:{index:05} survived the composed run"));
|
|
189
|
+
assert_eq!(value["n"], json!(index));
|
|
190
|
+
}
|
|
191
|
+
}
|
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
//! Operational health.
|
|
2
|
+
//!
|
|
3
|
+
//! Everything from the format work onward proved what FeltDB *does*. This file
|
|
4
|
+
//! asks a different question:
|
|
5
|
+
//!
|
|
6
|
+
//! > **Can an operator reliably tell what FeltDB knows about itself?**
|
|
7
|
+
//!
|
|
8
|
+
//! The rule the whole surface is built on:
|
|
9
|
+
//!
|
|
10
|
+
//! > **Health can expose a proven fact. It cannot create a stronger
|
|
11
|
+
//! > guarantee.**
|
|
12
|
+
//!
|
|
13
|
+
//! So `DatabaseHealth` reports the durable format an open accepted and whether
|
|
14
|
+
//! replay discarded an incomplete final append, and nothing else. There is no
|
|
15
|
+
//! replication health, no backup freshness and no durability claim, because
|
|
16
|
+
//! none of those has an operational contract yet and inventing one here would
|
|
17
|
+
//! reintroduce exactly the defect this replaces: a reassuring label that
|
|
18
|
+
//! observes nothing.
|
|
19
|
+
//!
|
|
20
|
+
//! See `docs/architecture/operational-health.md`.
|
|
21
|
+
|
|
22
|
+
use feltdb::{
|
|
23
|
+
inspect_durable_format, DatabaseHealth, DurableFormat, FeltDb, FlowError, LogRecovery,
|
|
24
|
+
StorageHealth, DURABLE_FORMAT_VERSION,
|
|
25
|
+
};
|
|
26
|
+
use serde_json::{json, Value};
|
|
27
|
+
use std::io::Write;
|
|
28
|
+
use std::path::Path;
|
|
29
|
+
use tempfile::TempDir;
|
|
30
|
+
|
|
31
|
+
fn three_writes(path: &Path) -> Vec<u8> {
|
|
32
|
+
let db = FeltDb::open(path).unwrap();
|
|
33
|
+
for n in 0..3 {
|
|
34
|
+
db.insert(&format!("tasks:{n}"), json!({ "n": n })).unwrap();
|
|
35
|
+
}
|
|
36
|
+
drop(db);
|
|
37
|
+
std::fs::read(path).unwrap()
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// ---------------------------------------------------------------------------
|
|
41
|
+
// The five questions health has to answer
|
|
42
|
+
// ---------------------------------------------------------------------------
|
|
43
|
+
|
|
44
|
+
/// **A clean startup reports clean.**
|
|
45
|
+
#[test]
|
|
46
|
+
fn a_clean_open_reports_clean_storage() {
|
|
47
|
+
let directory = TempDir::new().unwrap();
|
|
48
|
+
let path = directory.path().join("clean.log");
|
|
49
|
+
three_writes(&path);
|
|
50
|
+
|
|
51
|
+
let db = FeltDb::open(&path).unwrap();
|
|
52
|
+
let health = db.health();
|
|
53
|
+
|
|
54
|
+
assert_eq!(health.storage, StorageHealth::Clean);
|
|
55
|
+
assert_eq!(health.recovery, LogRecovery::Clean);
|
|
56
|
+
assert_eq!(
|
|
57
|
+
health.durable_format,
|
|
58
|
+
DurableFormat::Versioned(DURABLE_FORMAT_VERSION)
|
|
59
|
+
);
|
|
60
|
+
assert!(health.is_nominal());
|
|
61
|
+
assert_eq!(health.storage.label(), "clean");
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/// **The regression that matters.** A database that discarded a torn tail must
|
|
65
|
+
/// not report the same health as one that replayed cleanly.
|
|
66
|
+
///
|
|
67
|
+
/// This is the operational consequence of the corruption work: the distinction
|
|
68
|
+
/// between a clean open and a recovered one was established there, and it is
|
|
69
|
+
/// worth nothing to an operator unless it reaches them.
|
|
70
|
+
#[test]
|
|
71
|
+
fn a_recovered_open_does_not_report_the_same_health_as_a_clean_one() {
|
|
72
|
+
let directory = TempDir::new().unwrap();
|
|
73
|
+
let clean_path = directory.path().join("clean.log");
|
|
74
|
+
let torn_path = directory.path().join("torn.log");
|
|
75
|
+
let full = three_writes(&clean_path);
|
|
76
|
+
|
|
77
|
+
// The same database, with its final append cut short.
|
|
78
|
+
std::fs::write(&torn_path, &full[..full.len() - 30]).unwrap();
|
|
79
|
+
|
|
80
|
+
let clean = FeltDb::open(&clean_path).unwrap().health();
|
|
81
|
+
let torn = FeltDb::open(&torn_path).unwrap().health();
|
|
82
|
+
|
|
83
|
+
assert_ne!(
|
|
84
|
+
clean, torn,
|
|
85
|
+
"a database that lost its last write must not look identical to one that did not"
|
|
86
|
+
);
|
|
87
|
+
assert!(clean.is_nominal());
|
|
88
|
+
assert!(!torn.is_nominal(), "recovered is not nominal");
|
|
89
|
+
assert_ne!(clean.storage.label(), torn.storage.label());
|
|
90
|
+
|
|
91
|
+
match torn.storage {
|
|
92
|
+
StorageHealth::RecoveredIncompleteWrite {
|
|
93
|
+
byte_offset,
|
|
94
|
+
discarded_bytes,
|
|
95
|
+
} => {
|
|
96
|
+
assert!(byte_offset > 0, "the operator is told where");
|
|
97
|
+
assert!(discarded_bytes > 0, "and how much");
|
|
98
|
+
}
|
|
99
|
+
other => panic!("expected a reported recovery, got {other:?}"),
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// And it says so in words an operator can act on.
|
|
103
|
+
let described = torn.storage.to_string();
|
|
104
|
+
assert!(described.contains("incomplete append"));
|
|
105
|
+
assert!(described.contains("last write did not land"));
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/// **A corrupt database does not become healthy.** It does not open at all.
|
|
109
|
+
///
|
|
110
|
+
/// The ambiguity being removed here is "healthy process, unhealthy database":
|
|
111
|
+
/// there is no state in which a server is serving a database whose durable log
|
|
112
|
+
/// failed to replay.
|
|
113
|
+
#[test]
|
|
114
|
+
fn a_corrupt_database_never_reaches_a_health_report() {
|
|
115
|
+
let directory = TempDir::new().unwrap();
|
|
116
|
+
let path = directory.path().join("corrupt.log");
|
|
117
|
+
three_writes(&path);
|
|
118
|
+
|
|
119
|
+
let mut file = std::fs::OpenOptions::new()
|
|
120
|
+
.append(true)
|
|
121
|
+
.open(&path)
|
|
122
|
+
.unwrap();
|
|
123
|
+
writeln!(file, "this is not json at all").unwrap();
|
|
124
|
+
drop(file);
|
|
125
|
+
|
|
126
|
+
match FeltDb::open(&path) {
|
|
127
|
+
Err(FlowError::CorruptLogLine(_)) => {}
|
|
128
|
+
Err(other) => panic!("wrong error: {other}"),
|
|
129
|
+
Ok(_) => panic!("a corrupt database opened and could have reported health"),
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/// **An incompatible database stays refused**, so a running server cannot be
|
|
134
|
+
/// advertising one.
|
|
135
|
+
#[test]
|
|
136
|
+
fn an_incompatible_database_never_reaches_a_health_report() {
|
|
137
|
+
let directory = TempDir::new().unwrap();
|
|
138
|
+
let path = directory.path().join("future.log");
|
|
139
|
+
std::fs::write(
|
|
140
|
+
&path,
|
|
141
|
+
format!(
|
|
142
|
+
"{}\n",
|
|
143
|
+
json!({
|
|
144
|
+
"record_type": "feltdb.format.v1",
|
|
145
|
+
"format_version": DURABLE_FORMAT_VERSION + 1
|
|
146
|
+
})
|
|
147
|
+
),
|
|
148
|
+
)
|
|
149
|
+
.unwrap();
|
|
150
|
+
|
|
151
|
+
assert!(!inspect_durable_format(&path).unwrap().is_compatible());
|
|
152
|
+
assert!(matches!(
|
|
153
|
+
FeltDb::open(&path),
|
|
154
|
+
Err(FlowError::IncompatibleFormat(_))
|
|
155
|
+
));
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/// Health survives the thing it describes: reopening a repaired log reports
|
|
159
|
+
/// clean again, because the condition is genuinely gone.
|
|
160
|
+
///
|
|
161
|
+
/// A status that stayed degraded forever would be as useless as one that never
|
|
162
|
+
/// was.
|
|
163
|
+
#[test]
|
|
164
|
+
fn health_reflects_the_current_open_not_the_database_s_past() {
|
|
165
|
+
let directory = TempDir::new().unwrap();
|
|
166
|
+
let path = directory.path().join("repaired.log");
|
|
167
|
+
let full = three_writes(&path);
|
|
168
|
+
std::fs::write(&path, &full[..full.len() - 30]).unwrap();
|
|
169
|
+
|
|
170
|
+
let recovered = FeltDb::open(&path).unwrap();
|
|
171
|
+
assert!(!recovered.health().is_nominal());
|
|
172
|
+
drop(recovered);
|
|
173
|
+
|
|
174
|
+
let reopened = FeltDb::open(&path).unwrap();
|
|
175
|
+
assert!(
|
|
176
|
+
reopened.health().is_nominal(),
|
|
177
|
+
"the incomplete append is gone, so the condition is too"
|
|
178
|
+
);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/// A database with no torn tail but written before format versioning is
|
|
182
|
+
/// reported as what it is, rather than as an error or as current.
|
|
183
|
+
#[test]
|
|
184
|
+
fn an_unversioned_database_reports_its_actual_format() {
|
|
185
|
+
let directory = TempDir::new().unwrap();
|
|
186
|
+
let path = directory.path().join("unversioned.log");
|
|
187
|
+
std::fs::write(
|
|
188
|
+
&path,
|
|
189
|
+
format!(
|
|
190
|
+
"{}\n",
|
|
191
|
+
json!({
|
|
192
|
+
"capability": "tasks", "key": "tasks:1",
|
|
193
|
+
"rust_type": "serde_json::value::Value", "value": {"n": 1},
|
|
194
|
+
"unix_ms": 1, "content_hash": null, "flow_ref": null,
|
|
195
|
+
"deleted": false, "operation": null
|
|
196
|
+
})
|
|
197
|
+
),
|
|
198
|
+
)
|
|
199
|
+
.unwrap();
|
|
200
|
+
|
|
201
|
+
let health = FeltDb::open(&path).unwrap().health();
|
|
202
|
+
assert_eq!(health.durable_format, DurableFormat::Unversioned);
|
|
203
|
+
assert!(health.is_nominal(), "unversioned is not unhealthy");
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// ---------------------------------------------------------------------------
|
|
207
|
+
// What health deliberately does not say
|
|
208
|
+
// ---------------------------------------------------------------------------
|
|
209
|
+
|
|
210
|
+
/// **Health does not manufacture a guarantee the database does not have.**
|
|
211
|
+
///
|
|
212
|
+
/// The defect being replaced was `storage: "durable"` — a string constant that
|
|
213
|
+
/// inspected nothing and asserted the one property still unproven. The
|
|
214
|
+
/// replacement is a small closed set of observed conditions, and every label in
|
|
215
|
+
/// it names what was seen rather than what would be reassuring.
|
|
216
|
+
///
|
|
217
|
+
/// In particular nothing here claims durability against power loss, which
|
|
218
|
+
/// remains unproven; nothing claims the database is replicated, backed up, or
|
|
219
|
+
/// within any capacity; and there is no "probably fine".
|
|
220
|
+
#[test]
|
|
221
|
+
fn health_reports_only_conditions_that_were_observed() {
|
|
222
|
+
let directory = TempDir::new().unwrap();
|
|
223
|
+
let clean = directory.path().join("a.log");
|
|
224
|
+
let torn = directory.path().join("b.log");
|
|
225
|
+
let full = three_writes(&clean);
|
|
226
|
+
std::fs::write(&torn, &full[..full.len() - 30]).unwrap();
|
|
227
|
+
|
|
228
|
+
let labels: Vec<&str> = [&clean, &torn]
|
|
229
|
+
.iter()
|
|
230
|
+
.map(|path| FeltDb::open(path).unwrap().health().storage.label())
|
|
231
|
+
.collect();
|
|
232
|
+
|
|
233
|
+
// Exactly two conditions, both naming an observation.
|
|
234
|
+
assert_eq!(labels, vec!["clean", "recovered-incomplete-write"]);
|
|
235
|
+
for label in &labels {
|
|
236
|
+
assert!(
|
|
237
|
+
!label.contains("durable") && !label.contains("healthy"),
|
|
238
|
+
"'{label}' would assert more than was observed"
|
|
239
|
+
);
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/// The health value is derived from the recovery the open recorded, not from
|
|
244
|
+
/// anything a caller can set.
|
|
245
|
+
#[test]
|
|
246
|
+
fn health_is_derived_from_the_open_rather_than_configured() {
|
|
247
|
+
let directory = TempDir::new().unwrap();
|
|
248
|
+
let path = directory.path().join("derived.log");
|
|
249
|
+
let full = three_writes(&path);
|
|
250
|
+
std::fs::write(&path, &full[..full.len() - 30]).unwrap();
|
|
251
|
+
|
|
252
|
+
let db = FeltDb::open(&path).unwrap();
|
|
253
|
+
let health: DatabaseHealth = db.health();
|
|
254
|
+
|
|
255
|
+
// The two agree because one is computed from the other.
|
|
256
|
+
match (&health.recovery, &health.storage) {
|
|
257
|
+
(
|
|
258
|
+
LogRecovery::RecoveredTornTail {
|
|
259
|
+
byte_offset: recovered_at,
|
|
260
|
+
discarded_bytes: recovered_bytes,
|
|
261
|
+
},
|
|
262
|
+
StorageHealth::RecoveredIncompleteWrite {
|
|
263
|
+
byte_offset,
|
|
264
|
+
discarded_bytes,
|
|
265
|
+
},
|
|
266
|
+
) => {
|
|
267
|
+
assert_eq!(recovered_at, byte_offset);
|
|
268
|
+
assert_eq!(recovered_bytes, discarded_bytes);
|
|
269
|
+
}
|
|
270
|
+
other => panic!("recovery and storage disagree: {other:?}"),
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
// Writing more does not change what the open found.
|
|
274
|
+
db.insert("tasks:9", json!({"n": 9})).unwrap();
|
|
275
|
+
assert_eq!(db.health(), health);
|
|
276
|
+
let value: Value = db.get("tasks:9").unwrap().unwrap();
|
|
277
|
+
assert_eq!(value["n"], json!(9), "and the database is fully usable");
|
|
278
|
+
}
|