-
Notifications
You must be signed in to change notification settings - Fork 1
feat(echo-cas): content-addressed blob store (Phase 1) #263
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,17 @@ | ||
| # SPDX-License-Identifier: Apache-2.0 | ||
| # © James Ross Ω FLYING•ROBOTS <https://github.com/flyingrobots> | ||
| [package] | ||
| name = "echo-cas" | ||
| version = "0.1.0" | ||
| edition = "2021" | ||
| license.workspace = true | ||
| repository.workspace = true | ||
| rust-version.workspace = true | ||
| description = "Content-addressed blob store for Echo" | ||
| readme = "README.md" | ||
| keywords = ["echo", "cas", "content-addressed"] | ||
| categories = ["data-structures"] | ||
|
|
||
| [dependencies] | ||
| blake3 = "1.5" | ||
| thiserror = "2" |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,6 @@ | ||
| <!-- SPDX-License-Identifier: Apache-2.0 OR MIND-UCAL-1.0 --> | ||
| <!-- © James Ross Ω FLYING•ROBOTS <https://github.com/flyingrobots> --> | ||
|
|
||
| # echo-cas | ||
|
|
||
| Content-addressed blob store for Echo. | ||
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,145 @@ | ||||||||||||||||||||||||||||||
| // SPDX-License-Identifier: Apache-2.0 | ||||||||||||||||||||||||||||||
| // © James Ross Ω FLYING•ROBOTS <https://github.com/flyingrobots> | ||||||||||||||||||||||||||||||
| //! Content-addressed blob store for Echo. | ||||||||||||||||||||||||||||||
| //! | ||||||||||||||||||||||||||||||
| //! `echo-cas` provides a [`BlobStore`] trait for content-addressed storage keyed by | ||||||||||||||||||||||||||||||
| //! BLAKE3 hash. Phase 1 ships [`MemoryTier`] — sufficient for the in-browser website | ||||||||||||||||||||||||||||||
| //! demo. Disk/cold tiers, wire protocol, and GC come in Phase 3. | ||||||||||||||||||||||||||||||
| //! | ||||||||||||||||||||||||||||||
| //! # Hash Domain Policy | ||||||||||||||||||||||||||||||
| //! | ||||||||||||||||||||||||||||||
| //! CAS hash is content-only: `BLAKE3(bytes)` with no domain prefix. Two blobs with | ||||||||||||||||||||||||||||||
| //! identical bytes are the same CAS blob regardless of semantic type. This is by | ||||||||||||||||||||||||||||||
| //! design — deduplication is a feature, not a bug. Domain separation happens at the | ||||||||||||||||||||||||||||||
| //! typed-reference layer above (`TypedRef`: `schema_hash` + `type_id` + `layout_hash` + | ||||||||||||||||||||||||||||||
| //! `value_hash`). | ||||||||||||||||||||||||||||||
| //! | ||||||||||||||||||||||||||||||
| //! # Determinism Invariant | ||||||||||||||||||||||||||||||
| //! | ||||||||||||||||||||||||||||||
| //! No public API exposes store iteration order. CAS determinism is content-level | ||||||||||||||||||||||||||||||
| //! (same bytes → same hash), not collection-level. Any future `list`/`iter` API must | ||||||||||||||||||||||||||||||
| //! return results sorted by [`BlobHash`]. | ||||||||||||||||||||||||||||||
| #![forbid(unsafe_code)] | ||||||||||||||||||||||||||||||
| #![deny(missing_docs, rust_2018_idioms, unused_must_use)] | ||||||||||||||||||||||||||||||
| #![deny( | ||||||||||||||||||||||||||||||
| clippy::all, | ||||||||||||||||||||||||||||||
| clippy::pedantic, | ||||||||||||||||||||||||||||||
| clippy::nursery, | ||||||||||||||||||||||||||||||
| clippy::cargo, | ||||||||||||||||||||||||||||||
| clippy::unwrap_used, | ||||||||||||||||||||||||||||||
| clippy::expect_used, | ||||||||||||||||||||||||||||||
| clippy::panic, | ||||||||||||||||||||||||||||||
| clippy::todo, | ||||||||||||||||||||||||||||||
| clippy::unimplemented, | ||||||||||||||||||||||||||||||
| clippy::dbg_macro, | ||||||||||||||||||||||||||||||
| clippy::print_stdout, | ||||||||||||||||||||||||||||||
| clippy::print_stderr | ||||||||||||||||||||||||||||||
| )] | ||||||||||||||||||||||||||||||
| #![allow( | ||||||||||||||||||||||||||||||
| clippy::must_use_candidate, | ||||||||||||||||||||||||||||||
| clippy::return_self_not_must_use, | ||||||||||||||||||||||||||||||
| clippy::unreadable_literal, | ||||||||||||||||||||||||||||||
| clippy::missing_const_for_fn, | ||||||||||||||||||||||||||||||
| clippy::suboptimal_flops, | ||||||||||||||||||||||||||||||
| clippy::redundant_pub_crate, | ||||||||||||||||||||||||||||||
| clippy::many_single_char_names, | ||||||||||||||||||||||||||||||
| clippy::module_name_repetitions, | ||||||||||||||||||||||||||||||
| clippy::use_self | ||||||||||||||||||||||||||||||
| )] | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| mod memory; | ||||||||||||||||||||||||||||||
| pub use memory::MemoryTier; | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| use std::sync::Arc; | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| /// A 32-byte BLAKE3 content hash. | ||||||||||||||||||||||||||||||
| /// | ||||||||||||||||||||||||||||||
| /// Thin newtype over `[u8; 32]` following the `NodeId`/`TypeId` pattern from | ||||||||||||||||||||||||||||||
| /// `warp-core`. The inner bytes are public for zero-cost access; the `Display` | ||||||||||||||||||||||||||||||
| /// impl renders lowercase hex for logging and error messages. | ||||||||||||||||||||||||||||||
| #[repr(transparent)] | ||||||||||||||||||||||||||||||
| #[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Debug)] | ||||||||||||||||||||||||||||||
| pub struct BlobHash(pub [u8; 32]); | ||||||||||||||||||||||||||||||
|
Comment on lines
+60
to
+62
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🛠️ Refactor suggestion | 🟠 Major
Anyone can write Consider making the inner field private and offering:
You can still expose If this is a deliberate "value type, no invariants" choice (like 🤖 Prompt for AI Agents |
||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| impl BlobHash { | ||||||||||||||||||||||||||||||
| /// View the hash as a byte slice. | ||||||||||||||||||||||||||||||
| pub fn as_bytes(&self) -> &[u8; 32] { | ||||||||||||||||||||||||||||||
| &self.0 | ||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| impl std::fmt::Display for BlobHash { | ||||||||||||||||||||||||||||||
| fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { | ||||||||||||||||||||||||||||||
| for byte in &self.0 { | ||||||||||||||||||||||||||||||
| write!(f, "{byte:02x}")?; | ||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||
| Ok(()) | ||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||
|
Comment on lines
+71
to
+78
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🧹 Nitpick | 🔵 Trivial
Each A single-pass approach: Proposed alternative impl std::fmt::Display for BlobHash {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
- for byte in &self.0 {
- write!(f, "{byte:02x}")?;
- }
- Ok(())
+ let mut buf = [0u8; 64];
+ for (i, &b) in self.0.iter().enumerate() {
+ let hi = b >> 4;
+ let lo = b & 0x0F;
+ buf[i * 2] = HEX_CHARS[hi as usize];
+ buf[i * 2 + 1] = HEX_CHARS[lo as usize];
+ }
+ // SAFETY: hex chars are always valid UTF-8.
+ f.write_str(unsafe { std::str::from_utf8_unchecked(&buf) })
}
}
+
+const HEX_CHARS: &[u8; 16] = b"0123456789abcdef";...but you Safe single-allocation approach impl std::fmt::Display for BlobHash {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
- for byte in &self.0 {
- write!(f, "{byte:02x}")?;
- }
- Ok(())
+ let hex: String = self.0.iter().map(|b| format!("{b:02x}")).collect();
+ f.write_str(&hex)
}
}Not a hill to die on for Phase 1, but flag it for when you're profiling. 📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| /// Compute the BLAKE3 content hash of `bytes`. | ||||||||||||||||||||||||||||||
| /// | ||||||||||||||||||||||||||||||
| /// No domain prefix — the content IS the identity. See module-level docs for | ||||||||||||||||||||||||||||||
| /// hash domain policy. | ||||||||||||||||||||||||||||||
| pub fn blob_hash(bytes: &[u8]) -> BlobHash { | ||||||||||||||||||||||||||||||
| let hash = blake3::hash(bytes); | ||||||||||||||||||||||||||||||
| BlobHash(*hash.as_bytes()) | ||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| /// Errors that can occur during CAS operations. | ||||||||||||||||||||||||||||||
| #[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)] | ||||||||||||||||||||||||||||||
| pub enum CasError { | ||||||||||||||||||||||||||||||
| /// Blob bytes did not match the declared hash. | ||||||||||||||||||||||||||||||
| #[error("[CAS_HASH_MISMATCH] expected {expected}, computed {computed}")] | ||||||||||||||||||||||||||||||
| HashMismatch { | ||||||||||||||||||||||||||||||
| /// The hash that was declared/expected. | ||||||||||||||||||||||||||||||
| expected: BlobHash, | ||||||||||||||||||||||||||||||
| /// The hash actually computed from the bytes. | ||||||||||||||||||||||||||||||
| computed: BlobHash, | ||||||||||||||||||||||||||||||
| }, | ||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| /// Content-addressed blob store. | ||||||||||||||||||||||||||||||
| /// | ||||||||||||||||||||||||||||||
| /// Implementations store opaque byte blobs keyed by their BLAKE3 hash. The trait | ||||||||||||||||||||||||||||||
| /// is intentionally synchronous and object-safe for Phase 1. Async methods will be | ||||||||||||||||||||||||||||||
| /// added (likely as a separate `AsyncBlobStore` trait) when disk/network tiers | ||||||||||||||||||||||||||||||
| /// demand it. | ||||||||||||||||||||||||||||||
| /// | ||||||||||||||||||||||||||||||
| /// # Absence Semantics | ||||||||||||||||||||||||||||||
| /// | ||||||||||||||||||||||||||||||
| /// [`get`](BlobStore::get) returns `None` for missing blobs — this is **not** an | ||||||||||||||||||||||||||||||
| /// error. CAS is a lookup table: missing blobs are expected (not-yet-fetched, | ||||||||||||||||||||||||||||||
| /// GC'd, never stored). Error variants are reserved for integrity violations. | ||||||||||||||||||||||||||||||
| pub trait BlobStore { | ||||||||||||||||||||||||||||||
| /// Compute hash and store. Returns the content hash. | ||||||||||||||||||||||||||||||
| fn put(&mut self, bytes: &[u8]) -> BlobHash; | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| /// Store with a pre-computed hash. Rejects if `BLAKE3(bytes) != expected`. | ||||||||||||||||||||||||||||||
| /// | ||||||||||||||||||||||||||||||
| /// On mismatch the store is unchanged and a [`CasError::HashMismatch`] is | ||||||||||||||||||||||||||||||
| /// returned. This method exists for receivers of `WANT`/`PROVIDE` messages | ||||||||||||||||||||||||||||||
| /// who already possess the hash. | ||||||||||||||||||||||||||||||
| /// | ||||||||||||||||||||||||||||||
| /// # Errors | ||||||||||||||||||||||||||||||
| /// | ||||||||||||||||||||||||||||||
| /// Returns [`CasError::HashMismatch`] if the computed hash differs from | ||||||||||||||||||||||||||||||
| /// `expected`. | ||||||||||||||||||||||||||||||
| fn put_verified(&mut self, expected: BlobHash, bytes: &[u8]) -> Result<(), CasError>; | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| /// Retrieve blob by hash. Returns `None` if not stored — absence is not an | ||||||||||||||||||||||||||||||
| /// error. | ||||||||||||||||||||||||||||||
| fn get(&self, hash: &BlobHash) -> Option<Arc<[u8]>>; | ||||||||||||||||||||||||||||||
|
Comment on lines
+130
to
+132
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🧹 Nitpick | 🔵 Trivial
When DiskTier or ColdTier arrive in Phase 3, they may want to return Options to future-proof:
Option 3 is the minimum viable action right now. Just don't let this silently calcify. 🤖 Prompt for AI Agents |
||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| /// Check existence without retrieving. | ||||||||||||||||||||||||||||||
| fn has(&self, hash: &BlobHash) -> bool; | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| /// Mark hash as a retention root. | ||||||||||||||||||||||||||||||
| /// | ||||||||||||||||||||||||||||||
| /// Legal on missing blobs (pre-pin intent). Pin semantics are set-based (not | ||||||||||||||||||||||||||||||
| /// reference-counted) in Phase 1. | ||||||||||||||||||||||||||||||
| fn pin(&mut self, hash: &BlobHash); | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| /// Remove retention root. No-op if not pinned or not stored. | ||||||||||||||||||||||||||||||
| fn unpin(&mut self, hash: &BlobHash); | ||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
License mismatch: README declares
Apache-2.0 OR MIND-UCAL-1.0but Cargo.toml inherits workspace licenseApache-2.0only.This is a compliance inconsistency.
Cargo.toml→license.workspace = true→ root workspace declareslicense = "Apache-2.0". This README introduces a second license (MIND-UCAL-1.0) that doesn't appear anywhere in the manifest chain. Either:MIND-UCAL-1.0from the README header.Shipping contradictory license declarations is a legal landmine.
🤖 Prompt for AI Agents