8000 feat: migrate WAL call sites to WAL-0xx error codes — #277 (4/6) by adityamukho · Pull Request #363 · project-minigraf/minigraf · GitHub
[go: up one dir, main page]

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
13 changes: 13 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
string-matches error output is affected, including the interactive REPL's
printed error messages and all 7 language-binding repos (Python, Node,
WASM, Java, Android, Swift, C).
- **`src/wal.rs`'s errors now carry real `WAL-0xx` codes instead of falling
back to `INT-000`** (#360, toward #277): a bad `.wal` magic number is
`WAL-001`, an unsupported WAL version is `WAL-002`, a fact whose serialised
size exceeds the WAL's per-entry limit (~4080 bytes) is `WAL-003`, a fact
whose serialised size exceeds `u32::MAX` is `WAL-004` (practically
unreachable — WAL-003's limit fires first), a WAL header's `num_facts`
exceeding the platform `usize` is `WAL-005` (unreachable on any 64-bit
target), and a failure to delete the sidecar `.wal` file after a successful
checkpoint is `WAL-006`. `docs/ERROR_REFERENCE.md`'s WAL-002/003/004/006
"Error text" entries were rewritten from a concrete example value to the
canonical `{}`-placeholder template form so the registry↔doc sync test can
compare them byte-for-byte; the concrete example now lives in each entry's
prose instead.

### Bug fixes

Expand Down
14 changes: 7 additions & 7 deletions docs/ERROR_REFERENCE.md
Original file line number Diff line number Diff line change
Expand Up @@ -1949,19 +1949,19 @@ The WAL is replayed on open and deleted on checkpoint.

### WAL-002 Unsupported WAL version

**Error text**: `Unsupported WAL version: 3 (expected 2)`
**Error text**: `Unsupported WAL version: {} (expected {})`

**Cause**: The `.wal` file was written by a version of Minigraf with a different WAL format. This can occur when downgrading the library after a WAL was written by a newer version.

**Resolution**:
- Delete the `.wal` file if it is from an incomplete or stale session (no data is lost — committed facts are in the `.graph` file).
- If the WAL contains uncommitted in-flight data you need to recover, upgrade the library to the version that wrote the WAL before reopening.

**Scenario**: A `.wal` file written by a pre-release version of Minigraf is opened with the stable release, which uses a different WAL version number.
**Scenario**: A `.wal` file written by a pre-release version of Minigraf is opened with the stable release, which uses a different WAL version number — e.g. `Unsupported WAL version: 3 (expected 2)`.

### WAL-003 Fact serialised size exceeds maximum

**Error text**: `Fact serialised size 524800 bytes exceeds maximum 524288 bytes. Store large payloads externally and reference them with a Value::String URL/path or Value::Ref entity ID.`
**Error text**: `Fact serialised size {} bytes exceeds maximum {} bytes. Store large payloads externally and reference them with a Value::String URL/path or Value::Ref entity ID.`

**Cause**: A single fact's serialised size exceeds the WAL entry limit (~512 KB). This typically means a `Value::String` attribute value contains very large content such as raw document text, a base64-encoded image, or binary data.

Expand All @@ -1979,15 +1979,15 @@ The WAL is replayed on open and deleted on checkpoint.

### WAL-004 Fact serialised size exceeds u32 range

**Error text**: `fact serialised size 4294967297 exceeds u32 range`
**Error text**: `fact serialised size {} exceeds u32 range`

**Cause**: The serialised size of a single fact exceeds `u32::MAX` (~4 GB). This is practically unreachable — WAL-003's ~512 KB limit fires first.

**Resolution**:
- This should not occur under normal operation.
- If it does, file a bug.

**Scenario**: An extreme edge case where the fact serialisation path bypassed the WAL-003 limit check, producing an impossibly large entry.
**Scenario**: An extreme edge case where the fact serialisation path bypassed the WAL-003 limit check, producing an impossibly large entry — e.g. `fact serialised size 4294967297 exceeds u32 range`.

### WAL-005 WAL num_facts exceeds platform usize

Expand All @@ -2003,15 +2003,15 @@ The WAL is replayed on open and deleted on checkpoint.

### WAL-006 Failed to delete WAL file

**Error text**: `failed to delete WAL file my-db.wal: permission denied`
**Error text**: `failed to delete WAL file {}: {}`

**Cause**: After a successful `checkpoint()`, Minigraf could not delete the sidecar `.wal` file. This is typically a file system permissions issue.

**Resolution**:
- Check that the process has write access to the directory containing the `.graph` file (WAL deletion requires directory write permission, not just file write permission).
- The `.wal` file is safe to delete manually — Minigraf will create a new one on the next write.

**Scenario**: `db.checkpoint()` succeeds but the process lacks directory write permission, preventing deletion of `my-db.wal`.
**Scenario**: `db.checkpoint()` succeeds but the process lacks directory write permission, preventing deletion of `my-db.wal` — e.g. `failed to delete WAL file my-db.wal: permission denied`.

---

Expand Down
68 changes: 56 additions & 12 deletions src/error.rs
8000
Original file line number Diff line number Diff line change
Expand Up @@ -36,19 +36,63 @@ pub enum ErrorCategory {
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum ErrorCode {
Int000,
Wal001,
Wal002,
Wal003,
Wal004,
Wal005,
Wal006,
}

/// Single source of truth: (code, code string, message template, category).
///
/// The message template is verified against `docs/ERROR_REFERENCE.md`'s
/// "Error text" lines by the sync test in this module (added once real
/// codes exist in a follow-up category PR — see the design spec).
pub(crate) const REGISTRY: &[(ErrorCode, &str, &str, ErrorCategory)] = &[(
ErrorCode::Int000,
"INT-000",
"unclassified internal error: {}",
ErrorCategory::Internal,
)];
pub(crate) const REGISTRY: &[(ErrorCode, &str, &str, ErrorCategory)] = &[
(
ErrorCode::Int000,
"INT-000",
"unclassified internal error: {}",
ErrorCategory::Internal,
),
(
ErrorCode::Wal001,
"WAL-001",
"Invalid WAL magic number: not a .wal file",
ErrorCategory::Wal,
),
(
ErrorCode::Wal002,
"WAL-002",
"Unsupported WAL version: {} (expected {})",
ErrorCategory::Wal,
),
(
ErrorCode::Wal003,
"WAL-003",
"Fact serialised size {} bytes exceeds maximum {} bytes. Store large payloads externally and reference them with a Value::String URL/path or Value::Ref entity ID.",
ErrorCategory::Wal,
),
(
ErrorCode::Wal004,
"WAL-004",
"fact serialised size {} exceeds u32 range",
ErrorCategory::Wal,
),
(
ErrorCode::Wal005,
"WAL-005",
"WAL num_facts exceeds platform usize",
ErrorCategory::Wal,
),
(
ErrorCode::Wal006,
"WAL-006",
"failed to delete WAL file {}: {}",
ErrorCategory::Wal,
),
];

pub(crate) fn registry_entry(code: ErrorCode) -> (&'static str, &'static str, ErrorCategory) {
let entry = REGISTRY.iter().find(|(c, ..)| *c == code);
Expand Down Expand Up @@ -79,7 +123,6 @@ pub(crate) fn registry_entry(code: ErrorCode) -> (&'static str, &'static str, Er
///
/// Not `std::fmt`-based: the template is runtime data pulled from [`REGISTRY`],
/// and `format!` requires a compile-time string literal.
#[allow(dead_code)] // unused until the PRS category PR wires up real call sites
pub(crate) fn format_template(template: &str, args: &[&dyn fmt::Display]) -> String {
let placeholder_count = template.matches("{}").count();
debug_assert_eq!(
Expand Down Expand Up @@ -115,7 +158,6 @@ pub(crate) struct CodedError {
}

impl CodedError {
#[allow(dead_code)] // unused until the PRS category PR wires up real call sites
pub(crate) fn new(code: ErrorCode, args: &[&dyn fmt::Display]) -> Self {
let (_, template, _) = registry_entry(code);
CodedError {
Expand All @@ -142,7 +184,6 @@ impl std::error::Error for CodedError {}
/// `bail_coded!(ErrorCode::Prs001)` for a static message, or
/// `bail_coded!(ErrorCode::Prs002, ch)` to fill the template's `{}`
/// placeholders positionally. A drop-in replacement for `anyhow::bail!`.
#[allow(unused_macros)] // unused until the PRS category PR wires up real call sites
macro_rules! bail_coded {
($code:expr $(,)?) => {
return Err(::anyhow::Error::new($crate::error::CodedError::new($code, &[])))
Expand All @@ -159,7 +200,6 @@ macro_rules! bail_coded {
///
/// For use where an `anyhow::Error` value is needed directly, e.g.
/// `.ok_or_else(|| err_coded!(ErrorCode::Api009))`.
#[allow(unused_macros)] // unused until the PRS category PR wires up real call sites
macro_rules! err_coded {
($code:expr $(,)?) => {
::anyhow::Error::new($crate::error::CodedError::new($code, &[]))
Expand All @@ -172,9 +212,7 @@ macro_rules! err_coded {
};
}

#[allow(unused_imports)] // unused until the PRS category PR wires up real call sites
pub(crate) use bail_coded;
#[allow(unused_imports)] // unused until the PRS category PR wires up real call sites
pub(crate) use err_coded;

/// The error type returned from Minigraf's public API.
Expand Down Expand Up @@ -299,6 +337,12 @@ mod tests {

match ErrorCode::Int000 {
ErrorCode::Int000 => assert_has_registry_entry(ErrorCode::Int000),
ErrorCode::Wal001 => assert_has_registry_entry(ErrorCode::Wal001),
ErrorCode::Wal002 => assert_has_registry_entry(ErrorCode::Wal002),
ErrorCode::Wal003 => assert_has_registry_entry(ErrorCode::Wal003),
ErrorCode::Wal004 => assert_has_registry_entry(ErrorCode::Wal004),
ErrorCode::Wal005 => assert_has_registry_entry(ErrorCode::Wal005),
ErrorCode::Wal006 => assert_has_registry_entry(ErrorCode::Wal006),
}
}

Expand Down
115 changes: 92 additions & 23 deletions src/wal.rs
< CA80 td id="diff-5895a700b35c0010817a1e1167e97cec62f252a0c66ff1b787699221ea50f374R688" data-line-number="688" class="blob-num blob-num-addition js-linkable-line-number js-blob-rnum">
Original file line number Diff line number Diff line change
Expand Up @@ -20,9 +20,10 @@
//! ```

use crate::db::SyncMode;
use crate::error::{ErrorCode, bail_coded, err_coded};
use crate::graph::types::Fact;
use crate::storage::packed_pages::MAX_FACT_BYTES;
use anyhow::{Result, bail};
use anyhow::Result;
use std::fs::{File, OpenOptions};
use std::io::{self, Read, Seek, SeekFrom, Write};
use std::path::Path;
Expand Down Expand Up @@ -50,15 +51,11 @@ fn validate_wal_header(file: &mut File) -> Result<()> {
file.read_exact(&mut buf)?;

if buf[0..4] != WAL_MAGIC {
bail!("Invalid WAL magic number: not a .wal file");
bail_coded!(ErrorCode::Wal001);
}
let version = u32::from_le_bytes([buf[4], buf[5], buf[6], buf[7]]);
if version != WAL_VERSION {
bail!(
"Unsupported WAL version: {} (expected {})",
version,
WAL_VERSION
);
bail_coded!(ErrorCode::Wal002, version, WAL_VERSION);
}
Ok(())
}
Expand Down Expand Up @@ -100,20 +97,10 @@ fn serialize_entry(tx_count: u64, facts: &[Fact]) -> Result<Vec<u8>> {
for fact in facts {
let fact_bytes = postcard::to_allocvec(fact)?;
if fact_bytes.len() > MAX_FACT_BYTES {
bail!(
"Fact serialised size {} bytes exceeds maximum {} bytes. \
Store large payloads externally and reference them with a \
Value::String URL/path or Value::Ref entity ID.",
fact_bytes.len(),
MAX_FACT_BYTES
);
bail_coded!(ErrorCode::Wal003, fact_bytes.len(), MAX_FACT_BYTES);
}
let fact_len = u32::try_from(fact_bytes.len()).map_err(|_| {
anyhow::anyhow!(
"fact serialised size {} exceeds u32 range",
fact_bytes.len()
)
})?;
let fact_len = u32::try_from(fact_bytes.len())
.map_err(|_| err_coded!(ErrorCode::Wal004, fact_bytes.len()))?;
payload.extend_from_slice(&fact_len.to_le_bytes());
payload.extend_from_slice(&fact_bytes);
}
Expand Down Expand Up @@ -222,8 +209,8 @@ impl WalWriter {
}
}
}
Err(anyhow::anyhow!(
"failed to delete WAL file {}: {}",
Err(err_coded!(
ErrorCode::Wal006,
path.display(),
last_err
.map(|e| e.to_string())
Expand Down Expand Up @@ -288,7 +275,7 @@ impl WalReader {
break; // truncated
}
let num_facts = usize::try_from(u64::from_le_bytes(num_facts_buf))
.map_err(|_| anyhow::anyhow!("WAL num_facts exceeds platform usize"))?;
.map_err(|_| err_coded!(ErrorCode::Wal005))?;

// Sanity cap: no legitimate entry has more than 1M facts
const MAX_FACTS_PER_ENTRY: usize = 1_000_000;
Expand Down Expand Up @@ -671,4 +658,86 @@ mod tests {
"Should fail or return empty on corrupted large fact"
);
}

// ── #360: WAL-0xx error code regression tests ──────────────────────────
//
// These assert the exact code carried by `MinigrafError::from(err)` for
// each migrated call site, complementing `tests/error_codes_wal_test.rs`
// (which exercises the same WAL-001/002/003 paths through the public
// `Minigraf` API). WAL-004 and WAL-005 are documented as "practically
// unreachable" (WAL-004 needs a ~4 GB single fact; WAL-005 can never
// fail on a 64-bit target, where `usize` and `u64` are the same width)
// and have no dedicated trigger test here for that reason — they are
// still covered by `error::tests::registry_is_a_subset_of_error_reference_doc`
// and `error::tests::every_error_code_variant_has_a_registry_entry`.

#[test]
fn test_wal_bad_magic_error_code_is_wal_001() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("bad.wal");
std::fs::write(&path, b"XXXX\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00").unwrap();

let result = WalReader::open(&path);
let err = match result {
Err(e) => e,
Ok(_) => panic!("bad magic should be rejected"),
};
let minigraf_err: crate::error::MinigrafError = err.into();
assert_eq!(minigraf_err.code(), "WAL-001");
}

#[test]
fn test_wal_bad_version_error_code_is_wal_002() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("badversion.wal");

let mut header = [0u8; WAL_HEADER_SIZE];
header[0..4].copy_from_slice(&WAL_MAGIC);
header[4..8].copy_from_slice(&99u32.to_le_bytes());
std::fs::write(&path, header).unwrap();

let result = WalReader::open(&path);
let err = match result {
Err(e) => e,
Ok(_) => panic!("bad version should be rejected"),
};
let minigraf_err: crate::error::MinigrafError = err.into();
assert_eq!(minigraf_err.code(), "WAL-002");
}

#[test]
fn test_wal_fact_size_limit_error_code_is_wal_003() {
use crate::graph::types::{Fact, Value};
use uuid::Uuid;

let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("oversized.wal");
let mut writer = WalWriter::open_or_create(&path, SyncMode::Full).unwrap();

let entity = Uuid::new_v4();
let huge_string = "a".repeat(MAX_FACT_BYTES + 1000);
let fact = Fact::new(entity, ":test".to_string(), Value::String(huge_string), 1);

let result = writer.append_entry(1, &[fact]);
let err = result.expect_err("oversized fact should be rejected");
let minigraf_err: crate::error::MinigrafError = err.into();
assert_eq!(minigraf_err.code(), "WAL-003");
}

#[test]
fn test_wal_delete_directory_error_code_is_wal_006() {
let dir = tempfile::tempdir().unwrap();
// A directory at the WAL path, rather than a file, makes
// `std::fs::remove_file` fail regardless of permissions -- portable
// across platforms without needing a permission-denial trick.
let path = dir.path().join("not-a-file.wal");
std::fs::create_dir(&path).unwrap();

let result = WalWriter::delete_file(&path);
let err = result.expect_err("deleting a directory should fail");
let minigraf_err: crate::error::MinigrafError = err.into();
assert_eq!(minigraf_err.code(), "WAL-006");

std::fs::remove_dir(&path).unwrap();
}
}
Loading
Loading
0