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
4 changes: 4 additions & 0 deletions crates/lance-context-api/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,8 @@ pub trait RolloutStoreApi {

#[derive(Debug, Serialize, Deserialize)]
pub struct CreateContextRequest {
/// Portable dataset name: 1-128 ASCII characters matching
/// `[A-Za-z0-9_][A-Za-z0-9._-]*`; `_registry` and `_stats` are reserved.
pub name: String,
#[serde(default)]
pub storage_options: Option<std::collections::HashMap<String, String>>,
Expand Down Expand Up @@ -571,6 +573,8 @@ pub struct CompactStatsResponse {

#[derive(Debug, Serialize, Deserialize)]
pub struct CreateRolloutStoreRequest {
/// Portable dataset name: 1-128 ASCII characters matching
/// `[A-Za-z0-9_][A-Za-z0-9._-]*`; `_registry` and `_stats` are reserved.
pub name: String,
#[serde(default)]
pub storage_options: Option<std::collections::HashMap<String, String>>,
Expand Down
2 changes: 2 additions & 0 deletions crates/lance-context-core/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ mod registry;
mod rollout;
mod rollout_store;
pub mod serde;
mod storage;
mod store;

pub use context::{Context, ContextEntry, Snapshot};
Expand All @@ -35,6 +36,7 @@ pub use record::{
pub use registry::{RegistryEntry, RolloutRegistry};
pub use rollout::{RolloutRecord, ROLE_ARTIFACT, ROLE_ASSISTANT, ROLE_GRADE, ROLE_TOOL};
pub use rollout_store::{rollout_schema, RolloutObservation, RolloutStore, RolloutStoreOptions};
pub use storage::{create_local_dir_if_needed, join_uri, validate_store_name, MAX_STORE_NAME_LEN};
pub use store::{
CompactionConfig, CompactionStats, ContextStore, ContextStoreOptions, DistanceMetric,
IdIndexType, ReadProjection,
Expand Down
9 changes: 1 addition & 8 deletions crates/lance-context-core/src/namespace.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ use lance::io::{ObjectStoreParams, StorageOptionsAccessor};
use lance::{Error as LanceError, Result as LanceResult};
use uuid::Uuid;

use crate::storage::join_uri;
use crate::store::{ContextStore, ContextStoreOptions};

const MANIFEST_TABLE_NAME: &str = "__manifest";
Expand Down Expand Up @@ -432,14 +433,6 @@ fn normalize_root_uri(root_uri: String) -> LanceResult<String> {
Ok(root_uri.trim_end_matches('/').to_string())
}

fn join_uri(root_uri: &str, child: &str) -> String {
if root_uri == "/" {
format!("/{child}")
} else {
format!("{root_uri}/{child}")
}
}

fn invalid_input(message: impl Into<String>) -> LanceError {
LanceError::from(ArrowError::InvalidArgumentError(message.into()))
}
Expand Down
140 changes: 140 additions & 0 deletions crates/lance-context-core/src/storage.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
//! Shared helpers for dataset roots that may be local paths or object-store URIs.

use std::io;
use std::path::Path;

/// Maximum accepted length for a context or rollout store name.
pub const MAX_STORE_NAME_LEN: usize = 128;

/// Join one dataset child onto a local directory or object-store URI.
#[must_use]
pub fn join_uri(base: &str, child: &str) -> String {
if has_uri_scheme(base) {
format!("{}/{}", base.trim_end_matches('/'), child)
} else {
Path::new(base).join(child).to_string_lossy().to_string()
}
}

/// Create `data_dir` when it is a local filesystem path.
///
/// Object-store roots such as `s3://`, `gs://`, and `az://` must never be
/// passed to `std::fs`, where they would be interpreted as relative paths.
pub fn create_local_dir_if_needed(data_dir: &str) -> io::Result<()> {
if has_uri_scheme(data_dir) {
return Ok(());
}
std::fs::create_dir_all(data_dir)
}

/// Validate a logical context/rollout store name before using it in a URI.
///
/// Names are intentionally limited to a portable single-segment form so they
/// cannot escape `data_dir` or acquire platform-specific path semantics.
pub fn validate_store_name(name: &str) -> Result<(), String> {
if name.is_empty() {
return Err("store name must not be empty".to_string());
}

let mut chars = name.chars();
let first = chars.next().expect("non-empty checked above");
if !(first.is_ascii_alphanumeric() || first == '_') {
return Err("store name must start with an ASCII letter, digit, or '_'".to_string());
}
if !chars.all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_' | '.')) {
return Err(
"store name may contain only ASCII letters, digits, '.', '-', and '_'".to_string(),
);
}
if matches!(name, "_registry" | "_stats") {
return Err(format!("store name '{name}' is reserved"));
}
if name.len() > MAX_STORE_NAME_LEN {
return Err(format!(
"store name must be at most {MAX_STORE_NAME_LEN} characters"
));
}
Ok(())
}

fn has_uri_scheme(value: &str) -> bool {
let Some((scheme, _)) = value.split_once("://") else {
return false;
};
let mut chars = scheme.chars();
matches!(chars.next(), Some(ch) if ch.is_ascii_alphabetic())
&& chars.all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '+' | '-' | '.'))
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn joins_local_paths_and_object_store_uris() {
assert_eq!(
join_uri("/data/rollouts", "exp.rollout.lance"),
"/data/rollouts/exp.rollout.lance"
);
assert_eq!(
join_uri("relative/data/", "exp.rollout.lance"),
"relative/data/exp.rollout.lance"
);
assert_eq!(
join_uri("s3://bucket/prefix/", "exp.rollout.lance"),
"s3://bucket/prefix/exp.rollout.lance"
);
assert_eq!(
join_uri("gs://bucket", "_registry.rollout.lance"),
"gs://bucket/_registry.rollout.lance"
);
}

#[test]
fn recognizes_uri_schemes_without_treating_local_paths_as_uris() {
assert!(has_uri_scheme("s3://bucket/prefix"));
assert!(has_uri_scheme("az://container/prefix"));
assert!(has_uri_scheme("file:///tmp/data"));
assert!(!has_uri_scheme("/tmp/data"));
assert!(!has_uri_scheme("./data"));
assert!(!has_uri_scheme("experiment:data"));
}

#[test]
fn creates_only_local_directories() {
let root = tempfile::TempDir::new().unwrap();
let nested = root.path().join("nested/data");
create_local_dir_if_needed(nested.to_str().unwrap()).unwrap();
assert!(nested.is_dir());

create_local_dir_if_needed("s3://bucket/prefix").unwrap();
create_local_dir_if_needed("gs://bucket/prefix").unwrap();
}

#[test]
fn validates_portable_single_segment_store_names() {
for name in [
"exp-alpha",
"conntest_lianghongxu",
"run.2026_07-15",
"_private",
"7",
] {
validate_store_name(name).unwrap();
}

for name in [
"",
"../outside",
"nested/store",
r"nested\store",
"_registry",
"has space",
"experiment:name",
] {
assert!(validate_store_name(name).is_err(), "{name}");
}
assert!(validate_store_name(&"a".repeat(MAX_STORE_NAME_LEN)).is_ok());
assert!(validate_store_name(&"a".repeat(MAX_STORE_NAME_LEN + 1)).is_err());
}
}
24 changes: 1 addition & 23 deletions crates/lance-context-master/src/discovery.rs
Original file line number Diff line number Diff line change
@@ -1,9 +1,7 @@
//! Startup discovery for rollout datasets created before the registry existed.

use std::path::Path;

use lance::io::ObjectStore;
use lance_context_core::RolloutRegistry;
use lance_context_core::{join_uri, RolloutRegistry};

const ROLLOUT_SUFFIX: &str = ".rollout.lance";

Expand Down Expand Up @@ -31,14 +29,6 @@ pub async fn backfill_registry(
registry.insert_missing(&entries).await
}

fn join_uri(base: &str, child: &str) -> String {
if base.contains("://") {
format!("{}/{}", base.trim_end_matches('/'), child)
} else {
Path::new(base).join(child).to_string_lossy().to_string()
}
}

#[cfg(test)]
mod tests {
use super::*;
Expand Down Expand Up @@ -95,16 +85,4 @@ mod tests {
names.sort();
assert_eq!(names, vec!["existing", "legacy"]);
}

#[test]
fn joins_local_paths_and_object_store_uris() {
assert_eq!(
join_uri("/data/rollouts", "exp.rollout.lance"),
"/data/rollouts/exp.rollout.lance"
);
assert_eq!(
join_uri("s3://bucket/prefix/", "exp.rollout.lance"),
"s3://bucket/prefix/exp.rollout.lance"
);
}
}
5 changes: 3 additions & 2 deletions crates/lance-context-master/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ use tower_http::services::ServeDir;
use tower_http::trace::TraceLayer;
use tracing_subscriber::EnvFilter;

use lance_context_core::create_local_dir_if_needed;
use lance_context_master::config::MasterConfig;
use lance_context_master::routes;
use lance_context_master::scanner;
Expand All @@ -21,9 +22,9 @@ async fn main() {
let config = MasterConfig::parse();
let addr = format!("{}:{}", config.host, config.port);

if let Err(e) = std::fs::create_dir_all(&config.data_dir) {
if let Err(e) = create_local_dir_if_needed(&config.data_dir) {
tracing::error!(
"Failed to create data directory '{}': {}",
"Failed to create local data directory '{}': {}",
config.data_dir,
e
);
Expand Down
24 changes: 7 additions & 17 deletions crates/lance-context-master/src/state.rs
Original file line number Diff line number Diff line change
@@ -1,11 +1,10 @@
//! Shared master state: durable registry + stats table.

use std::collections::HashMap;
use std::path::PathBuf;
use std::sync::Arc;

use lance_context_api::CompactJobStatus;
use lance_context_core::RolloutRegistry;
use lance_context_core::{join_uri, RolloutRegistry};
use tokio::sync::{mpsc, Mutex, RwLock};

use crate::config::MasterConfig;
Expand All @@ -25,7 +24,7 @@ pub struct MasterState {
/// Periodically-refreshed per-experiment metrics (master-owned).
pub stats: Mutex<StatsStore>,
/// Shared data directory / object-store prefix.
pub base_path: PathBuf,
pub base_uri: String,
/// Effective configuration.
pub config: MasterConfig,
/// Enqueues an experiment name for (serial) compaction. Both automatic
Expand All @@ -41,15 +40,9 @@ pub struct MasterState {
impl MasterState {
/// Open (or create) the registry and stats datasets under `data_dir`.
pub async fn new(config: MasterConfig) -> lance::Result<Arc<Self>> {
let base_path = PathBuf::from(&config.data_dir);
let registry_uri = base_path
.join("_registry.rollout.lance")
.to_string_lossy()
.to_string();
let stats_uri = base_path
.join("_stats.rollout.lance")
.to_string_lossy()
.to_string();
let base_uri = config.data_dir.clone();
let registry_uri = join_uri(&base_uri, "_registry.rollout.lance");
let stats_uri = join_uri(&base_uri, "_stats.rollout.lance");
let mut registry = RolloutRegistry::open_or_create(&registry_uri, None).await?;
let backfilled = discovery::backfill_registry(&config.data_dir, &mut registry).await?;
if backfilled > 0 {
Expand All @@ -63,7 +56,7 @@ impl MasterState {
Ok(Arc::new(Self {
registry: RwLock::new(registry),
stats: Mutex::new(stats),
base_path,
base_uri,
config,
compact_tx,
compact_rx: Mutex::new(Some(compact_rx)),
Expand All @@ -74,10 +67,7 @@ impl MasterState {
/// Physical rollout dataset URI for `name`, matching the data-plane's
/// `rollout_uri` convention (`{name}.rollout.lance`).
pub fn rollout_uri(&self, name: &str) -> String {
self.base_path
.join(format!("{}.rollout.lance", name))
.to_string_lossy()
.to_string()
join_uri(&self.base_uri, &format!("{}.rollout.lance", name))
}
}

Expand Down
6 changes: 4 additions & 2 deletions crates/lance-context-server/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ use tower_http::cors::CorsLayer;
use tower_http::trace::TraceLayer;
use tracing_subscriber::EnvFilter;

use lance_context_core::create_local_dir_if_needed;

use crate::config::ServerConfig;
use crate::state::AppState;

Expand All @@ -23,9 +25,9 @@ async fn main() {
let config = ServerConfig::parse();
let addr = format!("{}:{}", config.host, config.port);

if let Err(e) = std::fs::create_dir_all(&config.data_dir) {
if let Err(e) = create_local_dir_if_needed(&config.data_dir) {
tracing::error!(
"Failed to create data directory '{}': {}",
"Failed to create local data directory '{}': {}",
config.data_dir,
e
);
Expand Down
Loading
Loading