@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.
Files changed (29) hide show
  1. package/dist/create/package-versions.js +1 -1
  2. package/dist/create/server-source/crates/feltdb/src/lib.rs +1173 -72
  3. package/dist/create/server-source/crates/feltdb/src/operation.rs +39 -0
  4. package/dist/create/server-source/crates/feltdb/src/state_model.rs +52 -0
  5. package/dist/create/server-source/crates/feltdb/src/storage.rs +9 -3
  6. package/dist/create/server-source/crates/feltdb/tests/bounded_read_contract.rs +132 -0
  7. package/dist/create/server-source/crates/feltdb/tests/common/mod.rs +96 -0
  8. package/dist/create/server-source/crates/feltdb/tests/compaction_stall_contract.rs +272 -0
  9. package/dist/create/server-source/crates/feltdb/tests/crash_durability_contract.rs +467 -0
  10. package/dist/create/server-source/crates/feltdb/tests/current_revision_authority_evidence.rs +26 -5
  11. package/dist/create/server-source/crates/feltdb/tests/durable_backup_contract.rs +445 -0
  12. package/dist/create/server-source/crates/feltdb/tests/durable_corruption_contract.rs +518 -0
  13. package/dist/create/server-source/crates/feltdb/tests/managed_incident_regression.rs +191 -0
  14. package/dist/create/server-source/crates/feltdb/tests/operational_health_contract.rs +278 -0
  15. package/dist/create/server-source/crates/feltdb/tests/production_certification.rs +1153 -0
  16. package/dist/create/server-source/crates/feltdb/tests/production_contract.rs +771 -0
  17. package/dist/create/server-source/crates/feltdb/tests/production_readiness_contract.rs +490 -157
  18. package/dist/create/server-source/crates/feltdb/tests/replicated_history_contract.rs +417 -0
  19. package/dist/create/server-source/crates/feltdb/tests/workload_envelope_contract.rs +442 -0
  20. package/dist/create/server-source/crates/feltdb-server/src/app_state.rs +14 -0
  21. package/dist/create/server-source/crates/feltdb-server/src/main.rs +369 -41
  22. package/dist/create/server-source/crates/feltdb-server/src/metrics.rs +21 -0
  23. package/dist/studio-app/assets/{feltdb_wasm-DaNwCLRX.js → feltdb_wasm-C1VhI-U5.js} +1 -1
  24. package/dist/studio-app/assets/feltdb_wasm_bg-C8HXbAXb.wasm +0 -0
  25. package/dist/studio-app/assets/{index-j8IlhNqJ.js → index-Bbos1m2U.js} +1 -1
  26. package/dist/studio-app/index.html +1 -1
  27. package/dist/wasm/feltdb_wasm_bg.wasm +0 -0
  28. package/package.json +1 -1
  29. 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
- .map_err(|_| FlowError::CorruptLogLine(line.clone()))?;
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
+ }