Skip to content

Commit f30565e

Browse files
committed
fix(db): publish durable commits atomically
1 parent 230e95d commit f30565e

42 files changed

Lines changed: 2298 additions & 1543 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,3 +96,4 @@ dmypy.json
9696

9797
.cargo/
9898
.pi
99+
.agents

src/catalog/codec.rs

Lines changed: 1 addition & 87 deletions
Original file line numberDiff line numberDiff line change
@@ -26,22 +26,7 @@ pub enum CatalogRowKind {
2626
// 0x04 and 0x05 are reserved: they were the in-catalog free-list and
2727
// deferred-free queue, superseded by the durable free-list chain rooted in
2828
// the A/B header (see `crate::pager::freelist`). Do not reuse these bytes.
29-
/// Durable reader pin. One row per active cross-process read transaction.
30-
/// Key: `[0x06] || pid_u32_be[4] || lease_id_u64_be[8]` (13 bytes).
31-
/// Value: `commit_id[8] || root_page_id[8] || catalog_root_page_id[8] ||
32-
/// free_list_root_page_id[8] || expires_unix_seconds[8] || flags[1]`
33-
/// (41 bytes).
34-
///
35-
/// On `begin_read` the writer inserts a row; on `ReadTxn::drop` the row is
36-
/// deleted. If a reader crashes, its row is cleaned up at the next
37-
/// `Db::open` of a writer handle. A row whose `expires_unix_seconds` is
38-
/// older than the current wall-clock time is treated as expired by GC and
39-
/// does not block page reclamation.
40-
///
41-
/// On a `ReadOnly` or `Follower` handle that cannot write to its own
42-
/// catalog, reader pins are maintained in-memory only and the writer process
43-
/// must be trusted to honor the catalog pins.
44-
ReaderPin = 0x06,
29+
// 0x06 is reserved, deliberately uninterpreted, and must never be reused.
4530
/// Reserved (`0x07`). Older builds wrote an incremental-compaction watermark
4631
/// here; compaction is now a single atomic operation and never writes it.
4732
/// Retained as a row-kind boundary and so any legacy row is recognised and
@@ -112,20 +97,6 @@ pub struct RealmQuotas {
11297
pub max_segment_bytes: Option<u64>,
11398
}
11499

115-
/// Value for a durable reader-pin row.
116-
#[derive(Debug, Clone, PartialEq, Eq)]
117-
pub struct ReaderPinValue {
118-
pub commit_id: u64,
119-
pub root_page_id: u64,
120-
pub catalog_root_page_id: u64,
121-
pub free_list_root_page_id: u64,
122-
pub expires_unix_seconds: u64,
123-
pub flags: u8,
124-
}
125-
126-
pub const READER_PIN_VALUE_LEN: usize = 41;
127-
pub const READER_PIN_KEY_LEN: usize = 13;
128-
129100
pub const SEGMENT_META_LEN: usize = 94;
130101
pub const REALM_QUOTAS_LEN: usize = 33;
131102

@@ -191,63 +162,6 @@ impl Catalog {
191162
})
192163
}
193164

194-
/// Reader-pin row key: `[0x06] || pid_u32_be[4] || lease_id_u64_be[8]`.
195-
#[must_use]
196-
pub fn reader_pin_key(pid: u32, lease_id: u64) -> [u8; READER_PIN_KEY_LEN] {
197-
let mut k = [0u8; READER_PIN_KEY_LEN];
198-
k[0] = CatalogRowKind::ReaderPin as u8;
199-
k[1..5].copy_from_slice(&pid.to_be_bytes());
200-
k[5..13].copy_from_slice(&lease_id.to_be_bytes());
201-
k
202-
}
203-
204-
/// Range start key for all reader-pin rows: `[0x06]`.
205-
#[must_use]
206-
pub fn reader_pin_range_start() -> [u8; 1] {
207-
[CatalogRowKind::ReaderPin as u8]
208-
}
209-
210-
/// Range end key (exclusive) for all reader-pin rows: `[0x07]`.
211-
#[must_use]
212-
pub fn reader_pin_range_end() -> [u8; 1] {
213-
[CatalogRowKind::CompactionState as u8]
214-
}
215-
216-
/// Encode a `ReaderPinValue` as 41 bytes.
217-
#[must_use]
218-
pub fn encode_reader_pin(v: &ReaderPinValue) -> [u8; READER_PIN_VALUE_LEN] {
219-
let mut o = [0u8; READER_PIN_VALUE_LEN];
220-
o[0..8].copy_from_slice(&v.commit_id.to_le_bytes());
221-
o[8..16].copy_from_slice(&v.root_page_id.to_le_bytes());
222-
o[16..24].copy_from_slice(&v.catalog_root_page_id.to_le_bytes());
223-
o[24..32].copy_from_slice(&v.free_list_root_page_id.to_le_bytes());
224-
o[32..40].copy_from_slice(&v.expires_unix_seconds.to_le_bytes());
225-
o[40] = v.flags;
226-
o
227-
}
228-
229-
/// Decode a `ReaderPinValue` from a 41-byte slice.
230-
pub fn decode_reader_pin(bytes: &[u8]) -> Result<ReaderPinValue> {
231-
if bytes.len() != READER_PIN_VALUE_LEN {
232-
return Err(PagedbError::corruption(
233-
crate::errors::CorruptionDetail::HeaderUnverifiable,
234-
));
235-
}
236-
let read_u64 = |off: usize| {
237-
let mut b = [0u8; 8];
238-
b.copy_from_slice(&bytes[off..off + 8]);
239-
u64::from_le_bytes(b)
240-
};
241-
Ok(ReaderPinValue {
242-
commit_id: read_u64(0),
243-
root_page_id: read_u64(8),
244-
catalog_root_page_id: read_u64(16),
245-
free_list_root_page_id: read_u64(24),
246-
expires_unix_seconds: read_u64(32),
247-
flags: bytes[40],
248-
})
249-
}
250-
251165
/// Counter row key: `[0x02] || name_bytes`. Rejects names longer than
252166
/// `MAX_SEGMENT_NAME_LEN`. Counter rows are per-`Db`, not per-realm.
253167
pub fn counter_key(name: &[u8]) -> Result<Vec<u8>> {

src/catalog/mod.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,4 +4,4 @@
44
55
pub mod codec;
66

7-
pub use codec::{Catalog, CatalogRowKind, ReaderPinValue, RekeyStateRow};
7+
pub use codec::{Catalog, CatalogRowKind, RekeyStateRow};

src/compaction/full.rs

Lines changed: 21 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ pub async fn compact_now<V: Vfs + Clone>(db: &Db<V>) -> Result<CompactStats> {
3333

3434
#[allow(clippy::too_many_lines)]
3535
async fn compact_now_inner<V: Vfs + Clone>(db: &Db<V>) -> Result<CompactStats> {
36+
db.ensure_usable()?;
3637
if !matches!(db.mode, crate::txn::mode::DbMode::Standalone) {
3738
return Err(PagedbError::Unsupported);
3839
}
@@ -41,6 +42,12 @@ async fn compact_now_inner<V: Vfs + Clone>(db: &Db<V>) -> Result<CompactStats> {
4142

4243
// Acquire exclusive writer lock for the entire compact operation.
4344
let mut state = db.writer.lock().await;
45+
db.ensure_usable()?;
46+
#[cfg(test)]
47+
db.notify_writer_waiting();
48+
// Keep reader admission closed from the pin scan through the main-file
49+
// swap, directory sync, and published replacement snapshot.
50+
let visibility_guard = db.visibility_gate.write().await;
4451

4552
// Compaction relocates and/or truncates pages, invalidating every page id
4653
// cached for runtime reuse. Drop those reuse hints so a post-compaction
@@ -49,23 +56,13 @@ async fn compact_now_inner<V: Vfs + Clone>(db: &Db<V>) -> Result<CompactStats> {
4956

5057
// ── 1. Refuse while readers are pinned ───────────────────────────────────
5158
// A dense repack relocates the current tree and truncates the file; pinned
52-
// readers (in-process or cross-process durable) still reference the old
53-
// pages, so neither is safe under them. Runtime free-page reuse already
59+
// in-process readers still reference the old pages, so neither is safe
60+
// under them. Runtime free-page reuse already
5461
// reclaims space on ordinary commits, so there is nothing for compaction to
5562
// do here until the readers drop.
5663
let has_readers = {
57-
let in_mem_min = {
58-
let readers = db.tracked_readers.lock();
59-
readers
60-
.iter()
61-
.map(|r| r.commit_id.0)
62-
.min()
63-
.unwrap_or(u64::MAX)
64-
};
65-
let durable_min = db
66-
.min_durable_reader_commit(state.catalog_root_page_id, state.next_page_id)
67-
.await;
68-
in_mem_min.min(durable_min) < u64::MAX
64+
let readers = db.tracked_readers.lock();
65+
!readers.is_empty()
6966
};
7067
if has_readers {
7168
return Ok(result);
@@ -78,7 +75,7 @@ async fn compact_now_inner<V: Vfs + Clone>(db: &Db<V>) -> Result<CompactStats> {
7875
// atomic rename; main.db is never modified until the rename (see
7976
// `super::repack`).
8077
if state.free_list_root_page_id != 0 {
81-
let repack = super::repack::atomic_dense_repack(db, &mut state).await?;
78+
let repack = super::repack::atomic_dense_repack(db, &mut state, &visibility_guard).await?;
8279
result.main_db_pages_reclaimed = repack.pages_reclaimed;
8380
result.bytes_truncated = repack.bytes_truncated;
8481
}
@@ -131,7 +128,15 @@ async fn compact_now_inner<V: Vfs + Clone>(db: &Db<V>) -> Result<CompactStats> {
131128
let new_meta = writer.seal().await?;
132129
let seg_name =
133130
find_segment_name_inner(&db.pager, db.realm_id, &state, &meta.segment_id).await?;
134-
replace_segment_compact(db, &mut state, &seg_name, &meta.segment_id, &new_meta).await?;
131+
replace_segment_compact(
132+
db,
133+
&mut state,
134+
&visibility_guard,
135+
&seg_name,
136+
&meta.segment_id,
137+
&new_meta,
138+
)
139+
.await?;
135140
result.segments_repacked += 1;
136141
}
137142

src/compaction/helpers.rs

Lines changed: 23 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ use crate::errors::PagedbError;
99
use crate::pager::header::commit_header;
1010
use crate::pager::structural_header::MainDbHeaderFields;
1111
use crate::txn::db::{Db, WriterState};
12+
use crate::txn::write::SegmentSideEffect;
1213
use crate::vfs::Vfs;
1314
use crate::{CommitId, Result};
1415

@@ -102,6 +103,7 @@ pub(super) async fn find_segment_name_inner<V: Vfs + Clone>(
102103
pub(super) async fn replace_segment_compact<V: Vfs + Clone>(
103104
db: &Db<V>,
104105
state: &mut WriterState,
106+
visibility: &tokio::sync::RwLockWriteGuard<'_, ()>,
105107
name: &str,
106108
old_segment_id: &[u8; 16],
107109
new_meta: &SegmentMeta,
@@ -164,34 +166,33 @@ pub(super) async fn replace_segment_compact<V: Vfs + Clone>(
164166
db.page_size,
165167
)
166168
.await?;
167-
db.pager.commit_anchor(counter_anchor)?;
168-
169-
// Promote staging file to live.
170-
db.vfs.mkdir_all("seg").await?;
171-
let staging = crate::segment::writer::staging_path(&new_meta.segment_id);
172-
let live = crate::segment::writer::live_path(&new_meta.segment_id);
173-
db.vfs.rename(&staging, &live).await?;
174-
db.vfs.sync_dir("seg").await.ok();
175-
176-
// Tombstone old segment.
177-
let old_live = crate::segment::writer::live_path(old_segment_id);
178-
let tomb = format!(
179-
"seg/.tombstone/{}.{}",
180-
crate::hex::to_hex_lower(old_segment_id),
181-
new_commit_id,
182-
);
183-
db.vfs.mkdir_all("seg/.tombstone").await?;
184-
db.vfs.rename(&old_live, &tomb).await.ok();
185-
db.vfs.sync_dir("seg/.tombstone").await.ok();
186-
169+
// The catalog is durable at this point. Preserve its state internally but
170+
// retain the prior reader snapshot until segment replacement is reconciled.
187171
state.catalog_root_page_id = new_cat_root;
172+
state.catalog_root_txn_id = new_commit_id;
188173
state.next_page_id = new_next;
189174
state.active_slot = new_slot;
190175
state.seq = new_seq;
191176
state.latest_commit_id = new_commit_id;
192-
db.latest_commit
193-
.store(new_commit_id, std::sync::atomic::Ordering::SeqCst);
194177

178+
let effects = [
179+
SegmentSideEffect::Tombstone {
180+
segment_id: *old_segment_id,
181+
tombstone_commit_id: None,
182+
},
183+
SegmentSideEffect::Promote {
184+
segment_id: new_meta.segment_id,
185+
},
186+
];
187+
let _ = db
188+
.finish_durable_commit_visible(
189+
visibility,
190+
state,
191+
CommitId(new_commit_id),
192+
counter_anchor,
193+
&effects,
194+
)
195+
.await?;
195196
Ok(())
196197
}
197198

src/compaction/repack.rs

Lines changed: 32 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -46,13 +46,14 @@ fn scratch_path(db: &Db<impl Vfs + Clone>) -> String {
4646
pub(super) async fn atomic_dense_repack<V: Vfs + Clone>(
4747
db: &Db<V>,
4848
state: &mut WriterState,
49+
visibility: &tokio::sync::RwLockWriteGuard<'_, ()>,
4950
) -> Result<RepackOutcome> {
5051
let scratch = scratch_path(db);
5152
// Remove any leftover scratch from an earlier interrupted compaction.
5253
db.vfs.remove(&scratch).await.ok();
5354

5455
match build_scratch(db, state, &scratch).await {
55-
Ok(pending) => commit_swap(db, state, &scratch, pending).await,
56+
Ok(pending) => commit_swap(db, state, visibility, &scratch, pending).await,
5657
Err(e) => {
5758
// Nothing was written to main.db; drop the never-persisted compacted
5859
// pages from the cache so the old tree is read back from disk, and
@@ -166,32 +167,52 @@ pub(super) async fn build_scratch<V: Vfs + Clone>(
166167
async fn commit_swap<V: Vfs + Clone>(
167168
db: &Db<V>,
168169
state: &mut WriterState,
170+
visibility: &tokio::sync::RwLockWriteGuard<'_, ()>,
169171
scratch: &str,
170172
pending: PendingSwap,
171173
) -> Result<RepackOutcome> {
172174
// Close the cached main.db handle so the rename can replace the file
173175
// (Windows) and the next access reopens the new inode (Unix).
174176
db.pager.close_main_handle().await;
175-
db.vfs.rename(scratch, &db.main_db_path).await?;
176-
db.vfs.sync_dir("/").await.ok();
177-
178-
db.pager.commit_anchor(pending.counter_anchor)?;
179-
// The cached pages were sealed for the scratch file (now main.db); drop them
180-
// so reads re-fetch from the renamed file rather than serving stale entries.
181-
db.pager.reset_main_pages();
182-
183177
let new_commit_id = state.latest_commit_id + 1;
178+
if db.vfs.rename(scratch, &db.main_db_path).await.is_err() {
179+
// After closing the old handle, a backend may have performed an
180+
// ambiguous replacement even when it reports an error. Reopen is the
181+
// only safe way to establish the durable image.
182+
let commit = crate::CommitId(new_commit_id);
183+
db.poison(commit);
184+
return Err(crate::errors::PagedbError::durably_committed_but_unpublished(commit));
185+
}
186+
187+
// Rename is the durable-image boundary. Advance internal state and drop
188+
// cache pages immediately, but do not publish until the parent directory
189+
// sync and nonce-anchor commit both succeed.
184190
state.root_page_id = pending.new_root;
185191
state.catalog_root_page_id = pending.new_cat_root;
192+
state.catalog_root_txn_id = new_commit_id;
186193
state.next_page_id = pending.new_next;
187194
state.active_slot = ActiveSlot::A;
188195
state.seq += 1;
189196
state.latest_commit_id = new_commit_id;
190197
state.commit_history_root_page_id = 0;
191198
state.commit_history_root_version = 0;
192199
state.free_list_root_page_id = 0;
193-
db.latest_commit
194-
.store(new_commit_id, std::sync::atomic::Ordering::SeqCst);
200+
db.pager.reset_main_pages();
201+
202+
if db.vfs.sync_dir(db.main_db_parent_dir()).await.is_err() {
203+
let commit = crate::CommitId(new_commit_id);
204+
db.poison(commit);
205+
return Err(crate::errors::PagedbError::durably_committed_but_unpublished(commit));
206+
}
207+
let _ = db
208+
.finish_durable_commit_visible(
209+
visibility,
210+
state,
211+
crate::CommitId(new_commit_id),
212+
pending.counter_anchor,
213+
&[],
214+
)
215+
.await?;
195216

196217
let pages_reclaimed = pending.old_next.saturating_sub(pending.new_next);
197218
Ok(RepackOutcome {

src/errors.rs

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,12 @@ pub enum PagedbError {
5050
#[error("identity forked; apply_incremental refused")]
5151
IdentityForked,
5252

53+
#[error("incremental snapshot is incompatible: {field}")]
54+
SnapshotIncompatible { field: &'static str },
55+
56+
#[error("commit {commit:?} is durable but unpublished; reopen required")]
57+
DurablyCommittedButUnpublished { commit: CommitId },
58+
5359
#[error("commit {commit:?} gone; oldest_available={oldest_available:?}")]
5460
CommitGone {
5561
commit: CommitId,
@@ -202,6 +208,20 @@ impl PagedbError {
202208
Self::Corruption(detail)
203209
}
204210

211+
/// Canonical constructor for an incremental snapshot that cannot be
212+
/// applied to this handle's current identity or reader-visible state.
213+
#[must_use]
214+
pub const fn snapshot_incompatible(field: &'static str) -> Self {
215+
Self::SnapshotIncompatible { field }
216+
}
217+
218+
/// Canonical constructor for a handle whose newest durable commit could
219+
/// not be reconciled into its reader-visible state.
220+
#[must_use]
221+
pub const fn durably_committed_but_unpublished(commit: CommitId) -> Self {
222+
Self::DurablyCommittedButUnpublished { commit }
223+
}
224+
205225
/// Canonical constructor for arithmetic-overflow errors.
206226
#[must_use]
207227
pub const fn arithmetic_overflow(operation: &'static str) -> Self {

src/pager/core.rs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -432,8 +432,8 @@ impl<V: Vfs> Pager<V> {
432432
self.inner.cache_for_key(key).lock().clear_file(key);
433433
self.journal_nonces.lock().remove(&journal_id);
434434
let path = format!("applyjournal/{}", crate::hex::to_hex_lower(&journal_id));
435-
self.vfs.remove(&path).await.ok();
436-
Ok(())
435+
self.vfs.remove(&path).await?;
436+
self.vfs.sync_dir("applyjournal").await
437437
}
438438

439439
/// Drop an apply-journal sidecar's cached pages without removing the file,

src/recovery/deep_walk.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -144,6 +144,7 @@ impl DeepWalkReport {
144144
/// page including free, spill, and unreferenced pages.
145145
#[allow(clippy::too_many_lines)]
146146
pub async fn run_deep_walk<V: Vfs + Clone>(db: &Db<V>) -> Result<DeepWalkReport> {
147+
db.ensure_usable()?;
147148
let mut report = DeepWalkReport::default();
148149

149150
let (next_page_id, catalog_root, catalog_next, free_list_root) = {

0 commit comments

Comments
 (0)