use crate::errors::PagedbError;
use crate::pager::anchor::HeaderCursor;
use crate::pager::freelist;
use crate::pager::freelist::CHAIN_METADATA_CID;
use crate::pager::header::commit_header;
use crate::pager::structural_header::MainDbHeaderFields;
use crate::vfs::Vfs;
use crate::{CommitId, Result};
use std::collections::HashSet;
use super::super::db::{CommitHistoryMeta, PendingWriterState, encode_free_list_root};
use super::txn::WriteTxn;
impl<V: Vfs + Clone> WriteTxn<'_, V> {
pub async fn commit(mut self) -> Result<CommitId> {
let outcome = self.commit_body().await;
self.cleanup_spill_async().await;
self.db
.spill_bytes_in_use
.store(0, std::sync::atomic::Ordering::Relaxed);
outcome
}
#[allow(clippy::too_many_lines)]
async fn commit_body(&mut self) -> Result<CommitId> {
debug_assert!(
self.db.write_lock_satisfied(),
"page write without held writer lock"
);
let _span = tracing::debug_span!("txn.commit");
let new_commit_id = self.guard.latest_commit_id + 1;
let invariant_checks = std::env::var_os("PAGEDB_INVARIANT_CHECKS").is_some();
let mut pending = PendingWriterState::capture(&self.guard);
self.btree.materialize_dirty().await?;
self.sync_allocator_to_catalog();
self.catalog_tree.materialize_dirty().await?;
self.sync_allocator_from_catalog();
let new_root = self.btree.root_page_id();
let new_catalog_root = self.catalog_tree.root_page_id();
pending.next_page_id = self
.btree
.next_page_id()
.max(self.catalog_tree.next_page_id());
let unix_seconds = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map_or(0, |d| d.as_secs());
let history_meta = CommitHistoryMeta {
active_root_page_id: new_root,
catalog_root_page_id: new_catalog_root,
free_list_root_page_id: self.guard.free_list_root_page_id,
next_page_id: pending.next_page_id,
unix_seconds,
};
let mut hist_freed: Vec<u64> = Vec::new();
if !matches!(
self.db.options.commit_history_retain,
crate::options::RetainPolicy::Disabled
) {
hist_freed = self
.db
.write_commit_history_entry(&mut pending, new_commit_id, history_meta)
.await?;
}
let all_freed: Vec<u64> = self
.btree
.drain_freed()
.into_iter()
.chain(self.catalog_tree.drain_freed())
.chain(hist_freed)
.filter(|&pid| pid >= 4)
.collect();
let consumed: HashSet<u64> = std::mem::take(&mut *self.db.free_page_consumed.lock())
.into_iter()
.collect();
let prior_free: Vec<(u64, u64)> = std::mem::take(&mut self.free_set_loaded)
.into_iter()
.filter(|(_, pid)| !consumed.contains(pid))
.collect();
let old_chain: Vec<u64> = std::mem::take(&mut self.old_chain_pages);
let retained_tail = self.retained_tail_page_id;
let host_candidates: Vec<u64> = std::mem::take(&mut *self.db.free_page_cache.lock())
.into_iter()
.filter(|pid| !consumed.contains(pid))
.collect();
let mut entries: Vec<(u64, u64)> = prior_free;
entries.extend(all_freed.iter().map(|&pid| (new_commit_id, pid)));
entries.extend(old_chain.iter().map(|&pid| (CHAIN_METADATA_CID, pid)));
let tail_stuck = freelist::count_at_or_above_floor(
&self.db.pager,
self.db.realm_id,
retained_tail,
self.reclaim_floor,
)
.await?;
let window_stuck = entries
.iter()
.filter(|(cid, _)| *cid >= self.reclaim_floor)
.count() as u64;
self.db
.evaluate_stall_policy(tail_stuck.saturating_add(window_stuck))?;
if invariant_checks {
let freed = entries.iter().map(|&(_, page_id)| page_id).collect();
let roots = [
("data", new_root),
("catalog", new_catalog_root),
("commit-history", pending.commit_history_root_page_id),
];
if let Err(violation) = assert_freed_pages_unreachable(
self.db,
roots,
pending.next_page_id,
&freed,
new_commit_id,
)
.await
{
panic!("{violation}");
}
}
let (new_free_list_root, new_next_page) = freelist::rewrite_chain(
&self.db.pager,
self.db.realm_id,
self.db.page_size,
entries,
host_candidates,
pending.next_page_id,
retained_tail,
)
.await?;
pending.next_page_id = new_next_page;
self.db.pager.flush_main(self.db.realm_id).await?;
let new_next = pending.next_page_id;
let header_cursor = self.db.pager.header_cursor()?;
let new_seq = header_cursor.next_seq()?;
let counter_anchor = self.db.pager.pending_anchor();
let mut catalog_root_bytes = [0u8; 16];
catalog_root_bytes[..8].copy_from_slice(&new_catalog_root.to_le_bytes());
catalog_root_bytes[8..].copy_from_slice(&new_commit_id.to_le_bytes());
let (policy_tag, policy_value) =
encode_retain_policy(&self.db.options.commit_history_retain);
let fields = MainDbHeaderFields {
format_version: crate::pager::structural_header::MAIN_FORMAT_VERSION,
cipher_id: self.db.cipher_id.as_byte(),
page_size_log2: page_size_log2(self.db.page_size)?,
flags: 0,
file_id: self.db.file_id,
kek_salt: self.db.kek_salt,
mk_epoch: self.db.mk_epoch.load(std::sync::atomic::Ordering::SeqCst),
seq: new_seq,
active_root_page_id: new_root,
active_root_txn_id: new_commit_id,
counter_anchor,
commit_id: CommitId(new_commit_id),
free_list_root: encode_free_list_root(new_free_list_root),
catalog_root: catalog_root_bytes,
apply_journal_root_page_id: 0,
apply_journal_root_version: 0,
commit_history_root_page_id: pending.commit_history_root_page_id,
commit_history_root_version: pending.commit_history_root_version,
restore_mode: 0,
next_page_id: new_next,
commit_retain_policy_tag: policy_tag,
commit_retain_policy_value: policy_value,
realm_id: self.db.realm_id,
};
let hk_clone = { self.db.hk.read().clone() };
let new_slot = commit_header(
&*self.db.vfs,
&self.db.main_db_path,
&hk_clone,
&fields,
header_cursor.slot,
self.db.page_size,
)
.await?;
self.db.pager.note_header_written(HeaderCursor {
slot: new_slot,
seq: new_seq,
});
pending.publish(&mut self.guard);
self.guard.root_page_id = new_root;
self.guard.latest_commit_id = new_commit_id;
self.guard.catalog_root_page_id = new_catalog_root;
self.guard.catalog_root_txn_id = new_commit_id;
self.guard.free_list_root_page_id = new_free_list_root;
self.committed_or_aborted = true;
crate::diag::committed(new_commit_id, all_freed.len());
if self
.db
.finish_durable_commit_visible(
&self.visibility_guard,
&self.guard,
CommitId(new_commit_id),
counter_anchor,
&self.pending_segments,
)
.await
.is_err()
{
return Err(PagedbError::durably_committed_but_unpublished(CommitId(
new_commit_id,
)));
}
tracing::debug!(
name = "txn.commit",
commit_id = new_commit_id,
"write transaction committed"
);
Ok(CommitId(new_commit_id))
}
}
#[derive(Debug)]
enum FreedPageInvariantViolation {
Dangling {
commit_id: u64,
tree_name: &'static str,
root: u64,
description: String,
},
Traversal {
commit_id: u64,
tree_name: &'static str,
root: u64,
error: PagedbError,
},
ReachableFreed {
commit_id: u64,
page_id: u64,
},
}
impl std::fmt::Display for FreedPageInvariantViolation {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Dangling {
commit_id,
tree_name,
root,
description,
} => write!(
formatter,
"PAGEDB INVARIANT VIOLATED: commit {commit_id} \
{tree_name}_root={root}: {description}"
),
Self::Traversal {
commit_id,
tree_name,
root,
error,
} => write!(
formatter,
"PAGEDB INVARIANT VIOLATED: commit {commit_id} could not traverse the \
new {tree_name} tree rooted at page {root}: {error}"
),
Self::ReachableFreed { commit_id, page_id } => write!(
formatter,
"PAGEDB INVARIANT VIOLATED: commit {commit_id} freed page {page_id}, but it is \
still reachable from the new data/catalog/history roots"
),
}
}
}
async fn assert_freed_pages_unreachable<V: Vfs + Clone>(
db: &super::super::db::Db<V>,
roots: [(&'static str, u64); 3],
next_page_id: u64,
freed: &HashSet<u64>,
commit_id: u64,
) -> std::result::Result<(), FreedPageInvariantViolation> {
let mut reachable = std::collections::BTreeSet::new();
for (tree_name, root) in roots.into_iter().filter(|(_, root)| *root != 0) {
let tree = crate::btree::BTree::open(
db.pager.clone(),
db.realm_id,
root,
next_page_id,
db.page_size,
);
if let Some(description) = tree.find_dangling().await {
return Err(FreedPageInvariantViolation::Dangling {
commit_id,
tree_name,
root,
description,
});
}
tree.collect_all_page_ids(&mut reachable)
.await
.map_err(|error| FreedPageInvariantViolation::Traversal {
commit_id,
tree_name,
root,
error,
})?;
}
for page_id in reachable {
if freed.contains(&page_id) {
return Err(FreedPageInvariantViolation::ReachableFreed { commit_id, page_id });
}
}
Ok(())
}
fn page_size_log2(page_size: usize) -> Result<u8> {
match page_size {
4096 => Ok(12),
8192 => Ok(13),
16384 => Ok(14),
32768 => Ok(15),
65536 => Ok(16),
_ => Err(PagedbError::Unsupported),
}
}
fn encode_retain_policy(policy: &crate::options::RetainPolicy) -> (u8, u64) {
match policy {
crate::options::RetainPolicy::Count(n) => (0, u64::from(*n)),
crate::options::RetainPolicy::Age(d) => (1, d.as_secs()),
crate::options::RetainPolicy::Unbounded => (2, 0),
crate::options::RetainPolicy::Disabled => (3, 0),
}
}
#[cfg(test)]
mod tests {
use std::process::Command;
use std::sync::Arc;
use super::*;
use crate::OpenOptions;
use crate::pager::PageKind;
use crate::vfs::memory::MemVfs;
const REALM: crate::RealmId = crate::RealmId::new([3u8; 16]);
async fn populated_db() -> crate::Db<MemVfs> {
let db = crate::Db::open_internal_with_options(
MemVfs::new(),
[7u8; 32],
4096,
REALM,
OpenOptions::default(),
)
.await
.unwrap();
let mut write = db.begin_write().await.unwrap();
write.put(b"live", b"value").await.unwrap();
write.commit().await.unwrap();
db
}
#[tokio::test(flavor = "current_thread")]
async fn invariant_rejects_a_reachable_commit_history_page() {
let db = populated_db().await;
let (history_root, next_page_id) = {
let state = db.writer.lock().await;
(state.commit_history_root_page_id, state.next_page_id)
};
assert_ne!(history_root, 0, "the fixture must create commit history");
let violation = assert_freed_pages_unreachable(
&db,
[
("data", 0),
("catalog", 0),
("commit-history", history_root),
],
next_page_id,
&HashSet::from([history_root]),
2,
)
.await
.unwrap_err();
assert!(matches!(
violation,
FreedPageInvariantViolation::ReachableFreed { page_id, .. }
if page_id == history_root
));
}
#[tokio::test(flavor = "current_thread")]
async fn invariant_rejects_an_unreadable_root() {
let db = populated_db().await;
let next_page_id = db.writer.lock().await.next_page_id;
let unreadable_root = next_page_id + 100;
let violation = assert_freed_pages_unreachable(
&db,
[
("data", unreadable_root),
("catalog", 0),
("commit-history", 0),
],
unreadable_root + 1,
&HashSet::new(),
2,
)
.await
.unwrap_err();
assert!(matches!(
violation,
FreedPageInvariantViolation::Dangling {
tree_name: "data",
root,
..
} if root == unreadable_root
));
}
#[tokio::test(flavor = "current_thread")]
async fn invariant_rejects_a_root_whose_authenticated_kind_contradicts_its_body() {
let db = populated_db().await;
let (data_root, next_page_id) = {
let state = db.writer.lock().await;
(state.root_page_id, state.next_page_id)
};
let (guard, kind) = db.pager.read_main_node(data_root, REALM).await.unwrap();
assert_eq!(kind, PageKind::BTreeLeaf, "the fixture root must be a leaf");
let body = guard.body_ref().to_vec();
drop(guard);
let forged = next_page_id;
db.pager
.write_main_page(forged, REALM, PageKind::BTreeInternal, &body)
.await
.unwrap();
db.pager.flush_main(REALM).await.unwrap();
db.pager.reset_main_pages();
let tree =
crate::btree::BTree::open(db.pager.clone(), REALM, forged, forged + 1, db.page_size);
assert!(
tree.find_dangling().await.is_none(),
"the strict pointer walk is expected to miss a kind disagreement; \
if it now catches it, this test no longer covers the traversal path"
);
let violation = assert_freed_pages_unreachable(
&db,
[("data", forged), ("catalog", 0), ("commit-history", 0)],
forged + 1,
&HashSet::new(),
2,
)
.await
.unwrap_err();
assert!(
matches!(
violation,
FreedPageInvariantViolation::Traversal {
tree_name: "data",
root,
..
} if root == forged
),
"expected a traversal violation, got {violation:?}"
);
}
#[test]
fn invariant_failure_precedes_durable_publication() {
let output = Command::new(std::env::current_exe().unwrap())
.args([
"--ignored",
"--exact",
"txn::write::commit::tests::invariant_failure_precedes_durable_publication_child",
"--nocapture",
])
.env("PAGEDB_INVARIANT_CHECKS", "1")
.output()
.unwrap();
let report = String::from_utf8_lossy(&output.stdout).into_owned()
+ &String::from_utf8_lossy(&output.stderr);
assert!(
report.contains("1 passed"),
"the child helper did not run; the filter no longer names it:\n{report}"
);
assert!(
output.status.success(),
"invariant subprocess failed:\n{report}"
);
}
#[tokio::test(flavor = "current_thread")]
#[ignore = "subprocess helper; the parent supplies PAGEDB_INVARIANT_CHECKS"]
async fn invariant_failure_precedes_durable_publication_child() {
assert!(std::env::var_os("PAGEDB_INVARIANT_CHECKS").is_some());
let vfs = MemVfs::new();
let db = Arc::new(
crate::Db::open_internal_with_options(
vfs.clone(),
[7u8; 32],
4096,
crate::RealmId::new([3u8; 16]),
OpenOptions::default(),
)
.await
.unwrap(),
);
{
let mut write = db.begin_write().await.unwrap();
write.put(b"live", b"value").await.unwrap();
write.commit().await.unwrap();
}
let (commit_before, data_root, history_state_before) = {
let state = db.writer.lock().await;
(
state.latest_commit_id,
state.root_page_id,
(
state.next_page_id,
state.commit_history_root_page_id,
state.commit_history_root_version,
state.commit_history_count,
),
)
};
let task_db = Arc::clone(&db);
let failure = tokio::spawn(async move {
let mut write = task_db.begin_write().await.unwrap();
write.free_set_loaded.push((0, data_root));
write.commit().await
})
.await;
assert!(
failure.is_err(),
"the injected invariant violation must panic"
);
drop(failure);
let state = db.writer.lock().await;
assert_eq!(state.latest_commit_id, commit_before);
assert_eq!(
(
state.next_page_id,
state.commit_history_root_page_id,
state.commit_history_root_version,
state.commit_history_count,
),
history_state_before
);
drop(state);
drop(db);
let reopened = crate::Db::open(
vfs,
[7u8; 32],
4096,
crate::RealmId::new([3u8; 16]),
OpenOptions::default(),
)
.await
.unwrap();
assert_eq!(reopened.latest_commit().0, commit_before);
let read = reopened.begin_read().await.unwrap();
assert_eq!(
read.get(b"live").await.unwrap().as_deref(),
Some(b"value".as_slice())
);
}
}