@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
|
@@ -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
|
+
}
|