@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
|
@@ -30,6 +30,42 @@ pub struct Operation {
|
|
|
30
30
|
/// Vector clock for causal tracking (optional for backward compatibility)
|
|
31
31
|
#[serde(default)]
|
|
32
32
|
pub vector_clock: Option<std::collections::HashMap<String, u64>>,
|
|
33
|
+
/// The historical revision this operation produced on the authority that
|
|
34
|
+
/// created it.
|
|
35
|
+
///
|
|
36
|
+
/// Carried so a peer can **reconstruct the same revision** rather than mint
|
|
37
|
+
/// a local substitute for the resulting state. Without it a replica can
|
|
38
|
+
/// only reproduce what is current, and two authorities end up agreeing
|
|
39
|
+
/// about what *is* while disagreeing about what *happened*.
|
|
40
|
+
///
|
|
41
|
+
/// Additive and outside `content_hash`, exactly like `vector_clock`: an
|
|
42
|
+
/// operation from a build that does not send it still verifies, and simply
|
|
43
|
+
/// carries no history.
|
|
44
|
+
#[serde(default)]
|
|
45
|
+
pub revision: Option<RevisionProvenance>,
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/// The revision an operation produced, as its originating authority recorded it.
|
|
49
|
+
///
|
|
50
|
+
/// Everything a peer needs to rebuild the identical `StateRevision`. The content
|
|
51
|
+
/// is not repeated — it is the operation's own value — so this adds a bounded
|
|
52
|
+
/// amount to an operation regardless of record size.
|
|
53
|
+
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
|
54
|
+
pub struct RevisionProvenance {
|
|
55
|
+
/// The resource whose history this revision belongs to.
|
|
56
|
+
pub resource: String,
|
|
57
|
+
/// The revision identity the originating authority committed.
|
|
58
|
+
pub id: String,
|
|
59
|
+
/// Its parent, as the origin recorded it — **not** whatever is current on
|
|
60
|
+
/// the peer. This is what preserves forks.
|
|
61
|
+
pub parent_id: Option<String>,
|
|
62
|
+
/// Its position in the resource's history, preserved rather than reassigned.
|
|
63
|
+
pub sequence: u64,
|
|
64
|
+
/// The content identity, so a peer can check that the value it received is
|
|
65
|
+
/// the value the revision was computed from.
|
|
66
|
+
pub content_id: String,
|
|
67
|
+
/// Who committed the historical fact. Never rewritten to the peer.
|
|
68
|
+
pub authority: String,
|
|
33
69
|
}
|
|
34
70
|
|
|
35
71
|
/// Types of mutations that become durable operations
|
|
@@ -66,6 +102,7 @@ impl Operation {
|
|
|
66
102
|
capability,
|
|
67
103
|
content_hash: String::new(), // Will be computed below
|
|
68
104
|
vector_clock: None,
|
|
105
|
+
revision: None,
|
|
69
106
|
};
|
|
70
107
|
let mut result = op;
|
|
71
108
|
result.content_hash = result.compute_content_hash();
|
|
@@ -94,6 +131,7 @@ impl Operation {
|
|
|
94
131
|
capability,
|
|
95
132
|
content_hash: String::new(), // Will be computed below
|
|
96
133
|
vector_clock: None,
|
|
134
|
+
revision: None,
|
|
97
135
|
};
|
|
98
136
|
let mut result = op;
|
|
99
137
|
result.content_hash = result.compute_content_hash();
|
|
@@ -121,6 +159,7 @@ impl Operation {
|
|
|
121
159
|
capability,
|
|
122
160
|
content_hash: String::new(), // Will be computed below
|
|
123
161
|
vector_clock: None,
|
|
162
|
+
revision: None,
|
|
124
163
|
};
|
|
125
164
|
let mut result = op;
|
|
126
165
|
result.content_hash = result.compute_content_hash();
|
|
@@ -1545,6 +1545,58 @@ impl StateStore {
|
|
|
1545
1545
|
}
|
|
1546
1546
|
}
|
|
1547
1547
|
|
|
1548
|
+
/// A digest of this store's **history**: the revision graph and retention
|
|
1549
|
+
/// state, and nothing else.
|
|
1550
|
+
///
|
|
1551
|
+
/// Deliberately independent of a digest over current records. Two databases
|
|
1552
|
+
/// can hold identical current state and completely different histories —
|
|
1553
|
+
/// one that recorded every step and one that only ever saw the last — and
|
|
1554
|
+
/// any equivalence check that cannot tell those apart is not checking
|
|
1555
|
+
/// history at all.
|
|
1556
|
+
///
|
|
1557
|
+
/// Covers, for every revision: its resource, identity, parent, sequence,
|
|
1558
|
+
/// content identity and the authority that committed it; and for every
|
|
1559
|
+
/// resource: its retention policy and horizon. It excludes wall-clock
|
|
1560
|
+
/// timestamps, which two authorities holding the same history legitimately
|
|
1561
|
+
/// disagree about.
|
|
1562
|
+
pub fn history_digest(&self) -> String {
|
|
1563
|
+
let mut entries: Vec<String> = self
|
|
1564
|
+
.all_revisions()
|
|
1565
|
+
.into_iter()
|
|
1566
|
+
.map(|revision| {
|
|
1567
|
+
format!(
|
|
1568
|
+
"{}|{}|{}|{}|{}|{}",
|
|
1569
|
+
revision.resource,
|
|
1570
|
+
revision.id.as_hex(),
|
|
1571
|
+
revision
|
|
1572
|
+
.parent_id
|
|
1573
|
+
.as_ref()
|
|
1574
|
+
.map(StateId::as_hex)
|
|
1575
|
+
.unwrap_or("-"),
|
|
1576
|
+
revision.sequence,
|
|
1577
|
+
revision.content_id.as_hex(),
|
|
1578
|
+
revision.authority,
|
|
1579
|
+
)
|
|
1580
|
+
})
|
|
1581
|
+
.collect();
|
|
1582
|
+
for resource in self.resources() {
|
|
1583
|
+
let state = self.retention_state(&resource);
|
|
1584
|
+
entries.push(format!(
|
|
1585
|
+
"retention|{}|{:?}|{}",
|
|
1586
|
+
resource, state.policy.keep_last, state.horizon
|
|
1587
|
+
));
|
|
1588
|
+
}
|
|
1589
|
+
entries.sort();
|
|
1590
|
+
|
|
1591
|
+
let mut hasher = Sha256::new();
|
|
1592
|
+
hasher.update(b"feltdb.history.v1");
|
|
1593
|
+
for entry in entries {
|
|
1594
|
+
hasher.update(entry.as_bytes());
|
|
1595
|
+
hasher.update([0u8]);
|
|
1596
|
+
}
|
|
1597
|
+
format!("{:x}", hasher.finalize())
|
|
1598
|
+
}
|
|
1599
|
+
|
|
1548
1600
|
// -- retention ----------------------------------------------------------
|
|
1549
1601
|
|
|
1550
1602
|
/// The retention policy in force for a resource.
|
|
@@ -157,13 +157,19 @@ impl Storage for FileStorage {
|
|
|
157
157
|
let file = OpenOptions::new().read(true).open(&self.path)?;
|
|
158
158
|
let reader = BufReader::new(file);
|
|
159
159
|
|
|
160
|
-
for line in reader.lines() {
|
|
160
|
+
for (index, line) in reader.lines().enumerate() {
|
|
161
161
|
let line = line?;
|
|
162
162
|
if line.trim().is_empty() {
|
|
163
163
|
continue;
|
|
164
164
|
}
|
|
165
|
-
let row: StoredRow = serde_json::from_str(&line)
|
|
166
|
-
|
|
165
|
+
let row: StoredRow = serde_json::from_str(&line).map_err(|_| {
|
|
166
|
+
FlowError::CorruptLogLine(Box::new(crate::LogCorruption {
|
|
167
|
+
line_number: index + 1,
|
|
168
|
+
byte_offset: 0,
|
|
169
|
+
reason: "not a durable record this build understands".to_string(),
|
|
170
|
+
excerpt: line.chars().take(120).collect(),
|
|
171
|
+
}))
|
|
172
|
+
})?;
|
|
167
173
|
operations.push(row);
|
|
168
174
|
}
|
|
169
175
|
}
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
//! Scoped reads must not materialize a collection.
|
|
2
|
+
//!
|
|
3
|
+
//! The managed incident's amplifier: `list_collection` takes the one lock that
|
|
4
|
+
//! serializes every read and write, and **clones every row in the collection**.
|
|
5
|
+
//! A keyed lookup implemented that way, or a search that filters after the
|
|
6
|
+
//! clone, extends the window in which nothing else in the database can proceed.
|
|
7
|
+
//!
|
|
8
|
+
//! The code's own comment already recorded this pathology being seen before:
|
|
9
|
+
//!
|
|
10
|
+
//! > *a query holding the lock while it cloned a collection inflated the cost
|
|
11
|
+
//! > of unrelated writes*
|
|
12
|
+
//!
|
|
13
|
+
//! The bounded APIs already existed. These tests pin the properties the server
|
|
14
|
+
//! now depends on, so a future handler cannot quietly go back to the scanning
|
|
15
|
+
//! form.
|
|
16
|
+
|
|
17
|
+
use feltdb::FeltDb;
|
|
18
|
+
use serde_json::json;
|
|
19
|
+
use std::sync::atomic::{AtomicUsize, Ordering};
|
|
20
|
+
use tempfile::TempDir;
|
|
21
|
+
|
|
22
|
+
fn populated(rows: usize) -> (TempDir, FeltDb) {
|
|
23
|
+
let directory = TempDir::new().unwrap();
|
|
24
|
+
let db = FeltDb::open(directory.path().join("reads.log")).unwrap();
|
|
25
|
+
for index in 0..rows {
|
|
26
|
+
db.insert(
|
|
27
|
+
&format!("docs:{index:05}"),
|
|
28
|
+
json!({ "n": index, "body": "x".repeat(200) }),
|
|
29
|
+
)
|
|
30
|
+
.unwrap();
|
|
31
|
+
}
|
|
32
|
+
(directory, db)
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/// **A keyed lookup reads one record.**
|
|
36
|
+
///
|
|
37
|
+
/// `get_provenance` used to call `list_collection` and `find` the key, cloning
|
|
38
|
+
/// the entire collection to return one row.
|
|
39
|
+
#[test]
|
|
40
|
+
fn a_keyed_lookup_does_not_materialize_the_collection() {
|
|
41
|
+
let (_directory, db) = populated(500);
|
|
42
|
+
|
|
43
|
+
let row = db
|
|
44
|
+
.get_collection_record("docs", "docs:00042")
|
|
45
|
+
.unwrap()
|
|
46
|
+
.expect("the record is found");
|
|
47
|
+
assert_eq!(row.value["n"], json!(42));
|
|
48
|
+
|
|
49
|
+
assert!(db
|
|
50
|
+
.get_collection_record("docs", "docs:99999")
|
|
51
|
+
.unwrap()
|
|
52
|
+
.is_none());
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/// **A bounded search stops at its limit.**
|
|
56
|
+
///
|
|
57
|
+
/// The predicate is the observable proof: it runs once per row *visited*, so a
|
|
58
|
+
/// limited search over a large collection visits far fewer rows than the
|
|
59
|
+
/// collection holds. The scanning form evaluated every row, every time.
|
|
60
|
+
#[test]
|
|
61
|
+
fn a_bounded_search_stops_at_the_limit_instead_of_scanning() {
|
|
62
|
+
let (_directory, db) = populated(500);
|
|
63
|
+
let visited = AtomicUsize::new(0);
|
|
64
|
+
|
|
65
|
+
// Every row matches, so the only thing that can stop the traversal is the
|
|
66
|
+
// limit.
|
|
67
|
+
let matched = db
|
|
68
|
+
.query_collection("docs", Some(10), |row| {
|
|
69
|
+
visited.fetch_add(1, Ordering::Relaxed);
|
|
70
|
+
row.value["body"].is_string()
|
|
71
|
+
})
|
|
72
|
+
.unwrap();
|
|
73
|
+
|
|
74
|
+
assert_eq!(matched.len(), 10, "the limit is honoured");
|
|
75
|
+
assert_eq!(
|
|
76
|
+
visited.load(Ordering::Relaxed),
|
|
77
|
+
10,
|
|
78
|
+
"and the traversal stopped there rather than visiting all 500"
|
|
79
|
+
);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/// Only matching rows are cloned; rejected rows are borrowed and dropped.
|
|
83
|
+
#[test]
|
|
84
|
+
fn a_search_clones_only_what_it_returns() {
|
|
85
|
+
let (_directory, db) = populated(300);
|
|
86
|
+
let visited = AtomicUsize::new(0);
|
|
87
|
+
|
|
88
|
+
let matched = db
|
|
89
|
+
.query_collection("docs", None, |row| {
|
|
90
|
+
visited.fetch_add(1, Ordering::Relaxed);
|
|
91
|
+
row.value["n"].as_u64() == Some(7)
|
|
92
|
+
})
|
|
93
|
+
.unwrap();
|
|
94
|
+
|
|
95
|
+
assert_eq!(matched.len(), 1);
|
|
96
|
+
assert_eq!(
|
|
97
|
+
visited.load(Ordering::Relaxed),
|
|
98
|
+
300,
|
|
99
|
+
"an unlimited search still visits every row — the saving is in cloning"
|
|
100
|
+
);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/// Paging traverses in key order and is bounded, so a large collection is
|
|
104
|
+
/// readable without a single unbounded materialization.
|
|
105
|
+
#[test]
|
|
106
|
+
fn paging_is_bounded_and_ordered() {
|
|
107
|
+
let (_directory, db) = populated(120);
|
|
108
|
+
|
|
109
|
+
let first = db.list_collection_page("docs", None, 25).unwrap();
|
|
110
|
+
assert_eq!(first.len(), 25);
|
|
111
|
+
assert_eq!(first[0].key, "docs:00000");
|
|
112
|
+
|
|
113
|
+
let after = first.last().unwrap().key.clone();
|
|
114
|
+
let second = db.list_collection_page("docs", Some(&after), 25).unwrap();
|
|
115
|
+
assert_eq!(second.len(), 25);
|
|
116
|
+
assert!(second[0].key > after, "paging advances");
|
|
117
|
+
|
|
118
|
+
// The whole collection is reachable in bounded steps.
|
|
119
|
+
let mut seen = 0;
|
|
120
|
+
let mut cursor: Option<String> = None;
|
|
121
|
+
loop {
|
|
122
|
+
let page = db
|
|
123
|
+
.list_collection_page("docs", cursor.as_deref(), 25)
|
|
124
|
+
.unwrap();
|
|
125
|
+
if page.is_empty() {
|
|
126
|
+
break;
|
|
127
|
+
}
|
|
128
|
+
seen += page.len();
|
|
129
|
+
cursor = Some(page.last().unwrap().key.clone());
|
|
130
|
+
}
|
|
131
|
+
assert_eq!(seen, 120);
|
|
132
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
//! Shared rules for reading the certification and contract documents.
|
|
2
|
+
//!
|
|
3
|
+
//! Both `production_certification.rs` and `production_contract.rs` check
|
|
4
|
+
//! published prose against an executable table, and both need the same answer
|
|
5
|
+
//! to one awkward question: when does a document *assert* a guarantee, as
|
|
6
|
+
//! opposed to naming one it is withholding? Defining that twice would be the
|
|
7
|
+
//! same mistake the certification exists to prevent — two files quietly
|
|
8
|
+
//! disagreeing — so it is defined once, here.
|
|
9
|
+
|
|
10
|
+
#![allow(dead_code)]
|
|
11
|
+
|
|
12
|
+
/// Split a markdown table row into its trimmed cells.
|
|
13
|
+
///
|
|
14
|
+
/// Emphasis and code delimiters are stripped, so `**Proven**` and `` `E6` ``
|
|
15
|
+
/// compare equal to the plain text they render.
|
|
16
|
+
pub fn table_cells(line: &str) -> Option<Vec<String>> {
|
|
17
|
+
let line = line.trim();
|
|
18
|
+
if !line.starts_with('|') {
|
|
19
|
+
return None;
|
|
20
|
+
}
|
|
21
|
+
Some(
|
|
22
|
+
line.trim_matches('|')
|
|
23
|
+
.split('|')
|
|
24
|
+
.map(|cell| {
|
|
25
|
+
cell.trim()
|
|
26
|
+
.trim_matches('*')
|
|
27
|
+
.trim()
|
|
28
|
+
.trim_matches('`')
|
|
29
|
+
.to_string()
|
|
30
|
+
})
|
|
31
|
+
.collect(),
|
|
32
|
+
)
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/// Does `text` *assert* `phrase`, as opposed to naming it?
|
|
36
|
+
///
|
|
37
|
+
/// The distinction matters because the honest way to record a withheld
|
|
38
|
+
/// guarantee is to quote it: `workload-envelope.md` says a test asserts no
|
|
39
|
+
/// durability mode's wording contains "power-loss safe". A tripwire that
|
|
40
|
+
/// flagged any occurrence would punish the document for being precise, and the
|
|
41
|
+
/// cheapest way to satisfy it would be to stop naming the phrase at all — which
|
|
42
|
+
/// is the opposite of what the tripwire is for.
|
|
43
|
+
///
|
|
44
|
+
/// So an occurrence inside a quotation (`"..."`) or a code span (`` `...` ``)
|
|
45
|
+
/// is a mention. Every other occurrence is an assertion. Quoting is judged per
|
|
46
|
+
/// line, because a stray delimiter must not silence the rest of the file.
|
|
47
|
+
pub fn asserts_phrase(text: &str, phrase: &str) -> bool {
|
|
48
|
+
text.lines().any(|line| {
|
|
49
|
+
if !line.contains(phrase) {
|
|
50
|
+
return false;
|
|
51
|
+
}
|
|
52
|
+
let mut unquoted = String::new();
|
|
53
|
+
let mut segment = String::new();
|
|
54
|
+
let mut quoted = false;
|
|
55
|
+
for character in line.chars() {
|
|
56
|
+
if character == '"' || character == '`' {
|
|
57
|
+
if !quoted {
|
|
58
|
+
// A quotation opens here, so the text before it is asserted.
|
|
59
|
+
unquoted.push_str(&segment);
|
|
60
|
+
unquoted.push('\u{0}');
|
|
61
|
+
}
|
|
62
|
+
segment.clear();
|
|
63
|
+
quoted = !quoted;
|
|
64
|
+
} else {
|
|
65
|
+
segment.push(character);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
// A delimiter that is never closed quotes nothing.
|
|
69
|
+
unquoted.push_str(&segment);
|
|
70
|
+
unquoted.contains(phrase)
|
|
71
|
+
})
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/// The document's prose, with rows that record a withheld verdict removed.
|
|
75
|
+
///
|
|
76
|
+
/// A table row that names something next to a label withholding it — `Unproven`,
|
|
77
|
+
/// `Not certified` — is recording a gap, not asserting a guarantee. Without
|
|
78
|
+
/// this, a matrix would fail its own tripwire the moment it published a gap
|
|
79
|
+
/// precisely, and the cheapest way to pass would be to stop naming the gap.
|
|
80
|
+
///
|
|
81
|
+
/// Rows whose verdict cell *grants* the thing are deliberately not exempt: if
|
|
82
|
+
/// one ever carried a forbidden phrase, the table and the phrase list would be
|
|
83
|
+
/// in genuine contradiction and the tripwire should say so.
|
|
84
|
+
pub fn prose_excluding_withheld_rows(document: &str, withheld: &[&str]) -> String {
|
|
85
|
+
document
|
|
86
|
+
.lines()
|
|
87
|
+
.filter(|line| match table_cells(line) {
|
|
88
|
+
None => true,
|
|
89
|
+
Some(cells) => !cells
|
|
90
|
+
.iter()
|
|
91
|
+
.any(|cell| withheld.iter().any(|label| cell == label)),
|
|
92
|
+
})
|
|
93
|
+
.collect::<Vec<_>>()
|
|
94
|
+
.join("\n")
|
|
95
|
+
.to_lowercase()
|
|
96
|
+
}
|
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
//! Compaction must not stop the world on a timer.
|
|
2
|
+
//!
|
|
3
|
+
//! An incident on the managed deployment showed `/health` returning `200` while
|
|
4
|
+
//! every application-scoped operation timed out or returned `502`, across
|
|
5
|
+
//! several minutes, on four unrelated collections at once.
|
|
6
|
+
//!
|
|
7
|
+
//! Four collections failing together is the signature of a **global stall**,
|
|
8
|
+
//! not of four collection-specific faults. The mechanism was here:
|
|
9
|
+
//!
|
|
10
|
+
//! > `compact_operation_log` takes the one lock that serializes every read and
|
|
11
|
+
//! > every write, then serializes **every row in the database** to a temp file
|
|
12
|
+
//! > and `fsync`s it — and a background task called it **every two seconds**.
|
|
13
|
+
//!
|
|
14
|
+
//! Pruning acknowledged operations from memory is cheap. Rewriting the log is
|
|
15
|
+
//! not. This file holds them apart: pruning still happens whenever it can, and
|
|
16
|
+
//! the rewrite waits until enough has accumulated to be worth the stall.
|
|
17
|
+
//!
|
|
18
|
+
//! See `docs/architecture/compaction-stall.md`.
|
|
19
|
+
|
|
20
|
+
use feltdb::state_model::StateStore;
|
|
21
|
+
use feltdb::{CompactionOutcome, CompactionPolicy, FeltDb};
|
|
22
|
+
use serde_json::json;
|
|
23
|
+
use std::sync::Arc;
|
|
24
|
+
use std::time::Instant;
|
|
25
|
+
use tempfile::TempDir;
|
|
26
|
+
|
|
27
|
+
/// Acknowledge everything a database has produced, so a compaction has work.
|
|
28
|
+
fn acknowledge_all(db: &Arc<FeltDb>) {
|
|
29
|
+
let versions = db.operation_versions().unwrap();
|
|
30
|
+
db.acknowledge_peer_versions("peer-1".to_string(), versions)
|
|
31
|
+
.unwrap();
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
fn write_n(db: &Arc<FeltDb>, n: usize) {
|
|
35
|
+
for index in 0..n {
|
|
36
|
+
db.insert(&format!("tasks:{index}"), json!({ "n": index }))
|
|
37
|
+
.unwrap();
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// ---------------------------------------------------------------------------
|
|
42
|
+
// The fix
|
|
43
|
+
// ---------------------------------------------------------------------------
|
|
44
|
+
|
|
45
|
+
/// **A timer-driven compaction prunes without rewriting the log.**
|
|
46
|
+
///
|
|
47
|
+
/// This is the change that stops the stall. The in-memory operation log still
|
|
48
|
+
/// shrinks on every tick; the durable rewrite — the expensive half — waits.
|
|
49
|
+
#[test]
|
|
50
|
+
fn a_policy_driven_compaction_defers_the_rewrite() {
|
|
51
|
+
let directory = TempDir::new().unwrap();
|
|
52
|
+
let path = directory.path().join("defer.log");
|
|
53
|
+
let db = Arc::new(FeltDb::open(&path).unwrap());
|
|
54
|
+
db.set_compaction_policy(CompactionPolicy {
|
|
55
|
+
rewrite_after_pruned: 1000,
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
write_n(&db, 20);
|
|
59
|
+
acknowledge_all(&db);
|
|
60
|
+
let before = std::fs::metadata(&path).unwrap().len();
|
|
61
|
+
|
|
62
|
+
let outcome = db
|
|
63
|
+
.maybe_compact_operation_log(&["peer-1".to_string()])
|
|
64
|
+
.unwrap();
|
|
65
|
+
|
|
66
|
+
match outcome {
|
|
67
|
+
CompactionOutcome::Deferred {
|
|
68
|
+
pruned,
|
|
69
|
+
pending_since_rewrite,
|
|
70
|
+
} => {
|
|
71
|
+
assert!(pruned > 0, "the cheap half still happened");
|
|
72
|
+
assert_eq!(pending_since_rewrite, pruned);
|
|
73
|
+
}
|
|
74
|
+
other => panic!("expected the rewrite to be deferred, got {other:?}"),
|
|
75
|
+
}
|
|
76
|
+
assert_eq!(
|
|
77
|
+
std::fs::metadata(&path).unwrap().len(),
|
|
78
|
+
before,
|
|
79
|
+
"the durable log was not rewritten"
|
|
80
|
+
);
|
|
81
|
+
|
|
82
|
+
// And the database is entirely usable.
|
|
83
|
+
let value: serde_json::Value = db.get("tasks:0").unwrap().unwrap();
|
|
84
|
+
assert_eq!(value["n"], json!(0));
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/// **The rewrite happens once the accumulated gain earns it**, and the log
|
|
88
|
+
/// actually shrinks when it does.
|
|
89
|
+
#[test]
|
|
90
|
+
fn the_rewrite_happens_once_the_threshold_is_reached() {
|
|
91
|
+
let directory = TempDir::new().unwrap();
|
|
92
|
+
let path = directory.path().join("threshold.log");
|
|
93
|
+
let db = Arc::new(FeltDb::open(&path).unwrap());
|
|
94
|
+
db.set_compaction_policy(CompactionPolicy {
|
|
95
|
+
rewrite_after_pruned: 25,
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
let mut rewrites = 0;
|
|
99
|
+
let mut defers = 0;
|
|
100
|
+
for round in 0..6 {
|
|
101
|
+
for index in 0..10 {
|
|
102
|
+
db.update(
|
|
103
|
+
&format!("tasks:{}", round * 10 + index),
|
|
104
|
+
json!({ "n": index }),
|
|
105
|
+
)
|
|
106
|
+
.unwrap();
|
|
107
|
+
}
|
|
108
|
+
acknowledge_all(&db);
|
|
109
|
+
match db
|
|
110
|
+
.maybe_compact_operation_log(&["peer-1".to_string()])
|
|
111
|
+
.unwrap()
|
|
112
|
+
{
|
|
113
|
+
CompactionOutcome::Rewritten { .. } => rewrites += 1,
|
|
114
|
+
CompactionOutcome::Deferred { .. } => defers += 1,
|
|
115
|
+
CompactionOutcome::Idle => {}
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
assert!(defers > 0, "most ticks defer");
|
|
120
|
+
assert!(rewrites > 0, "and the rewrite still happens");
|
|
121
|
+
assert!(
|
|
122
|
+
defers > rewrites,
|
|
123
|
+
"the point is that deferring is the common case: {defers} deferred, {rewrites} rewritten"
|
|
124
|
+
);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/// **An explicit compaction still rewrites immediately.**
|
|
128
|
+
///
|
|
129
|
+
/// The policy governs the timer, not a caller who is asking for a rewrite now.
|
|
130
|
+
/// Every existing caller of `compact_operation_log` keeps its behaviour.
|
|
131
|
+
#[test]
|
|
132
|
+
fn an_explicit_compaction_still_rewrites_now() {
|
|
133
|
+
let directory = TempDir::new().unwrap();
|
|
134
|
+
let path = directory.path().join("explicit.log");
|
|
135
|
+
let db = Arc::new(FeltDb::open(&path).unwrap());
|
|
136
|
+
db.set_compaction_policy(CompactionPolicy {
|
|
137
|
+
rewrite_after_pruned: usize::MAX,
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
write_n(&db, 20);
|
|
141
|
+
for index in 0..20 {
|
|
142
|
+
db.update(&format!("tasks:{index}"), json!({"n": "updated"}))
|
|
143
|
+
.unwrap();
|
|
144
|
+
}
|
|
145
|
+
acknowledge_all(&db);
|
|
146
|
+
let before = std::fs::metadata(&path).unwrap().len();
|
|
147
|
+
|
|
148
|
+
let pruned = db.compact_operation_log(&["peer-1".to_string()]).unwrap();
|
|
149
|
+
assert!(pruned > 0);
|
|
150
|
+
assert!(
|
|
151
|
+
std::fs::metadata(&path).unwrap().len() < before,
|
|
152
|
+
"an explicit compaction rewrote and shrank the log"
|
|
153
|
+
);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/// **The deferred rewrite costs nothing in correctness.**
|
|
157
|
+
///
|
|
158
|
+
/// Pruning without rewriting leaves the durable log longer than it needs to be,
|
|
159
|
+
/// and nothing else: the database reopens to the same state and the same
|
|
160
|
+
/// history.
|
|
161
|
+
#[test]
|
|
162
|
+
fn deferring_the_rewrite_preserves_state_and_history() {
|
|
163
|
+
let directory = TempDir::new().unwrap();
|
|
164
|
+
let path = directory.path().join("preserve.log");
|
|
165
|
+
let db = Arc::new(FeltDb::open(&path).unwrap());
|
|
166
|
+
db.set_compaction_policy(CompactionPolicy {
|
|
167
|
+
rewrite_after_pruned: usize::MAX,
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
db.insert("tasks:1", json!({"n": 0})).unwrap();
|
|
171
|
+
for n in 1..10 {
|
|
172
|
+
db.update("tasks:1", json!({ "n": n })).unwrap();
|
|
173
|
+
}
|
|
174
|
+
acknowledge_all(&db);
|
|
175
|
+
assert!(matches!(
|
|
176
|
+
db.maybe_compact_operation_log(&["peer-1".to_string()])
|
|
177
|
+
.unwrap(),
|
|
178
|
+
CompactionOutcome::Deferred { .. }
|
|
179
|
+
));
|
|
180
|
+
|
|
181
|
+
let store = StateStore::with_feltdb(db.clone()).unwrap();
|
|
182
|
+
let history_before = store.history_digest();
|
|
183
|
+
let state_before = db.state_digest().unwrap();
|
|
184
|
+
drop(store);
|
|
185
|
+
drop(db);
|
|
186
|
+
|
|
187
|
+
let reopened = Arc::new(FeltDb::open(&path).unwrap());
|
|
188
|
+
let store = StateStore::with_feltdb(reopened.clone()).unwrap();
|
|
189
|
+
assert_eq!(reopened.state_digest().unwrap(), state_before);
|
|
190
|
+
assert_eq!(store.history_digest(), history_before);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/// Nothing to prune is not a rewrite either — the pre-existing early return.
|
|
194
|
+
#[test]
|
|
195
|
+
fn an_idle_compaction_does_no_work() {
|
|
196
|
+
let directory = TempDir::new().unwrap();
|
|
197
|
+
let path = directory.path().join("idle.log");
|
|
198
|
+
let db = Arc::new(FeltDb::open(&path).unwrap());
|
|
199
|
+
write_n(&db, 5);
|
|
200
|
+
let before = std::fs::read(&path).unwrap();
|
|
201
|
+
|
|
202
|
+
// No peer has acknowledged anything.
|
|
203
|
+
let outcome = db.maybe_compact_operation_log(&[]).unwrap();
|
|
204
|
+
assert_eq!(outcome, CompactionOutcome::Idle);
|
|
205
|
+
assert_eq!(outcome.pruned(), 0);
|
|
206
|
+
assert!(!outcome.rewrote_log());
|
|
207
|
+
assert_eq!(std::fs::read(&path).unwrap(), before);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// ---------------------------------------------------------------------------
|
|
211
|
+
// The stall itself
|
|
212
|
+
// ---------------------------------------------------------------------------
|
|
213
|
+
|
|
214
|
+
/// **The rewrite is what stalls, and it grows with the database.**
|
|
215
|
+
///
|
|
216
|
+
/// Measured as an ordering rather than a number: a rewrite over a larger
|
|
217
|
+
/// database holds the lock longer. That is why running it on a two-second timer
|
|
218
|
+
/// degraded as the deployment grew, and why the default policy now waits.
|
|
219
|
+
#[test]
|
|
220
|
+
fn the_rewrite_cost_grows_with_the_database() {
|
|
221
|
+
let cost = |rows: usize| {
|
|
222
|
+
let directory = TempDir::new().unwrap();
|
|
223
|
+
let path = directory.path().join("cost.log");
|
|
224
|
+
let db = Arc::new(FeltDb::open(&path).unwrap());
|
|
225
|
+
write_n(&db, rows);
|
|
226
|
+
for index in 0..rows {
|
|
227
|
+
db.update(&format!("tasks:{index}"), json!({"n": "updated"}))
|
|
228
|
+
.unwrap();
|
|
229
|
+
}
|
|
230
|
+
acknowledge_all(&db);
|
|
231
|
+
let started = Instant::now();
|
|
232
|
+
db.compact_operation_log(&["peer-1".to_string()]).unwrap();
|
|
233
|
+
started.elapsed()
|
|
234
|
+
};
|
|
235
|
+
|
|
236
|
+
let small = cost(20);
|
|
237
|
+
let large = cost(400);
|
|
238
|
+
assert!(
|
|
239
|
+
large > small,
|
|
240
|
+
"a rewrite over more rows holds the lock longer: {large:?} vs {small:?}"
|
|
241
|
+
);
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/// **The default policy does not rewrite on every tick.**
|
|
245
|
+
///
|
|
246
|
+
/// The regression this whole file exists to prevent. With the shipped default,
|
|
247
|
+
/// a handful of acknowledged operations must not trigger a full log rewrite.
|
|
248
|
+
#[test]
|
|
249
|
+
fn the_default_policy_does_not_rewrite_on_a_handful_of_operations() {
|
|
250
|
+
assert_eq!(CompactionPolicy::default().rewrite_after_pruned, 1000);
|
|
251
|
+
|
|
252
|
+
let directory = TempDir::new().unwrap();
|
|
253
|
+
let path = directory.path().join("default.log");
|
|
254
|
+
let db = Arc::new(FeltDb::open(&path).unwrap());
|
|
255
|
+
assert_eq!(db.compaction_policy(), CompactionPolicy::default());
|
|
256
|
+
|
|
257
|
+
write_n(&db, 30);
|
|
258
|
+
acknowledge_all(&db);
|
|
259
|
+
let before = std::fs::metadata(&path).unwrap().len();
|
|
260
|
+
|
|
261
|
+
// Ten ticks of the timer.
|
|
262
|
+
for _ in 0..10 {
|
|
263
|
+
db.maybe_compact_operation_log(&["peer-1".to_string()])
|
|
264
|
+
.unwrap();
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
assert_eq!(
|
|
268
|
+
std::fs::metadata(&path).unwrap().len(),
|
|
269
|
+
before,
|
|
270
|
+
"ten timer ticks over a small database rewrote the log zero times"
|
|
271
|
+
);
|
|
272
|
+
}
|