Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -303,6 +303,18 @@ only the control plane, not the segment frames.
`503 incompatible_store`, and any other frame that will not decode is `500 frame_undecodable`,
the code X-ERR gave a wire sentence but no producer. **No writer opts into the envelope yet** —
seals keep writing 5 and 6 — so this is the mechanism, not a format change.
- **A WeftDB 0.1.x data directory is refused, not quietly emptied** (1.0 plan FRE-14, first half).
A 0.1.x root keeps one database per aspect — `metadata.db` at the top and
`<subject>/<aspect>/measurements.db` two levels down — and that `metadata.db` is one of
`weftdb::CONTROL_PLANE_FILES`, so opening such a root used to **succeed**: it found a control
plane, created the three databases it was missing, added `segments/`, wrote a `STORE_FORMAT`
marker beside the old data, and reported an **empty** store with no error anywhere. Before any
database is opened, `SegmentStore::open` now recognises the shape and returns
`StoreError::RowsModeDataDirectory { aspects }`, naming each `<subject>/<aspect>` pair in path
order. It rides X-ERR's existing mapping — `503 incompatible_store`, CLI exit 65 — so there is no
new status code. The refusal writes nothing but `LOCK` and `LOCK.holder`: the test asserts the
file set is otherwise unchanged, that no marker is added, and that a second open refuses the same
way. Turning those rows into frames is the second half of FRE-14 and is still open.

### Changed

Expand Down
15 changes: 15 additions & 0 deletions weftdb/src/types/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -151,4 +151,19 @@ pub enum StoreError {
/// The aspects with such frames, in name order, each once.
aspects: Vec<String>,
},
/// The directory opened is not a store but a **WeftDB 0.1.x data directory**: it keeps a
/// database per aspect at `<subject>/<aspect>/measurements.db`, two levels below a
/// top-level `metadata.db`. No reader for that layout exists yet, and opening such a directory as a
/// store would be quietly wrong rather than loud: 0.1.x named its top-level database
/// `metadata.db`, which is one of this build's [`CONTROL_PLANE_FILES`](crate::types::backup::CONTROL_PLANE_FILES),
/// so the open would find a control plane, create the three databases missing from it, add
/// `segments/` and a `STORE_FORMAT` marker next to the old data, and report an empty store
/// with no error at all. It is refused before any database opens and before any of those
/// files exist; only `LOCK` and `LOCK.holder` are touched, as with every other refusal.
#[error("this is a WeftDB 0.1.x data directory, keeping a database per aspect ({}); this version reads stores, and nothing here was changed (only LOCK and LOCK.holder were written)", aspects.iter().map(|aspect| format!("{aspect:?}")).collect::<Vec<_>>().join(", "))]
RowsModeDataDirectory {
/// The `<subject>/<aspect>` directories holding a `measurements.db`, in path order,
/// each once.
aspects: Vec<String>,
},
}
11 changes: 10 additions & 1 deletion weftdb/src/types/migrations.rs
Original file line number Diff line number Diff line change
Expand Up @@ -105,7 +105,9 @@ pub(crate) struct Floors {
pub min_write: u32,
}

/// Every registered migration, in id order.
/// Every registered migration, in id order. An id is four digits, an underscore, and a
/// lower snake_case name: the digits are the ordinal that `pending` and `after` select on,
/// so the numbering rule is part of the registry's contract and the test below asserts it.
pub(crate) static REGISTRY: &[Migration] = &[Migration { id: "0001_baseline", layout: 1, min_read_after: 1, min_write_after: 1, older_writers_safe: false, precheck: None, apply: baseline, rederive: None }, Migration { id: "0002_s6_s7", layout: 2, min_read_after: 2, min_write_after: 2, older_writers_safe: false, precheck: Some(refuse_unsafe_legacy_paths), apply: s6_s7, rederive: None }];

/// The ordinal of a migration id: its leading digits (`0002_s6_s7` → 2). An id without
Expand Down Expand Up @@ -487,6 +489,13 @@ mod tests {
let ordinals: Vec<u64> = REGISTRY.iter().map(|migration| ordinal(migration.id)).collect();
assert!(ordinals.windows(2).all(|pair| pair[0] < pair[1]), "{ordinals:?}");
for migration in REGISTRY {
// `ordinal()` takes the digits before the first `_` and falls back to 0 without
// complaint, so an id that does not begin with the numbering rule is ordinal 0:
// never pending, never after, and it silently never runs. The format is what the
// ordering is built from, so it is asserted here rather than left to review.
let (digits, name) = migration.id.split_once('_').unwrap_or((migration.id, ""));
assert!(digits.len() == 4 && digits.bytes().all(|byte| byte.is_ascii_digit()), "{}: a migration id must begin with four digits, which is its ordinal", migration.id);
assert!(!name.is_empty() && name.bytes().all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'_'), "{}: a migration id's name follows the digits and is lower snake_case", migration.id);
assert!(migration.min_read_after <= migration.layout && migration.min_write_after <= migration.layout, "{}: a floor above its own layout", migration.id);
assert!(migration.layout <= crate::SUPPORTED_LAYOUT, "{}: a layout this build does not support", migration.id);
if migration.min_write_after < migration.layout {
Expand Down
11 changes: 11 additions & 0 deletions weftdb/src/types/segment_store.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1455,6 +1455,17 @@ impl SegmentStore {
if let Some(marker) = found.as_ref().filter(|marker| !marker.writable_by(SUPPORTED_LAYOUT)) {
return Err(anyhow::Error::new(StoreError::IncompatibleLayout { min_write: marker.min_write_layout, supported: SUPPORTED_LAYOUT }).context(opening()));
}
// 4a. A root with no marker is not necessarily an empty directory: the 0.1.x line was
// a data directory with one database per aspect, and its top-level database carries
// this build's control-plane name. Opening it as a store would build the rest of a
// store beside that data and report it empty with no error, so it is refused here,
// before any database opens and before any file of ours exists in the directory.
if found.is_none() {
let aspects = store_format::rows_mode_aspects(&root).await;
if !aspects.is_empty() {
return Err(anyhow::Error::new(StoreError::RowsModeDataDirectory { aspects }).context(opening()));
}
}
// 7, for a root that has databases: they open now, before anything is written to the
// root. segment_index.db opens first and refuses a write floor its store_meta records
// newer than this build (the gate of a root without a marker, 4) before anything is
Expand Down
104 changes: 104 additions & 0 deletions weftdb/src/types/store_format.rs
Original file line number Diff line number Diff line change
Expand Up @@ -185,6 +185,49 @@ pub async fn write_marker(fs: &dyn StoreFs, root: &Path, format: &StoreFormat) -
Ok(())
}

/// The `<subject>/<aspect>` pairs under `root` that show a **WeftDB 0.1.x data directory**
/// rather than a store, and nothing for anything else.
///
/// Shape decides, not a name: 0.1.x kept one database per aspect two levels down
/// (`<subject>/<aspect>/measurements.db`, the layout `Database::aspect_measurements_db_path`
/// writes — a private helper on the legacy rows-mode type, so named in prose here rather
/// than linked). That top-level name is the hazard — it is one of [`CONTROL_PLANE_FILES`](crate::types::backup::CONTROL_PLANE_FILES),
/// so a data directory looks like a store whose control plane is incomplete, and a layout-1
/// store root has the same file. What separates the two is the second level: a store's
/// subdirectories hold frames and backup trees, never an aspect's own database.
///
/// The caller runs this only for a root with no `STORE_FORMAT` marker, so a store at the
/// current layout never pays for it; the probe costs one directory read at the top and one
/// per child directory.
#[must_use]
pub(crate) async fn rows_mode_aspects(root: &Path) -> Vec<String> {
if !tokio::fs::try_exists(root.join("metadata.db")).await.unwrap_or(false) {
return Vec::new();
}
// A root that cannot be read is not reported as a data directory: the open goes on to
// fail on its own, with its own context, rather than blaming a shape it did not see.
let Ok(mut subjects) = tokio::fs::read_dir(root).await else { return Vec::new() };
let mut found = Vec::new();
while let Ok(Some(subject)) = subjects.next_entry().await {
// A symlink's type describes the link, so links are passed over with the files here
// rather than followed into somebody else's tree.
if !subject.file_type().await.is_ok_and(|kind| kind.is_dir()) {
continue;
}
let Ok(mut aspects) = tokio::fs::read_dir(subject.path()).await else { continue };
while let Ok(Some(aspect)) = aspects.next_entry().await {
if !aspect.file_type().await.is_ok_and(|kind| kind.is_dir()) {
continue;
}
if tokio::fs::try_exists(aspect.path().join("measurements.db")).await.unwrap_or(false) {
found.push(format!("{}/{}", subject.file_name().to_string_lossy(), aspect.file_name().to_string_lossy()));
}
}
}
found.sort();
found
}

#[cfg(test)]
mod tests {
use super::*;
Expand Down Expand Up @@ -220,6 +263,67 @@ mod tests {
assert!(format.writable_by(SUPPORTED_LAYOUT) && !format.writable_by(1));
}

/// A helper for the shape probes: a file at `path`, with its parents created.
fn touch(path: &Path) {
std::fs::create_dir_all(path.parent().expect("a parent")).expect("the parent directory");
std::fs::write(path, b"").expect("the placeholder file");
}

#[tokio::test]
async fn a_rows_mode_root_reports_its_aspect_databases_in_order() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
// 0.1.x laid a control-plane `metadata.db` at the root and one database per aspect
// two levels down. The order matters: the refusal names them, and a `read_dir` order
// would make the sentence (and any test of it) move with the filesystem.
touch(&root.join("metadata.db"));
touch(&root.join("plant/temp/measurements.db"));
touch(&root.join("plant/price/measurements.db"));
// A directory under the same parent that is not a measurement store stays unnamed:
// `dictionaries/` and its siblings were 0.1.x plumbing, not a series to migrate.
touch(&root.join("plant/dictionaries/words.db"));
assert_eq!(rows_mode_aspects(root).await, ["plant/price", "plant/temp"]);
}

#[tokio::test]
async fn a_store_root_is_never_mistaken_for_a_rows_mode_root() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
// The shape that matters is the one the migration path meets: a pre-marker store,
// whose `metadata.db` is a control-plane file, keeps its frames under `segments/`.
// Flagging that root would refuse the very store FRE-14 exists to upgrade.
for file in ["metadata.db", "segment_index.db", "aspect_catalog.db", "catalog.db"] {
touch(&root.join(file));
}
touch(&root.join("segments/price-0.weftseg"));
// The same shape but not the same file name, and one level deeper than the probe
// looks: neither is a measurement store, because the probe asks for
// `measurements.db` exactly, at `<subject>/<aspect>/`.
touch(&root.join("segments/price/measurements.dbx"));
touch(&root.join("plant/price/nested/measurements.db"));
let found = rows_mode_aspects(root).await;
assert_eq!(found, Vec::<String>::new(), "a storage-v2 root with a `segments/` tree is a store, not a data directory");
// The same root once the marker exists is equally uninteresting, and an empty root
// — the case every new store takes — must not pay more than one directory read.
std::fs::write(root.join(STORE_FORMAT_FILE), r#"{"kind":"weftdb-store","layout_version":2,"min_read_layout":1,"min_write_layout":1,"last_written_layout":2,"store_uuid":"u","scope":{"database":"d","subject":"s"}}"#).expect("a marker");
assert_eq!(rows_mode_aspects(root).await, Vec::<String>::new(), "the same root once marked is a store too");
let empty = tempfile::tempdir().unwrap();
assert_eq!(rows_mode_aspects(empty.path()).await, Vec::<String>::new(), "an empty root — the case every new store takes — finds nothing");
}

#[tokio::test]
async fn a_rows_mode_probe_does_not_follow_a_symlink() {
// The probe is a refusal, so the worst it may do is name a shape. Following a link
// would let a data directory outside the root decide whether an open is refused.
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
let outside = tempfile::tempdir().unwrap();
touch(&outside.path().join("price/measurements.db"));
touch(&root.join("metadata.db"));
std::os::unix::fs::symlink(outside.path(), root.join("plant")).expect("a symlink");
assert_eq!(rows_mode_aspects(root).await, Vec::<String>::new(), "a symlinked database directory is not reported");
}

#[tokio::test]
async fn a_marker_that_is_not_one_is_unreadable() {
let dir = tempfile::tempdir().unwrap();
Expand Down
Loading
Loading