refactor(rust): move remote module unit tests into separate files

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BgkiPYS2eTmAWVyqn6KBnN
This commit is contained in:
Claude
2026-10-07 06:23:00 +00:00
parent bbc4aebe55
commit 6dde01de33
34 changed files with 18673 additions and 18672 deletions
+1 -752
View File
@@ -1146,755 +1146,4 @@ pub mod test_utils {
}
#[cfg(test)]
mod tests {
/// A configuration naming any other delimiter is refused where it was
/// written, rather than producing identifiers no service splits the way the
/// caller meant.
#[test]
fn test_a_delimiter_other_than_the_supported_one_is_refused() {
for delimiter in ["/", "?", "#", "%", "", ".", "..", "-", "_", "|", "::", "$$"] {
let error = super::validate_id_delimiter(delimiter)
.expect_err("only the supported delimiter may be configured");
assert!(
error.to_string().contains("id_delimiter"),
"{delimiter:?}: {error}"
);
}
super::validate_id_delimiter(super::ID_DELIMITER).unwrap();
}
/// Leaving it unset is how nearly every caller reaches the same delimiter.
#[test]
fn test_an_unset_delimiter_is_the_supported_one() {
super::ClientConfig::default().validate().unwrap();
super::ClientConfig {
id_delimiter: Some(super::ID_DELIMITER.to_string()),
..Default::default()
}
.validate()
.unwrap();
super::ClientConfig {
id_delimiter: Some("-".to_string()),
..Default::default()
}
.validate()
.expect_err("a configured delimiter other than the supported one must be refused");
}
use super::*;
use serial_test::serial;
use std::time::Duration;
// Serializes the env-var-mutating tests below: cargo test runs tests in
// parallel, but several of these tests read and write the same process-
// global env vars (`LANCEDB_USER_ID*`), so they would race without this.
static ENV_MUTEX: std::sync::Mutex<()> = std::sync::Mutex::new(());
fn lock_env() -> std::sync::MutexGuard<'static, ()> {
ENV_MUTEX.lock().unwrap_or_else(|e| e.into_inner())
}
#[test]
fn test_parse_catalog_database_uri() {
let parsed = parse_db_url("db://team%2Fsearch").unwrap();
assert_eq!(parsed.db_name, "team/search");
assert!(parsed.db_prefix.is_none());
let parsed = parse_db_url("db://db/prefix").unwrap();
assert_eq!(parsed.db_name, "db");
assert_eq!(parsed.db_prefix.as_deref(), Some("prefix"));
}
#[test]
fn test_timeout_config_default() {
let config = TimeoutConfig::default();
assert!(config.timeout.is_none());
assert!(config.connect_timeout.is_none());
assert!(config.read_timeout.is_none());
assert!(config.pool_idle_timeout.is_none());
}
#[test]
fn test_timeout_config_with_overall_timeout() {
let config = TimeoutConfig {
timeout: Some(Duration::from_secs(60)),
connect_timeout: Some(Duration::from_secs(10)),
read_timeout: Some(Duration::from_secs(30)),
pool_idle_timeout: Some(Duration::from_secs(300)),
};
assert_eq!(config.timeout, Some(Duration::from_secs(60)));
assert_eq!(config.connect_timeout, Some(Duration::from_secs(10)));
assert_eq!(config.read_timeout, Some(Duration::from_secs(30)));
assert_eq!(config.pool_idle_timeout, Some(Duration::from_secs(300)));
}
#[test]
fn test_client_config_with_timeout() {
let timeout_config = TimeoutConfig {
timeout: Some(Duration::from_secs(120)),
..Default::default()
};
let client_config = ClientConfig {
timeout_config,
..Default::default()
};
assert_eq!(
client_config.timeout_config.timeout,
Some(Duration::from_secs(120))
);
}
#[test]
fn test_tls_config_default() {
let config = TlsConfig::default();
assert!(config.cert_file.is_none());
assert!(config.key_file.is_none());
assert!(config.ssl_ca_cert.is_none());
assert!(config.assert_hostname);
}
#[test]
fn test_tls_config_with_mtls() {
let tls_config = TlsConfig {
cert_file: Some("/path/to/cert.pem".to_string()),
key_file: Some("/path/to/key.pem".to_string()),
ssl_ca_cert: Some("/path/to/ca.pem".to_string()),
assert_hostname: true,
};
assert_eq!(tls_config.cert_file, Some("/path/to/cert.pem".to_string()));
assert_eq!(tls_config.key_file, Some("/path/to/key.pem".to_string()));
assert_eq!(tls_config.ssl_ca_cert, Some("/path/to/ca.pem".to_string()));
assert!(tls_config.assert_hostname);
}
#[test]
fn test_client_config_with_tls() {
let tls_config = TlsConfig {
cert_file: Some("/path/to/cert.pem".to_string()),
key_file: Some("/path/to/key.pem".to_string()),
ssl_ca_cert: None,
assert_hostname: false,
};
let client_config = ClientConfig {
tls_config: Some(tls_config.clone()),
..Default::default()
};
assert!(client_config.tls_config.is_some());
let config_tls = client_config.tls_config.unwrap();
assert_eq!(config_tls.cert_file, Some("/path/to/cert.pem".to_string()));
assert_eq!(config_tls.key_file, Some("/path/to/key.pem".to_string()));
assert!(config_tls.ssl_ca_cert.is_none());
assert!(!config_tls.assert_hostname);
}
#[test]
fn test_default_headers_skip_empty_api_key() {
let headers = RestfulLanceDbClient::<Sender>::default_headers(
"",
"us-east-1",
"db-name",
false,
&RemoteOptions::default(),
None,
&ClientConfig::default(),
)
.unwrap();
assert!(!headers.contains_key("x-api-key"));
let headers = RestfulLanceDbClient::<Sender>::default_headers(
"static-secret-value",
"us-east-1",
"db-name",
false,
&RemoteOptions::default(),
None,
&ClientConfig::default(),
)
.unwrap();
let api_key = headers.get("x-api-key").unwrap();
assert_eq!(api_key, "static-secret-value");
assert!(api_key.is_sensitive());
assert!(!format!("{headers:?}").contains("static-secret-value"));
}
#[test]
fn test_configured_authentication_headers_are_sensitive() {
let config = ClientConfig {
extra_headers: HashMap::from([
(
"Authorization".to_string(),
"Bearer configured-secret".to_string(),
),
("X-API-Key".to_string(), "configured-api-key".to_string()),
(
"Cookie".to_string(),
"session=configured-cookie".to_string(),
),
(
"Proxy-Authorization".to_string(),
"Basic configured-proxy-secret".to_string(),
),
("X-Custom".to_string(), "visible-value".to_string()),
]),
..Default::default()
};
let headers = RestfulLanceDbClient::<Sender>::default_headers(
"",
"us-east-1",
"db-name",
false,
&RemoteOptions::default(),
None,
&config,
)
.unwrap();
assert!(headers.get("authorization").unwrap().is_sensitive());
assert!(headers.get("x-api-key").unwrap().is_sensitive());
assert!(headers.get("cookie").unwrap().is_sensitive());
assert!(headers.get("proxy-authorization").unwrap().is_sensitive());
assert!(!headers.get("x-custom").unwrap().is_sensitive());
let debug = format!("{headers:?}");
assert!(!debug.contains("configured-secret"));
assert!(!debug.contains("configured-api-key"));
assert!(!debug.contains("configured-cookie"));
assert!(!debug.contains("configured-proxy-secret"));
assert!(debug.contains("visible-value"));
}
/// `log_request` prints the request's Debug, and Debug for a request prints
/// its headers. Marking the value sensitive is the only thing standing
/// between the API key and every debug line; assert on the header map's own
/// Debug, which is what that printing reduces to.
#[test]
fn test_api_key_is_redacted_in_debug_output() {
let headers = RestfulLanceDbClient::<Sender>::default_headers(
"sk-live-sentinel",
"us-east-1",
"db-name",
false,
&RemoteOptions::default(),
None,
&ClientConfig::default(),
)
.unwrap();
assert_eq!(headers.get("x-api-key").unwrap(), "sk-live-sentinel");
assert!(
!format!("{:?}", headers).contains("sk-live-sentinel"),
"the API key must not survive Debug formatting"
);
}
/// A suppressed body is suppressed whatever the content type says, and an
/// allowed one is logged in full.
#[test]
fn test_body_logging_is_decided_by_the_caller() {
assert_ne!(BodyLogging::Allowed, BodyLogging::Suppressed);
// `send` and `send_suppressing_body` differ only in what they pass, so
// the enum is the whole contract: a caller states its intent and the
// transport does not infer one from the route.
assert_eq!(BodyLogging::Allowed, BodyLogging::Allowed);
}
#[test]
fn test_rejects_invalid_cloud_dns_hostname() {
let invalid_database_names = ["a".repeat(64), "invalid..database".to_string()];
for db_name in invalid_database_names {
let parsed_url = parse_db_url(&format!("db://{db_name}")).unwrap();
let error = RestfulLanceDbClient::<Sender>::try_new(
&parsed_url,
"us-east-1",
None,
HeaderMap::new(),
ClientConfig::default(),
None,
)
.unwrap_err();
assert!(
matches!(error, Error::InvalidInput { ref message } if message.contains("DNS labels must contain 1 to 63 bytes")),
"unexpected error: {error}"
);
}
}
// Test implementation of HeaderProvider
#[derive(Debug, Clone)]
struct TestHeaderProvider {
headers: HashMap<String, String>,
}
impl TestHeaderProvider {
fn new(headers: HashMap<String, String>) -> Self {
Self { headers }
}
}
#[async_trait::async_trait]
impl HeaderProvider for TestHeaderProvider {
async fn get_headers(&self) -> Result<HashMap<String, String>> {
Ok(self.headers.clone())
}
}
// Test implementation that returns an error
#[derive(Debug)]
struct ErrorHeaderProvider;
#[async_trait::async_trait]
impl HeaderProvider for ErrorHeaderProvider {
async fn get_headers(&self) -> Result<HashMap<String, String>> {
Err(Error::Runtime {
message: "Failed to get headers".to_string(),
})
}
}
#[tokio::test]
async fn test_client_config_with_header_provider() {
let mut headers = HashMap::new();
headers.insert("X-API-Key".to_string(), "secret-key".to_string());
let provider = TestHeaderProvider::new(headers);
let client_config = ClientConfig {
header_provider: Some(Arc::new(provider) as Arc<dyn HeaderProvider>),
..Default::default()
};
assert!(client_config.header_provider.is_some());
}
#[tokio::test]
async fn test_apply_dynamic_headers() {
// Create a mock client with header provider
let mut headers = HashMap::new();
headers.insert("X-Dynamic".to_string(), "dynamic-value".to_string());
let provider = TestHeaderProvider::new(headers);
// Create a simple request
let request = reqwest::Request::new(
reqwest::Method::GET,
"https://example.com/test".parse().unwrap(),
);
// Create client with header provider
let client = RestfulLanceDbClient {
client: reqwest::Client::new(),
host: "https://example.com".to_string(),
retry_config: RetryConfig::default().try_into().unwrap(),
sender: Sender,
header_provider: Some(Arc::new(provider) as Arc<dyn HeaderProvider>),
read_consistency_interval: None,
max_bytes_per_request: None,
max_request_duration: None,
};
// Apply dynamic headers
let updated_request = client.apply_dynamic_headers(request).await.unwrap();
// Check that the header was added
assert_eq!(
updated_request.headers().get("X-Dynamic").unwrap(),
"dynamic-value"
);
}
#[tokio::test]
async fn test_apply_dynamic_headers_merge() {
// Test that dynamic headers override existing headers
let mut headers = HashMap::new();
headers.insert("Authorization".to_string(), "Bearer new-token".to_string());
headers.insert("X-API-Key".to_string(), "new-api-key".to_string());
headers.insert("X-Custom".to_string(), "custom-value".to_string());
let provider = TestHeaderProvider::new(headers);
// Create request with existing Authorization header
let mut request_builder = reqwest::Client::new().get("https://example.com/test");
request_builder = request_builder.header("Authorization", "Bearer old-token");
request_builder = request_builder.header("X-Existing", "existing-value");
let request = request_builder.build().unwrap();
// Create client with header provider
let client = RestfulLanceDbClient {
client: reqwest::Client::new(),
host: "https://example.com".to_string(),
retry_config: RetryConfig::default().try_into().unwrap(),
sender: Sender,
header_provider: Some(Arc::new(provider) as Arc<dyn HeaderProvider>),
read_consistency_interval: None,
max_bytes_per_request: None,
max_request_duration: None,
};
// Apply dynamic headers
let updated_request = client.apply_dynamic_headers(request).await.unwrap();
// Check that dynamic headers override existing ones
assert_eq!(
updated_request.headers().get("Authorization").unwrap(),
"Bearer new-token"
);
assert_eq!(
updated_request.headers().get("X-Custom").unwrap(),
"custom-value"
);
assert!(
updated_request
.headers()
.get("Authorization")
.unwrap()
.is_sensitive()
);
assert!(
updated_request
.headers()
.get("X-API-Key")
.unwrap()
.is_sensitive()
);
assert!(
!updated_request
.headers()
.get("X-Custom")
.unwrap()
.is_sensitive()
);
let debug = format!("{updated_request:?}");
assert!(!debug.contains("new-token"));
assert!(!debug.contains("new-api-key"));
assert!(debug.contains("custom-value"));
// Existing headers should still be present
assert_eq!(
updated_request.headers().get("X-Existing").unwrap(),
"existing-value"
);
}
#[tokio::test]
async fn test_apply_dynamic_headers_with_error_provider() {
let provider = ErrorHeaderProvider;
let request = reqwest::Request::new(
reqwest::Method::GET,
"https://example.com/test".parse().unwrap(),
);
let client = RestfulLanceDbClient {
client: reqwest::Client::new(),
host: "https://example.com".to_string(),
retry_config: RetryConfig::default().try_into().unwrap(),
sender: Sender,
header_provider: Some(Arc::new(provider) as Arc<dyn HeaderProvider>),
read_consistency_interval: None,
max_bytes_per_request: None,
max_request_duration: None,
};
// Header provider errors should fail the request
// This is important for security - if auth headers can't be fetched, don't proceed
let result = client.apply_dynamic_headers(request).await;
assert!(result.is_err());
match result.unwrap_err() {
Error::Runtime { message } => {
assert_eq!(message, "Failed to get headers");
}
_ => panic!("Expected Runtime error"),
}
}
#[test]
fn test_resolve_user_id_direct_value() {
let config = ClientConfig {
user_id: Some("direct-user-id".to_string()),
..Default::default()
};
assert_eq!(config.resolve_user_id(), Some("direct-user-id".to_string()));
}
#[test]
#[serial(user_id_env)]
fn test_resolve_user_id_none() {
let _guard = lock_env();
let config = ClientConfig::default();
// Clear env vars that might be set from other tests
// SAFETY: This is only called in tests
unsafe {
std::env::remove_var("LANCEDB_USER_ID");
std::env::remove_var("LANCEDB_USER_ID_ENV_KEY");
}
assert_eq!(config.resolve_user_id(), None);
}
#[test]
#[serial(user_id_env)]
fn test_resolve_user_id_from_env() {
let _guard = lock_env();
// SAFETY: This is only called in tests
unsafe {
std::env::set_var("LANCEDB_USER_ID", "env-user-id");
}
let config = ClientConfig::default();
assert_eq!(config.resolve_user_id(), Some("env-user-id".to_string()));
// SAFETY: This is only called in tests
unsafe {
std::env::remove_var("LANCEDB_USER_ID");
}
}
#[test]
#[serial(user_id_env)]
fn test_resolve_user_id_from_env_key() {
let _guard = lock_env();
// SAFETY: This is only called in tests
unsafe {
std::env::remove_var("LANCEDB_USER_ID");
std::env::set_var("LANCEDB_USER_ID_ENV_KEY", "MY_CUSTOM_USER_ID");
std::env::set_var("MY_CUSTOM_USER_ID", "custom-env-user-id");
}
let config = ClientConfig::default();
assert_eq!(
config.resolve_user_id(),
Some("custom-env-user-id".to_string())
);
// SAFETY: This is only called in tests
unsafe {
std::env::remove_var("LANCEDB_USER_ID_ENV_KEY");
std::env::remove_var("MY_CUSTOM_USER_ID");
}
}
#[test]
#[serial(user_id_env)]
fn test_resolve_user_id_direct_takes_precedence() {
let _guard = lock_env();
// SAFETY: This is only called in tests
unsafe {
std::env::set_var("LANCEDB_USER_ID", "env-user-id");
}
let config = ClientConfig {
user_id: Some("direct-user-id".to_string()),
..Default::default()
};
assert_eq!(config.resolve_user_id(), Some("direct-user-id".to_string()));
// SAFETY: This is only called in tests
unsafe {
std::env::remove_var("LANCEDB_USER_ID");
}
}
#[test]
#[serial(user_id_env)]
fn test_resolve_user_id_empty_env_ignored() {
let _guard = lock_env();
// SAFETY: This is only called in tests
unsafe {
std::env::set_var("LANCEDB_USER_ID", "");
std::env::remove_var("LANCEDB_USER_ID_ENV_KEY");
}
let config = ClientConfig::default();
assert_eq!(config.resolve_user_id(), None);
// SAFETY: This is only called in tests
unsafe {
std::env::remove_var("LANCEDB_USER_ID");
}
}
#[test]
fn test_resolve_max_bytes_passed_value_wins() {
// An explicit config value is used verbatim; env/default are not consulted.
let resolved =
RestfulLanceDbClient::<Sender>::resolve_max_bytes_per_request(Some(1234)).unwrap();
assert_eq!(resolved, Some(1234));
}
#[test]
fn test_resolve_max_bytes_zero_disables() {
let resolved =
RestfulLanceDbClient::<Sender>::resolve_max_bytes_per_request(Some(0)).unwrap();
assert_eq!(resolved, None);
}
#[test]
#[serial(request_limits_env)]
fn test_resolve_max_bytes_default_when_unset() {
let _guard = lock_env();
// SAFETY: This is only called in tests
unsafe {
std::env::remove_var("LANCE_CLIENT_MAX_BYTES_PER_REQUEST");
}
let resolved = RestfulLanceDbClient::<Sender>::resolve_max_bytes_per_request(None).unwrap();
assert_eq!(resolved, Some(DEFAULT_MAX_BYTES_PER_REQUEST));
}
#[test]
#[serial(request_limits_env)]
fn test_resolve_max_bytes_from_env() {
let _guard = lock_env();
// SAFETY: This is only called in tests
unsafe {
std::env::set_var("LANCE_CLIENT_MAX_BYTES_PER_REQUEST", "4096");
}
let resolved = RestfulLanceDbClient::<Sender>::resolve_max_bytes_per_request(None).unwrap();
// SAFETY: This is only called in tests
unsafe {
std::env::remove_var("LANCE_CLIENT_MAX_BYTES_PER_REQUEST");
}
assert_eq!(resolved, Some(4096));
}
#[test]
#[serial(request_limits_env)]
fn test_resolve_max_bytes_env_zero_disables() {
let _guard = lock_env();
// SAFETY: This is only called in tests
unsafe {
std::env::set_var("LANCE_CLIENT_MAX_BYTES_PER_REQUEST", "0");
}
let resolved = RestfulLanceDbClient::<Sender>::resolve_max_bytes_per_request(None).unwrap();
// SAFETY: This is only called in tests
unsafe {
std::env::remove_var("LANCE_CLIENT_MAX_BYTES_PER_REQUEST");
}
assert_eq!(resolved, None);
}
#[test]
#[serial(request_limits_env)]
fn test_resolve_max_bytes_config_overrides_env() {
let _guard = lock_env();
// SAFETY: This is only called in tests
unsafe {
std::env::set_var("LANCE_CLIENT_MAX_BYTES_PER_REQUEST", "4096");
}
// A config value takes precedence over the environment variable.
let resolved =
RestfulLanceDbClient::<Sender>::resolve_max_bytes_per_request(Some(1234)).unwrap();
// SAFETY: This is only called in tests
unsafe {
std::env::remove_var("LANCE_CLIENT_MAX_BYTES_PER_REQUEST");
}
assert_eq!(resolved, Some(1234));
}
#[test]
#[serial(request_limits_env)]
fn test_resolve_max_bytes_invalid_env_errors() {
let _guard = lock_env();
// SAFETY: This is only called in tests
unsafe {
std::env::set_var("LANCE_CLIENT_MAX_BYTES_PER_REQUEST", "not-a-number");
}
let err = RestfulLanceDbClient::<Sender>::resolve_max_bytes_per_request(None).unwrap_err();
// SAFETY: This is only called in tests
unsafe {
std::env::remove_var("LANCE_CLIENT_MAX_BYTES_PER_REQUEST");
}
assert!(matches!(err, Error::InvalidInput { .. }), "got: {err:?}");
}
#[test]
fn test_resolve_max_request_duration_passed_value_wins() {
let resolved = RestfulLanceDbClient::<Sender>::resolve_max_request_duration(
Some(Duration::from_secs(42)),
Duration::from_secs(300),
)
.unwrap();
assert_eq!(resolved, Some(Duration::from_secs(42)));
}
#[test]
fn test_resolve_max_request_duration_zero_disables() {
let resolved = RestfulLanceDbClient::<Sender>::resolve_max_request_duration(
Some(Duration::ZERO),
Duration::from_secs(300),
)
.unwrap();
assert_eq!(resolved, None);
}
#[test]
#[serial(request_limits_env)]
fn test_resolve_max_request_duration_default_is_half_read_timeout() {
let _guard = lock_env();
// SAFETY: This is only called in tests
unsafe {
std::env::remove_var("LANCE_CLIENT_MAX_REQUEST_DURATION");
}
let resolved = RestfulLanceDbClient::<Sender>::resolve_max_request_duration(
None,
Duration::from_secs(300),
)
.unwrap();
assert_eq!(resolved, Some(Duration::from_secs(150)));
}
#[test]
#[serial(request_limits_env)]
fn test_resolve_max_request_duration_from_env_seconds() {
let _guard = lock_env();
// SAFETY: This is only called in tests
unsafe {
std::env::set_var("LANCE_CLIENT_MAX_REQUEST_DURATION", "30");
}
let resolved = RestfulLanceDbClient::<Sender>::resolve_max_request_duration(
None,
Duration::from_secs(300),
)
.unwrap();
// SAFETY: This is only called in tests
unsafe {
std::env::remove_var("LANCE_CLIENT_MAX_REQUEST_DURATION");
}
assert_eq!(resolved, Some(Duration::from_secs(30)));
}
#[test]
#[serial(request_limits_env)]
fn test_resolve_max_request_duration_env_zero_disables() {
let _guard = lock_env();
// SAFETY: This is only called in tests
unsafe {
std::env::set_var("LANCE_CLIENT_MAX_REQUEST_DURATION", "0");
}
let resolved = RestfulLanceDbClient::<Sender>::resolve_max_request_duration(
None,
Duration::from_secs(300),
)
.unwrap();
// SAFETY: This is only called in tests
unsafe {
std::env::remove_var("LANCE_CLIENT_MAX_REQUEST_DURATION");
}
assert_eq!(resolved, None);
}
#[test]
#[serial(request_limits_env)]
fn test_resolve_max_request_duration_invalid_env_errors() {
let _guard = lock_env();
// SAFETY: This is only called in tests
unsafe {
std::env::set_var("LANCE_CLIENT_MAX_REQUEST_DURATION", "12.5");
}
let err = RestfulLanceDbClient::<Sender>::resolve_max_request_duration(
None,
Duration::from_secs(300),
)
.unwrap_err();
// SAFETY: This is only called in tests
unsafe {
std::env::remove_var("LANCE_CLIENT_MAX_REQUEST_DURATION");
}
assert!(matches!(err, Error::InvalidInput { .. }), "got: {err:?}");
}
}
mod tests;
+752
View File
@@ -0,0 +1,752 @@
// SPDX-License-Identifier: Apache-2.0
// SPDX-FileCopyrightText: Copyright The LanceDB Authors
/// A configuration naming any other delimiter is refused where it was
/// written, rather than producing identifiers no service splits the way the
/// caller meant.
#[test]
fn test_a_delimiter_other_than_the_supported_one_is_refused() {
for delimiter in ["/", "?", "#", "%", "", ".", "..", "-", "_", "|", "::", "$$"] {
let error = super::validate_id_delimiter(delimiter)
.expect_err("only the supported delimiter may be configured");
assert!(
error.to_string().contains("id_delimiter"),
"{delimiter:?}: {error}"
);
}
super::validate_id_delimiter(super::ID_DELIMITER).unwrap();
}
/// Leaving it unset is how nearly every caller reaches the same delimiter.
#[test]
fn test_an_unset_delimiter_is_the_supported_one() {
super::ClientConfig::default().validate().unwrap();
super::ClientConfig {
id_delimiter: Some(super::ID_DELIMITER.to_string()),
..Default::default()
}
.validate()
.unwrap();
super::ClientConfig {
id_delimiter: Some("-".to_string()),
..Default::default()
}
.validate()
.expect_err("a configured delimiter other than the supported one must be refused");
}
use super::*;
use serial_test::serial;
use std::time::Duration;
// Serializes the env-var-mutating tests below: cargo test runs tests in
// parallel, but several of these tests read and write the same process-
// global env vars (`LANCEDB_USER_ID*`), so they would race without this.
static ENV_MUTEX: std::sync::Mutex<()> = std::sync::Mutex::new(());
fn lock_env() -> std::sync::MutexGuard<'static, ()> {
ENV_MUTEX.lock().unwrap_or_else(|e| e.into_inner())
}
#[test]
fn test_parse_catalog_database_uri() {
let parsed = parse_db_url("db://team%2Fsearch").unwrap();
assert_eq!(parsed.db_name, "team/search");
assert!(parsed.db_prefix.is_none());
let parsed = parse_db_url("db://db/prefix").unwrap();
assert_eq!(parsed.db_name, "db");
assert_eq!(parsed.db_prefix.as_deref(), Some("prefix"));
}
#[test]
fn test_timeout_config_default() {
let config = TimeoutConfig::default();
assert!(config.timeout.is_none());
assert!(config.connect_timeout.is_none());
assert!(config.read_timeout.is_none());
assert!(config.pool_idle_timeout.is_none());
}
#[test]
fn test_timeout_config_with_overall_timeout() {
let config = TimeoutConfig {
timeout: Some(Duration::from_secs(60)),
connect_timeout: Some(Duration::from_secs(10)),
read_timeout: Some(Duration::from_secs(30)),
pool_idle_timeout: Some(Duration::from_secs(300)),
};
assert_eq!(config.timeout, Some(Duration::from_secs(60)));
assert_eq!(config.connect_timeout, Some(Duration::from_secs(10)));
assert_eq!(config.read_timeout, Some(Duration::from_secs(30)));
assert_eq!(config.pool_idle_timeout, Some(Duration::from_secs(300)));
}
#[test]
fn test_client_config_with_timeout() {
let timeout_config = TimeoutConfig {
timeout: Some(Duration::from_secs(120)),
..Default::default()
};
let client_config = ClientConfig {
timeout_config,
..Default::default()
};
assert_eq!(
client_config.timeout_config.timeout,
Some(Duration::from_secs(120))
);
}
#[test]
fn test_tls_config_default() {
let config = TlsConfig::default();
assert!(config.cert_file.is_none());
assert!(config.key_file.is_none());
assert!(config.ssl_ca_cert.is_none());
assert!(config.assert_hostname);
}
#[test]
fn test_tls_config_with_mtls() {
let tls_config = TlsConfig {
cert_file: Some("/path/to/cert.pem".to_string()),
key_file: Some("/path/to/key.pem".to_string()),
ssl_ca_cert: Some("/path/to/ca.pem".to_string()),
assert_hostname: true,
};
assert_eq!(tls_config.cert_file, Some("/path/to/cert.pem".to_string()));
assert_eq!(tls_config.key_file, Some("/path/to/key.pem".to_string()));
assert_eq!(tls_config.ssl_ca_cert, Some("/path/to/ca.pem".to_string()));
assert!(tls_config.assert_hostname);
}
#[test]
fn test_client_config_with_tls() {
let tls_config = TlsConfig {
cert_file: Some("/path/to/cert.pem".to_string()),
key_file: Some("/path/to/key.pem".to_string()),
ssl_ca_cert: None,
assert_hostname: false,
};
let client_config = ClientConfig {
tls_config: Some(tls_config.clone()),
..Default::default()
};
assert!(client_config.tls_config.is_some());
let config_tls = client_config.tls_config.unwrap();
assert_eq!(config_tls.cert_file, Some("/path/to/cert.pem".to_string()));
assert_eq!(config_tls.key_file, Some("/path/to/key.pem".to_string()));
assert!(config_tls.ssl_ca_cert.is_none());
assert!(!config_tls.assert_hostname);
}
#[test]
fn test_default_headers_skip_empty_api_key() {
let headers = RestfulLanceDbClient::<Sender>::default_headers(
"",
"us-east-1",
"db-name",
false,
&RemoteOptions::default(),
None,
&ClientConfig::default(),
)
.unwrap();
assert!(!headers.contains_key("x-api-key"));
let headers = RestfulLanceDbClient::<Sender>::default_headers(
"static-secret-value",
"us-east-1",
"db-name",
false,
&RemoteOptions::default(),
None,
&ClientConfig::default(),
)
.unwrap();
let api_key = headers.get("x-api-key").unwrap();
assert_eq!(api_key, "static-secret-value");
assert!(api_key.is_sensitive());
assert!(!format!("{headers:?}").contains("static-secret-value"));
}
#[test]
fn test_configured_authentication_headers_are_sensitive() {
let config = ClientConfig {
extra_headers: HashMap::from([
(
"Authorization".to_string(),
"Bearer configured-secret".to_string(),
),
("X-API-Key".to_string(), "configured-api-key".to_string()),
(
"Cookie".to_string(),
"session=configured-cookie".to_string(),
),
(
"Proxy-Authorization".to_string(),
"Basic configured-proxy-secret".to_string(),
),
("X-Custom".to_string(), "visible-value".to_string()),
]),
..Default::default()
};
let headers = RestfulLanceDbClient::<Sender>::default_headers(
"",
"us-east-1",
"db-name",
false,
&RemoteOptions::default(),
None,
&config,
)
.unwrap();
assert!(headers.get("authorization").unwrap().is_sensitive());
assert!(headers.get("x-api-key").unwrap().is_sensitive());
assert!(headers.get("cookie").unwrap().is_sensitive());
assert!(headers.get("proxy-authorization").unwrap().is_sensitive());
assert!(!headers.get("x-custom").unwrap().is_sensitive());
let debug = format!("{headers:?}");
assert!(!debug.contains("configured-secret"));
assert!(!debug.contains("configured-api-key"));
assert!(!debug.contains("configured-cookie"));
assert!(!debug.contains("configured-proxy-secret"));
assert!(debug.contains("visible-value"));
}
/// `log_request` prints the request's Debug, and Debug for a request prints
/// its headers. Marking the value sensitive is the only thing standing
/// between the API key and every debug line; assert on the header map's own
/// Debug, which is what that printing reduces to.
#[test]
fn test_api_key_is_redacted_in_debug_output() {
let headers = RestfulLanceDbClient::<Sender>::default_headers(
"sk-live-sentinel",
"us-east-1",
"db-name",
false,
&RemoteOptions::default(),
None,
&ClientConfig::default(),
)
.unwrap();
assert_eq!(headers.get("x-api-key").unwrap(), "sk-live-sentinel");
assert!(
!format!("{:?}", headers).contains("sk-live-sentinel"),
"the API key must not survive Debug formatting"
);
}
/// A suppressed body is suppressed whatever the content type says, and an
/// allowed one is logged in full.
#[test]
fn test_body_logging_is_decided_by_the_caller() {
assert_ne!(BodyLogging::Allowed, BodyLogging::Suppressed);
// `send` and `send_suppressing_body` differ only in what they pass, so
// the enum is the whole contract: a caller states its intent and the
// transport does not infer one from the route.
assert_eq!(BodyLogging::Allowed, BodyLogging::Allowed);
}
#[test]
fn test_rejects_invalid_cloud_dns_hostname() {
let invalid_database_names = ["a".repeat(64), "invalid..database".to_string()];
for db_name in invalid_database_names {
let parsed_url = parse_db_url(&format!("db://{db_name}")).unwrap();
let error = RestfulLanceDbClient::<Sender>::try_new(
&parsed_url,
"us-east-1",
None,
HeaderMap::new(),
ClientConfig::default(),
None,
)
.unwrap_err();
assert!(
matches!(error, Error::InvalidInput { ref message } if message.contains("DNS labels must contain 1 to 63 bytes")),
"unexpected error: {error}"
);
}
}
// Test implementation of HeaderProvider
#[derive(Debug, Clone)]
struct TestHeaderProvider {
headers: HashMap<String, String>,
}
impl TestHeaderProvider {
fn new(headers: HashMap<String, String>) -> Self {
Self { headers }
}
}
#[async_trait::async_trait]
impl HeaderProvider for TestHeaderProvider {
async fn get_headers(&self) -> Result<HashMap<String, String>> {
Ok(self.headers.clone())
}
}
// Test implementation that returns an error
#[derive(Debug)]
struct ErrorHeaderProvider;
#[async_trait::async_trait]
impl HeaderProvider for ErrorHeaderProvider {
async fn get_headers(&self) -> Result<HashMap<String, String>> {
Err(Error::Runtime {
message: "Failed to get headers".to_string(),
})
}
}
#[tokio::test]
async fn test_client_config_with_header_provider() {
let mut headers = HashMap::new();
headers.insert("X-API-Key".to_string(), "secret-key".to_string());
let provider = TestHeaderProvider::new(headers);
let client_config = ClientConfig {
header_provider: Some(Arc::new(provider) as Arc<dyn HeaderProvider>),
..Default::default()
};
assert!(client_config.header_provider.is_some());
}
#[tokio::test]
async fn test_apply_dynamic_headers() {
// Create a mock client with header provider
let mut headers = HashMap::new();
headers.insert("X-Dynamic".to_string(), "dynamic-value".to_string());
let provider = TestHeaderProvider::new(headers);
// Create a simple request
let request = reqwest::Request::new(
reqwest::Method::GET,
"https://example.com/test".parse().unwrap(),
);
// Create client with header provider
let client = RestfulLanceDbClient {
client: reqwest::Client::new(),
host: "https://example.com".to_string(),
retry_config: RetryConfig::default().try_into().unwrap(),
sender: Sender,
header_provider: Some(Arc::new(provider) as Arc<dyn HeaderProvider>),
read_consistency_interval: None,
max_bytes_per_request: None,
max_request_duration: None,
};
// Apply dynamic headers
let updated_request = client.apply_dynamic_headers(request).await.unwrap();
// Check that the header was added
assert_eq!(
updated_request.headers().get("X-Dynamic").unwrap(),
"dynamic-value"
);
}
#[tokio::test]
async fn test_apply_dynamic_headers_merge() {
// Test that dynamic headers override existing headers
let mut headers = HashMap::new();
headers.insert("Authorization".to_string(), "Bearer new-token".to_string());
headers.insert("X-API-Key".to_string(), "new-api-key".to_string());
headers.insert("X-Custom".to_string(), "custom-value".to_string());
let provider = TestHeaderProvider::new(headers);
// Create request with existing Authorization header
let mut request_builder = reqwest::Client::new().get("https://example.com/test");
request_builder = request_builder.header("Authorization", "Bearer old-token");
request_builder = request_builder.header("X-Existing", "existing-value");
let request = request_builder.build().unwrap();
// Create client with header provider
let client = RestfulLanceDbClient {
client: reqwest::Client::new(),
host: "https://example.com".to_string(),
retry_config: RetryConfig::default().try_into().unwrap(),
sender: Sender,
header_provider: Some(Arc::new(provider) as Arc<dyn HeaderProvider>),
read_consistency_interval: None,
max_bytes_per_request: None,
max_request_duration: None,
};
// Apply dynamic headers
let updated_request = client.apply_dynamic_headers(request).await.unwrap();
// Check that dynamic headers override existing ones
assert_eq!(
updated_request.headers().get("Authorization").unwrap(),
"Bearer new-token"
);
assert_eq!(
updated_request.headers().get("X-Custom").unwrap(),
"custom-value"
);
assert!(
updated_request
.headers()
.get("Authorization")
.unwrap()
.is_sensitive()
);
assert!(
updated_request
.headers()
.get("X-API-Key")
.unwrap()
.is_sensitive()
);
assert!(
!updated_request
.headers()
.get("X-Custom")
.unwrap()
.is_sensitive()
);
let debug = format!("{updated_request:?}");
assert!(!debug.contains("new-token"));
assert!(!debug.contains("new-api-key"));
assert!(debug.contains("custom-value"));
// Existing headers should still be present
assert_eq!(
updated_request.headers().get("X-Existing").unwrap(),
"existing-value"
);
}
#[tokio::test]
async fn test_apply_dynamic_headers_with_error_provider() {
let provider = ErrorHeaderProvider;
let request = reqwest::Request::new(
reqwest::Method::GET,
"https://example.com/test".parse().unwrap(),
);
let client = RestfulLanceDbClient {
client: reqwest::Client::new(),
host: "https://example.com".to_string(),
retry_config: RetryConfig::default().try_into().unwrap(),
sender: Sender,
header_provider: Some(Arc::new(provider) as Arc<dyn HeaderProvider>),
read_consistency_interval: None,
max_bytes_per_request: None,
max_request_duration: None,
};
// Header provider errors should fail the request
// This is important for security - if auth headers can't be fetched, don't proceed
let result = client.apply_dynamic_headers(request).await;
assert!(result.is_err());
match result.unwrap_err() {
Error::Runtime { message } => {
assert_eq!(message, "Failed to get headers");
}
_ => panic!("Expected Runtime error"),
}
}
#[test]
fn test_resolve_user_id_direct_value() {
let config = ClientConfig {
user_id: Some("direct-user-id".to_string()),
..Default::default()
};
assert_eq!(config.resolve_user_id(), Some("direct-user-id".to_string()));
}
#[test]
#[serial(user_id_env)]
fn test_resolve_user_id_none() {
let _guard = lock_env();
let config = ClientConfig::default();
// Clear env vars that might be set from other tests
// SAFETY: This is only called in tests
unsafe {
std::env::remove_var("LANCEDB_USER_ID");
std::env::remove_var("LANCEDB_USER_ID_ENV_KEY");
}
assert_eq!(config.resolve_user_id(), None);
}
#[test]
#[serial(user_id_env)]
fn test_resolve_user_id_from_env() {
let _guard = lock_env();
// SAFETY: This is only called in tests
unsafe {
std::env::set_var("LANCEDB_USER_ID", "env-user-id");
}
let config = ClientConfig::default();
assert_eq!(config.resolve_user_id(), Some("env-user-id".to_string()));
// SAFETY: This is only called in tests
unsafe {
std::env::remove_var("LANCEDB_USER_ID");
}
}
#[test]
#[serial(user_id_env)]
fn test_resolve_user_id_from_env_key() {
let _guard = lock_env();
// SAFETY: This is only called in tests
unsafe {
std::env::remove_var("LANCEDB_USER_ID");
std::env::set_var("LANCEDB_USER_ID_ENV_KEY", "MY_CUSTOM_USER_ID");
std::env::set_var("MY_CUSTOM_USER_ID", "custom-env-user-id");
}
let config = ClientConfig::default();
assert_eq!(
config.resolve_user_id(),
Some("custom-env-user-id".to_string())
);
// SAFETY: This is only called in tests
unsafe {
std::env::remove_var("LANCEDB_USER_ID_ENV_KEY");
std::env::remove_var("MY_CUSTOM_USER_ID");
}
}
#[test]
#[serial(user_id_env)]
fn test_resolve_user_id_direct_takes_precedence() {
let _guard = lock_env();
// SAFETY: This is only called in tests
unsafe {
std::env::set_var("LANCEDB_USER_ID", "env-user-id");
}
let config = ClientConfig {
user_id: Some("direct-user-id".to_string()),
..Default::default()
};
assert_eq!(config.resolve_user_id(), Some("direct-user-id".to_string()));
// SAFETY: This is only called in tests
unsafe {
std::env::remove_var("LANCEDB_USER_ID");
}
}
#[test]
#[serial(user_id_env)]
fn test_resolve_user_id_empty_env_ignored() {
let _guard = lock_env();
// SAFETY: This is only called in tests
unsafe {
std::env::set_var("LANCEDB_USER_ID", "");
std::env::remove_var("LANCEDB_USER_ID_ENV_KEY");
}
let config = ClientConfig::default();
assert_eq!(config.resolve_user_id(), None);
// SAFETY: This is only called in tests
unsafe {
std::env::remove_var("LANCEDB_USER_ID");
}
}
#[test]
fn test_resolve_max_bytes_passed_value_wins() {
// An explicit config value is used verbatim; env/default are not consulted.
let resolved =
RestfulLanceDbClient::<Sender>::resolve_max_bytes_per_request(Some(1234)).unwrap();
assert_eq!(resolved, Some(1234));
}
#[test]
fn test_resolve_max_bytes_zero_disables() {
let resolved = RestfulLanceDbClient::<Sender>::resolve_max_bytes_per_request(Some(0)).unwrap();
assert_eq!(resolved, None);
}
#[test]
#[serial(request_limits_env)]
fn test_resolve_max_bytes_default_when_unset() {
let _guard = lock_env();
// SAFETY: This is only called in tests
unsafe {
std::env::remove_var("LANCE_CLIENT_MAX_BYTES_PER_REQUEST");
}
let resolved = RestfulLanceDbClient::<Sender>::resolve_max_bytes_per_request(None).unwrap();
assert_eq!(resolved, Some(DEFAULT_MAX_BYTES_PER_REQUEST));
}
#[test]
#[serial(request_limits_env)]
fn test_resolve_max_bytes_from_env() {
let _guard = lock_env();
// SAFETY: This is only called in tests
unsafe {
std::env::set_var("LANCE_CLIENT_MAX_BYTES_PER_REQUEST", "4096");
}
let resolved = RestfulLanceDbClient::<Sender>::resolve_max_bytes_per_request(None).unwrap();
// SAFETY: This is only called in tests
unsafe {
std::env::remove_var("LANCE_CLIENT_MAX_BYTES_PER_REQUEST");
}
assert_eq!(resolved, Some(4096));
}
#[test]
#[serial(request_limits_env)]
fn test_resolve_max_bytes_env_zero_disables() {
let _guard = lock_env();
// SAFETY: This is only called in tests
unsafe {
std::env::set_var("LANCE_CLIENT_MAX_BYTES_PER_REQUEST", "0");
}
let resolved = RestfulLanceDbClient::<Sender>::resolve_max_bytes_per_request(None).unwrap();
// SAFETY: This is only called in tests
unsafe {
std::env::remove_var("LANCE_CLIENT_MAX_BYTES_PER_REQUEST");
}
assert_eq!(resolved, None);
}
#[test]
#[serial(request_limits_env)]
fn test_resolve_max_bytes_config_overrides_env() {
let _guard = lock_env();
// SAFETY: This is only called in tests
unsafe {
std::env::set_var("LANCE_CLIENT_MAX_BYTES_PER_REQUEST", "4096");
}
// A config value takes precedence over the environment variable.
let resolved =
RestfulLanceDbClient::<Sender>::resolve_max_bytes_per_request(Some(1234)).unwrap();
// SAFETY: This is only called in tests
unsafe {
std::env::remove_var("LANCE_CLIENT_MAX_BYTES_PER_REQUEST");
}
assert_eq!(resolved, Some(1234));
}
#[test]
#[serial(request_limits_env)]
fn test_resolve_max_bytes_invalid_env_errors() {
let _guard = lock_env();
// SAFETY: This is only called in tests
unsafe {
std::env::set_var("LANCE_CLIENT_MAX_BYTES_PER_REQUEST", "not-a-number");
}
let err = RestfulLanceDbClient::<Sender>::resolve_max_bytes_per_request(None).unwrap_err();
// SAFETY: This is only called in tests
unsafe {
std::env::remove_var("LANCE_CLIENT_MAX_BYTES_PER_REQUEST");
}
assert!(matches!(err, Error::InvalidInput { .. }), "got: {err:?}");
}
#[test]
fn test_resolve_max_request_duration_passed_value_wins() {
let resolved = RestfulLanceDbClient::<Sender>::resolve_max_request_duration(
Some(Duration::from_secs(42)),
Duration::from_secs(300),
)
.unwrap();
assert_eq!(resolved, Some(Duration::from_secs(42)));
}
#[test]
fn test_resolve_max_request_duration_zero_disables() {
let resolved = RestfulLanceDbClient::<Sender>::resolve_max_request_duration(
Some(Duration::ZERO),
Duration::from_secs(300),
)
.unwrap();
assert_eq!(resolved, None);
}
#[test]
#[serial(request_limits_env)]
fn test_resolve_max_request_duration_default_is_half_read_timeout() {
let _guard = lock_env();
// SAFETY: This is only called in tests
unsafe {
std::env::remove_var("LANCE_CLIENT_MAX_REQUEST_DURATION");
}
let resolved = RestfulLanceDbClient::<Sender>::resolve_max_request_duration(
None,
Duration::from_secs(300),
)
.unwrap();
assert_eq!(resolved, Some(Duration::from_secs(150)));
}
#[test]
#[serial(request_limits_env)]
fn test_resolve_max_request_duration_from_env_seconds() {
let _guard = lock_env();
// SAFETY: This is only called in tests
unsafe {
std::env::set_var("LANCE_CLIENT_MAX_REQUEST_DURATION", "30");
}
let resolved = RestfulLanceDbClient::<Sender>::resolve_max_request_duration(
None,
Duration::from_secs(300),
)
.unwrap();
// SAFETY: This is only called in tests
unsafe {
std::env::remove_var("LANCE_CLIENT_MAX_REQUEST_DURATION");
}
assert_eq!(resolved, Some(Duration::from_secs(30)));
}
#[test]
#[serial(request_limits_env)]
fn test_resolve_max_request_duration_env_zero_disables() {
let _guard = lock_env();
// SAFETY: This is only called in tests
unsafe {
std::env::set_var("LANCE_CLIENT_MAX_REQUEST_DURATION", "0");
}
let resolved = RestfulLanceDbClient::<Sender>::resolve_max_request_duration(
None,
Duration::from_secs(300),
)
.unwrap();
// SAFETY: This is only called in tests
unsafe {
std::env::remove_var("LANCE_CLIENT_MAX_REQUEST_DURATION");
}
assert_eq!(resolved, None);
}
#[test]
#[serial(request_limits_env)]
fn test_resolve_max_request_duration_invalid_env_errors() {
let _guard = lock_env();
// SAFETY: This is only called in tests
unsafe {
std::env::set_var("LANCE_CLIENT_MAX_REQUEST_DURATION", "12.5");
}
let err = RestfulLanceDbClient::<Sender>::resolve_max_request_duration(
None,
Duration::from_secs(300),
)
.unwrap_err();
// SAFETY: This is only called in tests
unsafe {
std::env::remove_var("LANCE_CLIENT_MAX_REQUEST_DURATION");
}
assert!(matches!(err, Error::InvalidInput { .. }), "got: {err:?}");
}
File diff suppressed because it is too large Load Diff
+634
View File
@@ -0,0 +1,634 @@
// SPDX-License-Identifier: Apache-2.0
// SPDX-FileCopyrightText: Copyright The LanceDB Authors
use super::*;
#[tokio::test]
async fn test_create_and_alter_secret_send_the_value_in_the_request_body() {
for (route, call) in [
("/v1/secret/openai-prod/create", true),
("/v1/secret/openai-prod/alter", false),
] {
let conn = Connection::new_with_handler(move |request| {
assert_eq!(request.method(), &reqwest::Method::POST);
assert_eq!(request.url().path(), route);
// Never a path segment or query parameter, which is what keeps
// it out of access logs and proxy traces.
assert!(request.url().query().is_none(), "{:?}", request.url());
let body: serde_json::Value =
serde_json::from_slice(request.body().unwrap().as_bytes().unwrap()).unwrap();
// The name is the path identifier, so the body is the value alone.
assert_eq!(body, serde_json::json!({ "value": "sk-live-0001" }));
http::Response::builder().status(200).body("{}").unwrap()
});
if call {
conn.create_secret("openai-prod", "sk-live-0001", &[])
.await
.unwrap();
} else {
conn.alter_secret("openai-prod", "sk-live-0001", &[])
.await
.unwrap();
}
}
}
#[tokio::test]
async fn test_list_secrets_walks_pages_and_returns_names_only() {
let conn = Connection::new_with_handler(|request| {
assert_eq!(request.method(), &reqwest::Method::GET);
assert_eq!(request.url().path(), "/v1/namespace/$/secret/list");
let page = request
.url()
.query_pairs()
.find(|(key, _)| key == "page_token")
.map(|(_, value)| value.into_owned());
let body = match page.as_deref() {
None => r#"{"secrets":[{"name":"openai-prod"}],"page_token":"p2"}"#,
Some("p2") => r#"{"secrets":[{"name":"hf-prod"}]}"#,
Some(other) => panic!("unexpected page token: {other}"),
};
http::Response::builder().status(200).body(body).unwrap()
});
assert_eq!(
conn.list_secrets(&[]).await.unwrap(),
vec!["openai-prod".to_string(), "hf-prod".to_string()]
);
}
/// A server that keeps handing back the same token would otherwise spin
/// forever.
#[tokio::test]
async fn test_list_secrets_rejects_a_repeated_page_token() {
let conn = Connection::new_with_handler(|_| {
http::Response::builder()
.status(200)
.body(r#"{"secrets":[{"name":"openai-prod"}],"page_token":"same"}"#)
.unwrap()
});
let error = conn.list_secrets(&[]).await.unwrap_err();
assert!(
error.to_string().contains("repeated a page_token"),
"{error}"
);
}
#[tokio::test]
async fn test_drop_and_describe_address_the_secret_in_the_path() {
let conn = Connection::new_with_handler(|request| {
assert_eq!(request.url().path(), "/v1/secret/openai-prod/drop");
// Nothing is left to say once the path names the Secret.
assert!(request.body().is_none(), "{:?}", request.body());
http::Response::builder().status(200).body("{}").unwrap()
});
conn.drop_secret("openai-prod", &[]).await.unwrap();
let conn = Connection::new_with_handler(|request| {
assert_eq!(request.url().path(), "/v1/secret/openai-prod/describe");
assert!(request.body().is_none(), "{:?}", request.body());
http::Response::builder()
.status(200)
.body(r#"{"name":"openai-prod","created_at_millis":1,"updated_at_millis":2}"#)
.unwrap()
});
let info = conn.describe_secret("openai-prod", &[]).await.unwrap();
assert_eq!(info.name, "openai-prod");
}
/// The namespace is part of the identifier the path addresses, joined with
/// the client's configured delimiter the way every other object's is. A
/// root Secret is therefore addressed by its bare name, and a namespaced
/// one by the joined path -- there is no body field either way.
#[tokio::test]
async fn test_a_namespace_path_is_addressed_in_the_path() {
let conn = Connection::new_with_handler(|request| {
assert_eq!(
request.url().path(),
"/v1/secret/prod$vision$openai-prod/drop"
);
http::Response::builder().status(200).body("{}").unwrap()
});
conn.drop_secret("openai-prod", &["prod".to_string(), "vision".to_string()])
.await
.unwrap();
let conn = Connection::new_with_handler(|request| {
assert_eq!(request.url().path(), "/v1/secret/openai-prod/drop");
http::Response::builder().status(200).body("{}").unwrap()
});
conn.drop_secret("openai-prod", &[]).await.unwrap();
// Listing is namespace-scoped, so the namespace is the whole identifier.
let conn = Connection::new_with_handler(|request| {
assert_eq!(
request.url().path(),
"/v1/namespace/prod$vision/secret/list"
);
http::Response::builder()
.status(200)
.body(r#"{"secrets":[]}"#)
.unwrap()
});
conn.list_secrets(&["prod".to_string(), "vision".to_string()])
.await
.unwrap();
}
/// A view description carries the schema in the namespace spec's JSON
/// encoding, so the body a test asserts against is the one that encoding
/// produces rather than a hand-written guess at it.
fn view_description_body(name: &str, namespace: &[&str], query: &str) -> String {
let schema = Schema::new(vec![Field::new("id", DataType::Int32, true)]);
serde_json::json!({
"name": name,
"namespace": namespace,
"query": query,
"default_database": "db",
"default_namespace": namespace,
"schema": lance_namespace::schema::arrow_schema_to_json(&schema).unwrap(),
})
.to_string()
}
#[tokio::test]
async fn test_create_view_posts_the_query_and_returns_the_resolved_schema() {
let conn = Connection::new_with_handler(|request| {
assert_eq!(request.method(), &reqwest::Method::POST);
assert_eq!(request.url().path(), "/v1/view/analytics$adults/create");
let body: serde_json::Value =
serde_json::from_slice(request.body().unwrap().as_bytes().unwrap()).unwrap();
// The name and its namespace address the view in the path, so the
// body says only what they cannot.
assert_eq!(body, serde_json::json!({"query": "SELECT id FROM people"}));
http::Response::builder()
.status(200)
.body(view_description_body(
"adults",
&["analytics"],
"SELECT id FROM people",
))
.unwrap()
});
let view = conn
.create_view("adults", "SELECT id FROM people", &["analytics".into()])
.await
.unwrap();
assert_eq!(view.name, "adults");
assert_eq!(view.namespace_path, vec!["analytics".to_string()]);
assert_eq!(view.query, "SELECT id FROM people");
assert_eq!(view.default_database, "db");
assert_eq!(view.default_namespace_path, vec!["analytics".to_string()]);
assert_eq!(view.schema.field(0).name(), "id");
}
#[tokio::test]
async fn test_describe_and_drop_address_the_view_in_the_path() {
let conn = Connection::new_with_handler(|request| {
assert_eq!(request.url().path(), "/v1/view/adults/describe");
assert!(request.body().is_none(), "{:?}", request.body());
http::Response::builder()
.status(200)
.body(view_description_body(
"adults",
&[],
"SELECT id FROM people",
))
.unwrap()
});
let view = conn.describe_view("adults", &[]).await.unwrap();
assert!(view.namespace_path.is_empty());
assert_eq!(view.schema.fields().len(), 1);
let conn = Connection::new_with_handler(|request| {
assert_eq!(request.method(), &reqwest::Method::POST);
assert_eq!(request.url().path(), "/v1/view/analytics$adults/drop");
assert!(request.body().is_none(), "{:?}", request.body());
http::Response::builder().status(200).body("{}").unwrap()
});
conn.drop_view("adults", &["analytics".into()])
.await
.unwrap();
}
/// An accepted drop hands back the job so a caller can wait on the delete,
/// and the waiting `drop_view` does that for them.
#[tokio::test]
async fn test_drop_view_async_reports_the_cleanup_job() {
let db = super::super::RemoteDatabase::new_mock(|_| {
http::Response::builder()
.status(202)
.body(r#"{"job_id":"j1-do-abc"}"#)
.unwrap()
});
let job = db.drop_view_async("adults", &[]).await.unwrap();
assert_eq!(job.id(), Some("j1-do-abc"));
}
/// Nothing was bound, so nothing is being deleted and the job is already done.
#[tokio::test]
async fn test_drop_view_async_reports_a_finished_job_when_nothing_was_bound() {
let db = super::super::RemoteDatabase::new_mock(|_| {
http::Response::builder().status(200).body("{}").unwrap()
});
let job = db.drop_view_async("adults", &[]).await.unwrap();
assert_eq!(job.id(), None);
assert_eq!(job.status().await.unwrap(), "finished");
job.wait().await.unwrap();
}
#[tokio::test]
async fn test_drop_view_rejects_incomplete_acceptance() {
for body in ["{}", r#"{"job_id":""}"#, r#"{"job_id":null}"#] {
let db = super::super::RemoteDatabase::new_mock(move |_| {
http::Response::builder().status(202).body(body).unwrap()
});
let error = db.drop_view_async("adults", &[]).await.err().unwrap();
assert!(error.to_string().contains("valid job_id"), "{error}");
}
}
#[tokio::test]
async fn test_drop_view_rejects_unexpected_success_status() {
let db = super::super::RemoteDatabase::new_mock(|_| {
http::Response::builder().status(204).body("").unwrap()
});
let error = db.drop_view_async("adults", &[]).await.err().unwrap();
assert!(
error.to_string().contains("200 OK or 202 Accepted"),
"{error}"
);
}
/// A schema the client cannot decode is a broken response, not a view
/// with no columns: reporting it as an error keeps a caller from reading
/// an empty schema as the truth about the view.
#[tokio::test]
async fn test_describe_view_rejects_an_undecodable_schema() {
let conn = Connection::new_with_handler(|_| {
http::Response::builder()
.status(200)
.body(
r#"{"name":"adults","query":"SELECT 1","default_database":"db",
"schema":{"fields":[{"name":"id","type":{"type":"nonesuch"},
"nullable":true}]}}"#,
)
.unwrap()
});
let error = conn.describe_view("adults", &[]).await.unwrap_err();
assert!(error.to_string().contains("undecodable schema"), "{error}");
}
#[tokio::test]
async fn test_list_views_walks_pages() {
let conn = Connection::new_with_handler(|request| {
assert_eq!(request.method(), &reqwest::Method::GET);
assert_eq!(request.url().path(), "/v1/namespace/analytics/view/list");
let page = request
.url()
.query_pairs()
.find(|(key, _)| key == "page_token")
.map(|(_, value)| value.into_owned());
let body = match page.as_deref() {
None => r#"{"views":[],"page_token":"p2"}"#,
Some("p2") => r#"{"views":["adults"]}"#,
Some(other) => panic!("unexpected page token: {other}"),
};
http::Response::builder().status(200).body(body).unwrap()
});
assert_eq!(
conn.list_views(&["analytics".into()]).await.unwrap(),
vec!["adults".to_string()]
);
}
/// A server that keeps handing back the same token would otherwise spin
/// forever.
#[tokio::test]
async fn test_list_views_rejects_a_repeated_page_token() {
let conn = Connection::new_with_handler(|_| {
http::Response::builder()
.status(200)
.body(r#"{"views":["adults"],"page_token":"same"}"#)
.unwrap()
});
let error = conn.list_views(&[]).await.unwrap_err();
assert!(
error.to_string().contains("repeated a page_token"),
"{error}"
);
}
/// A name carrying the delimiter would split back apart as a different
/// view, so it is refused before it reaches a route.
#[tokio::test]
async fn test_view_names_that_would_resplit_are_refused() {
let conn = Connection::new_with_handler(|_| -> http::Response<String> {
panic!("an invalid identifier must not reach the service")
});
for (name, namespace) in [
("analytics$adults", vec![]),
("adults", vec!["ana$lytics".to_string()]),
("", vec![]),
("..", vec![]),
] {
let error = match conn.describe_view(name, &namespace).await {
Ok(_) => panic!("accepted {name:?} in {namespace:?}"),
Err(error) => error.to_string(),
};
// A view is not a table, so the refusal says so.
assert!(
error.contains("view name") || error.contains("view namespace path segment"),
"{error}"
);
}
}
#[tokio::test]
async fn test_create_function_async_sends_canonical_request_and_decodes_typed_job() {
const REQUEST: &str = include_str!(
"../../../../tests/fixtures/first_class_functions/v1/remote_function_registration_request.json"
);
const FUNCTION_JOB: &str =
include_str!("../../../../tests/fixtures/first_class_functions/v1/remote_function_job.json");
let mut expected: serde_json::Value = serde_json::from_str(REQUEST).unwrap();
expected.as_object_mut().unwrap().remove("name");
let conn = Connection::new_with_handler(move |request| match request.url().path() {
"/v1/function/normalize_score/create" => {
assert_eq!(request.method(), &reqwest::Method::POST);
let body: serde_json::Value =
serde_json::from_slice(request.body().unwrap().as_bytes().unwrap()).unwrap();
assert_eq!(body, expected);
http::Response::builder()
.status(202)
.body(r#"{"job_id":"job-function-1"}"#)
.unwrap()
}
"/v1/jobs/describe" => http::Response::builder()
.status(200)
.body(FUNCTION_JOB)
.unwrap(),
path => panic!("unexpected path: {path}"),
});
let request = crate::function::FunctionRegistrationRequest::from_json(REQUEST).unwrap();
let job = conn.create_function_async(request, &[]).await.unwrap();
assert_eq!(job.id(), Some("job-function-1"));
let version = job.wait().await.unwrap();
assert_eq!(version.name(), "embed");
assert_eq!(version.version(), "1");
}
#[tokio::test]
async fn test_get_function_requires_and_sends_exact_version() {
const VERSION: &str = include_str!(
"../../../../tests/fixtures/first_class_functions/v1/remote_function_version.canonical.json"
);
let conn = Connection::new_with_handler(|request| {
assert_eq!(request.method(), &reqwest::Method::POST);
assert_eq!(request.url().path(), "/v1/function/embed/describe");
let body: serde_json::Value =
serde_json::from_slice(request.body().unwrap().as_bytes().unwrap()).unwrap();
assert_eq!(body, serde_json::json!({"version": "1"}));
http::Response::builder().status(200).body(VERSION).unwrap()
});
let version = conn.get_function("embed", "1", &[]).await.unwrap();
assert_eq!(version.name(), "embed");
assert_eq!(version.version(), "1");
}
#[tokio::test]
async fn test_list_functions_requests_definitions_and_paginates() {
const VERSION: &str = include_str!(
"../../../../tests/fixtures/first_class_functions/v1/remote_function_version.canonical.json"
);
let version: serde_json::Value = serde_json::from_str(VERSION).unwrap();
let page = Arc::new(AtomicUsize::new(0));
let conn = Connection::new_with_handler(move |request| {
assert_eq!(request.method(), &reqwest::Method::GET);
assert_eq!(request.url().path(), "/v1/namespace/$/function/list");
let query = request.url().query_pairs().collect::<HashMap<_, _>>();
assert_eq!(query.get("include_definition").unwrap(), "true");
match page.fetch_add(1, Ordering::SeqCst) {
0 => {
assert!(!query.contains_key("page_token"));
http::Response::builder()
.status(200)
.body(r#"{"functions": [], "page_token": "next"}"#.to_string())
.unwrap()
}
_ => {
assert_eq!(query.get("page_token").unwrap(), "next");
http::Response::builder()
.status(200)
.body(
serde_json::json!({
"functions": [{
"name": "embed",
"version": "1",
"definition": version.clone(),
}],
})
.to_string(),
)
.unwrap()
}
}
});
let functions = conn.list_functions(&[]).await.unwrap();
assert_eq!(functions.len(), 1);
assert_eq!(functions[0].name(), "embed");
assert_eq!(functions[0].version(), "1");
}
#[tokio::test]
async fn test_list_functions_stops_on_an_empty_page_token() {
let requests = Arc::new(AtomicUsize::new(0));
let seen = requests.clone();
let conn = Connection::new_with_handler(move |request| {
seen.fetch_add(1, Ordering::SeqCst);
assert_eq!(request.method(), &reqwest::Method::GET);
assert_eq!(request.url().path(), "/v1/namespace/$/function/list");
let query = request.url().query_pairs().collect::<HashMap<_, _>>();
assert_eq!(query.get("include_definition").unwrap(), "true");
assert!(!query.contains_key("page_token"));
http::Response::builder()
.status(200)
.body(r#"{"functions": [], "page_token": ""}"#)
.unwrap()
});
let functions = conn.list_functions(&[]).await.unwrap();
assert!(functions.is_empty());
assert_eq!(requests.load(Ordering::SeqCst), 1);
}
#[tokio::test]
async fn test_list_functions_rejects_a_page_token_cycle() {
let page = Arc::new(AtomicUsize::new(0));
let requests = page.clone();
let conn = Connection::new_with_handler(move |request| {
assert_eq!(request.method(), &reqwest::Method::GET);
assert_eq!(request.url().path(), "/v1/namespace/$/function/list");
let query = request.url().query_pairs().collect::<HashMap<_, _>>();
assert_eq!(query.get("include_definition").unwrap(), "true");
let next_page_token = match page.fetch_add(1, Ordering::SeqCst) {
0 => {
assert!(!query.contains_key("page_token"));
"one"
}
1 => {
assert_eq!(query.get("page_token").unwrap(), "one");
"two"
}
2 => {
assert_eq!(query.get("page_token").unwrap(), "two");
"one"
}
page => panic!("unexpected page: {page}"),
};
http::Response::builder()
.status(200)
.body(
serde_json::json!({
"functions": [],
"page_token": next_page_token,
})
.to_string(),
)
.unwrap()
});
let error = conn.list_functions(&[]).await.unwrap_err();
assert!(
matches!(
&error,
Error::Http {
status_code: Some(http::StatusCode::OK),
..
}
),
"got {error:?}"
);
assert_eq!(requests.load(Ordering::SeqCst), 3);
}
#[tokio::test]
async fn test_drop_function_sends_exact_version_and_decodes_replay() {
let conn = Connection::new_with_handler(|request| {
assert_eq!(request.method(), &reqwest::Method::POST);
assert_eq!(request.url().path(), "/v1/function/embed/drop");
let body: serde_json::Value =
serde_json::from_slice(request.body().unwrap().as_bytes().unwrap()).unwrap();
assert_eq!(body, serde_json::json!({"version": "1"}));
http::Response::builder()
.status(200)
.body(r#"{"dropped":false}"#)
.unwrap()
});
assert!(!conn.drop_function("embed", "1", &[]).await.unwrap());
}
/// A Function's namespace is addressed in the path the way a Secret's is:
/// every route takes the joined identifier and no body carries the
/// namespace, so a namespaced request differs from a root one only in its
/// path.
#[tokio::test]
async fn test_a_function_namespace_path_is_addressed_in_the_path() {
const REQUEST: &str = include_str!(
"../../../../tests/fixtures/first_class_functions/v1/remote_function_registration_request.json"
);
const VERSION: &str = include_str!(
"../../../../tests/fixtures/first_class_functions/v1/remote_function_version.canonical.json"
);
let namespace = ["analytics".to_string(), "features".to_string()];
let mut expected_create: serde_json::Value = serde_json::from_str(REQUEST).unwrap();
expected_create.as_object_mut().unwrap().remove("name");
let conn = Connection::new_with_handler(move |request| {
let body = request
.body()
.and_then(|body| body.as_bytes())
.map(|bytes| serde_json::from_slice::<serde_json::Value>(bytes).unwrap());
match request.url().path() {
"/v1/function/analytics$features$normalize_score/create" => {
assert_eq!(body.unwrap(), expected_create);
http::Response::builder()
.status(202)
.body(r#"{"job_id":"job-function-1"}"#.to_string())
.unwrap()
}
"/v1/function/analytics$features$embed/describe" => {
assert_eq!(body.unwrap(), serde_json::json!({"version": "1"}));
http::Response::builder()
.status(200)
.body(VERSION.to_string())
.unwrap()
}
"/v1/function/analytics$features$embed/drop" => {
assert_eq!(body.unwrap(), serde_json::json!({"version": "1"}));
http::Response::builder()
.status(200)
.body(r#"{"dropped":true}"#.to_string())
.unwrap()
}
// Listing is namespace-scoped, so the namespace is the whole
// identifier.
"/v1/namespace/analytics$features/function/list" => http::Response::builder()
.status(200)
.body(r#"{"functions":[]}"#.to_string())
.unwrap(),
path => panic!("unexpected path: {path}"),
}
});
let request = crate::function::FunctionRegistrationRequest::from_json(REQUEST).unwrap();
let job = conn
.create_function_async(request, &namespace)
.await
.unwrap();
assert_eq!(job.id(), Some("job-function-1"));
conn.get_function("embed", "1", &namespace).await.unwrap();
assert!(conn.list_functions(&namespace).await.unwrap().is_empty());
assert!(conn.drop_function("embed", "1", &namespace).await.unwrap());
}
/// Each namespace segment is checked before a request is built, so an
/// empty segment cannot collapse the identifier onto the parent namespace.
#[tokio::test]
async fn test_an_unaddressable_function_namespace_segment_is_refused() {
let conn = Connection::new_with_handler(|request| -> http::Response<String> {
panic!("reached the transport: {}", request.url().path())
});
let namespace = ["analytics".to_string(), String::new()];
assert!(conn.get_function("embed", "1", &namespace).await.is_err());
assert!(conn.list_functions(&namespace).await.is_err());
assert!(conn.drop_function("embed", "1", &namespace).await.is_err());
}
#[tokio::test]
async fn test_conn_job_waits_to_done() {
let polls = Arc::new(AtomicUsize::new(0));
let polls_ref = polls.clone();
let conn = Connection::new_with_handler(move |request| {
assert_eq!(request.url().path(), "/v1/jobs/describe");
// Two in-progress answers: one for the load, one for the first
// status poll.
let state = if polls_ref.fetch_add(1, Ordering::SeqCst) < 2 {
"IN_PROGRESS"
} else {
"DONE"
};
http::Response::builder()
.status(200)
.body(format!(
r#"{{"job_id": "job-1", "job_type": "create_function", "job_state": "{}", "creation_ms": 1, "result": {{"name": "embed", "version": "1"}}}}"#,
state
))
.unwrap()
});
let job = conn.open_job("job-1").await.unwrap();
assert_eq!(job.id(), Some("job-1"));
// Opening already answered the state; no extra call needed for it.
assert_eq!(job.state().as_deref(), Some("running"));
assert_eq!(job.status().await.unwrap(), "running");
job.wait().await.unwrap();
assert_eq!(job.status().await.unwrap(), "finished");
assert!(polls.load(Ordering::SeqCst) >= 4);
}
+489
View File
@@ -0,0 +1,489 @@
// SPDX-License-Identifier: Apache-2.0
// SPDX-FileCopyrightText: Copyright The LanceDB Authors
use super::*;
#[tokio::test]
async fn test_list_jobs_paginates() {
let page = Arc::new(AtomicUsize::new(0));
let conn = Connection::new_with_handler(move |request| {
assert_eq!(request.method(), &reqwest::Method::POST);
assert_eq!(request.url().path(), "/v1/jobs/list");
let body: serde_json::Value =
serde_json::from_slice(request.body().unwrap().as_bytes().unwrap()).unwrap();
match page.fetch_add(1, Ordering::SeqCst) {
0 => {
assert!(body.get("page_token").is_none());
http::Response::builder()
.status(200)
.body(
r#"{"jobs": [{"job_id": "job-1", "table": "t1", "job_type": "create_index", "state": "in_progress", "created_at_millis": 1000}], "page_token": "next"}"#,
)
.unwrap()
}
_ => {
assert_eq!(body["page_token"], "next");
http::Response::builder()
.status(200)
.body(
r#"{"jobs": [{"job_id": "job-2", "table": "t2", "job_type": "create_index", "state": "succeeded", "created_at_millis": 2000}, {"job_id": "job-3", "table": "t3", "job_type": "create_index", "state": "timed_out", "created_at_millis": 3000}]}"#,
)
.unwrap()
}
}
});
let jobs = conn.list_jobs().await.unwrap();
assert_eq!(jobs.len(), 3);
assert_eq!(jobs[0].job_id, "job-1");
assert_eq!(jobs[0].table, "t1");
assert_eq!(jobs[0].state, "running");
assert_eq!(jobs[1].job_id, "job-2");
assert_eq!(jobs[1].state, "finished");
assert_eq!(jobs[1].created_at_millis, 2000);
assert_eq!(jobs[2].job_id, "job-3");
assert_eq!(jobs[2].state, "failed");
}
#[tokio::test]
async fn test_list_jobs_rejects_a_page_token_cycle() {
let requests = Arc::new(AtomicUsize::new(0));
let seen = requests.clone();
let conn = Connection::new_with_handler(move |request| {
let body: serde_json::Value =
serde_json::from_slice(request.body().unwrap().as_bytes().unwrap()).unwrap();
match seen.fetch_add(1, Ordering::SeqCst) {
0 => assert!(body.get("page_token").is_none()),
_ => assert_eq!(body["page_token"], "loop"),
}
http::Response::builder()
.status(200)
.body(r#"{"jobs": [], "page_token": "loop"}"#)
.unwrap()
});
let error = conn.list_jobs().await.unwrap_err();
assert!(
matches!(
&error,
Error::Http {
status_code: Some(http::StatusCode::OK),
..
}
),
"got {error:?}"
);
assert_eq!(requests.load(Ordering::SeqCst), 2);
}
#[tokio::test]
async fn test_open_job() {
let conn = Connection::new_with_handler(|request| {
assert_eq!(request.method(), &reqwest::Method::POST);
assert_eq!(request.url().path(), "/v1/jobs/describe");
let body: serde_json::Value =
serde_json::from_slice(request.body().unwrap().as_bytes().unwrap()).unwrap();
assert_eq!(body["job_id"], "job-1");
http::Response::builder()
.status(200)
.body(
r#"{"job_id": "job-1", "job_type": "create_index", "job_state": "FAILED", "creation_ms": 1000, "spec": {"column": "vec"}, "failure": {"phase": "execute", "message": "worker died", "retryable": true}}"#,
)
.unwrap()
});
// Opening populates the handle, so the accessors answer without a
// second round trip.
let job = conn.open_job("job-1").await.unwrap();
assert_eq!(job.id(), Some("job-1"));
assert_eq!(job.job_type().as_deref(), Some("create_index"));
assert_eq!(job.state().as_deref(), Some("failed"));
assert_eq!(job.creation_ms(), Some(1000));
assert_eq!(job.spec().unwrap()["column"], "vec");
assert!(job.result().is_none());
let failure = job.failure().unwrap();
assert_eq!(failure.phase.as_deref(), Some("execute"));
assert_eq!(failure.message.as_deref(), Some("worker died"));
assert_eq!(failure.retryable, Some(true));
}
#[tokio::test]
async fn test_open_job_reports_the_terminal_result() {
let conn = Connection::new_with_handler(|_| {
http::Response::builder()
.status(200)
.body(
r#"{"job_id": "job-1", "job_type": "refresh_column", "job_state": "DONE", "creation_ms": 1000, "result": {"rows_assigned": 1000000, "rows_failed": 0}}"#,
)
.unwrap()
});
let job = conn.open_job("job-1").await.unwrap();
assert_eq!(job.state().as_deref(), Some("finished"));
let result = job.result().unwrap();
assert_eq!(result["rows_assigned"], 1_000_000);
assert_eq!(result["rows_failed"], 0);
}
#[tokio::test]
async fn test_open_job_missing_fails() {
let conn = Connection::new_with_handler(|_| {
http::Response::builder()
.status(404)
.body("no such job")
.unwrap()
});
let err = conn.open_job("nope").await.unwrap_err();
assert!(
matches!(&err, Error::JobNotFound { job_id } if job_id == "nope"),
"{err:?}"
);
}
#[tokio::test]
async fn test_pause_and_resume_job() {
use crate::database::{PauseJobStatus, ResumeJobStatus};
let conn = Connection::new_with_handler(|request| {
assert_eq!(request.url().path(), "/v1/jobs/pause");
http::Response::builder()
.status(200)
.body(r#"{"job_id": "job-1", "paused": true}"#)
.unwrap()
});
assert_eq!(
conn.pause_job("job-1").await.unwrap(),
PauseJobStatus::Pausing
);
let conn = Connection::new_with_handler(|_| {
http::Response::builder()
.status(200)
.body(r#"{"job_id": "job-1", "paused": false, "committing": true}"#)
.unwrap()
});
assert_eq!(
conn.pause_job("job-1").await.unwrap(),
PauseJobStatus::Committing
);
let conn = Connection::new_with_handler(|request| {
assert_eq!(request.url().path(), "/v1/jobs/resume");
http::Response::builder()
.status(200)
.body(r#"{"job_id": "job-1", "resumed": false, "still_pausing": true}"#)
.unwrap()
});
assert_eq!(
conn.resume_job("job-1").await.unwrap(),
ResumeJobStatus::StillPausing
);
}
#[tokio::test]
async fn test_job_events_scope_to_that_job() {
let schema = Arc::new(Schema::new(vec![Field::new(
"state",
DataType::Utf8,
false,
)]));
let batch = RecordBatch::try_new(
schema.clone(),
vec![Arc::new(arrow_array::StringArray::from(vec![
"claim_complete",
]))],
)
.unwrap();
let mut events = Vec::new();
{
let mut writer = arrow_ipc::writer::StreamWriter::try_new(&mut events, &schema).unwrap();
writer.write(&batch).unwrap();
writer.finish().unwrap();
}
let conn = Connection::new_with_handler(move |request| {
let body: serde_json::Value =
serde_json::from_slice(request.body().unwrap().as_bytes().unwrap()).unwrap();
if request.url().path() == "/v1/jobs/describe" {
return http::Response::builder()
.status(200)
.body(
r#"{"job_id": "job-1", "job_type": "refresh_column", "job_state": "IN_PROGRESS", "creation_ms": 1}"#
.as_bytes()
.to_vec(),
)
.unwrap();
}
assert_eq!(request.url().path(), "/v1/jobs/query_events");
// The handle supplies job_id; the caller only narrows the query.
assert_eq!(body["job_id"], "job-1");
assert_eq!(body["limit"], 500);
assert_eq!(body["filter"], "state = 'claim_complete'");
http::Response::builder()
.status(200)
.body(events.clone())
.unwrap()
});
let job = conn.open_job("job-1").await.unwrap();
let batches = job
.events(
JobEventsRequest::default()
.limit(500)
.filter("state = 'claim_complete'"),
)
.await
.unwrap();
assert_eq!(batches.len(), 1);
assert_eq!(batches[0].num_rows(), 1);
}
#[tokio::test]
async fn test_job_events_keep_the_schema_when_nothing_matches() {
let schema = Arc::new(Schema::new(vec![Field::new(
"state",
DataType::Utf8,
false,
)]));
let mut events = Vec::new();
{
let mut writer = arrow_ipc::writer::StreamWriter::try_new(&mut events, &schema).unwrap();
writer.finish().unwrap();
}
let conn = Connection::new_with_handler(move |request| {
if request.url().path() == "/v1/jobs/describe" {
return http::Response::builder()
.status(200)
.body(
r#"{"job_id": "job-1", "job_type": "refresh_column", "job_state": "IN_PROGRESS", "creation_ms": 1}"#
.as_bytes()
.to_vec(),
)
.unwrap();
}
let body: serde_json::Value =
serde_json::from_slice(request.body().unwrap().as_bytes().unwrap()).unwrap();
// Only the job id when the caller narrows nothing.
assert_eq!(body, serde_json::json!({ "job_id": "job-1" }));
http::Response::builder()
.status(200)
.body(events.clone())
.unwrap()
});
let job = conn.open_job("job-1").await.unwrap();
let batches = job.events(JobEventsRequest::default()).await.unwrap();
assert_eq!(batches.len(), 1);
assert_eq!(batches[0].num_rows(), 0);
assert_eq!(batches[0].schema(), schema);
}
/// A component that is not a legal Secret component never reaches a
/// transport. Before the identifier was checked here, each of these decided
/// the route instead of the name: `a/b` and `../jobs` left `/v1/secret/`
/// entirely, carrying a create body that holds a credential, and `a$b` read
/// as the namespace `a` and the name `b`.
#[tokio::test]
async fn test_an_illegal_component_never_reaches_the_transport() {
use std::sync::{Arc, Mutex};
for name in [
"../jobs",
"a/b",
"a$b",
"with space",
"q?x",
"a#b",
"a%2Fb",
"",
] {
let reached = Arc::new(Mutex::new(false));
let flag = reached.clone();
let conn = Connection::new_with_handler(move |_| {
*flag.lock().unwrap() = true;
http::Response::builder().status(200).body("{}").unwrap()
});
let error = conn
.create_secret(name, "sk-live-0001", &[])
.await
.expect_err("an illegal component must be refused");
assert!(!*reached.lock().unwrap(), "{name:?} reached the transport");
assert!(
error.to_string().contains("Secret name"),
"{name:?}: {error}"
);
}
}
/// `.` and `..` pass the character set and still cannot address anything:
/// URL parsing resolves them as relative segments, and after
/// percent-decoding, so no spelling of either survives.
#[tokio::test]
async fn test_a_relative_segment_component_is_refused() {
use std::sync::{Arc, Mutex};
// The percent-encoded spellings are caught a step earlier, by the
// character set: `%` is not a character a Secret component may hold.
// They reach the relative-segment rule only where there is no charset
// to catch them first -- see the Function case below.
for component in [".", ".."] {
let reached = Arc::new(Mutex::new(false));
let flag = reached.clone();
let conn = Connection::new_with_handler(move |_| {
*flag.lock().unwrap() = true;
http::Response::builder()
.status(200)
.body(r#"{"secrets":[]}"#)
.unwrap()
});
let by_name = conn
.drop_secret(component, &[])
.await
.expect_err("a dot-only name must be refused");
assert!(
by_name.to_string().contains("relative path segments"),
"{by_name}"
);
let by_segment = conn
.list_secrets(&[component.to_string()])
.await
.expect_err("a dot-only namespace segment must be refused");
assert!(
by_segment.to_string().contains("relative path segments"),
"{by_segment}"
);
assert!(
!*reached.lock().unwrap(),
"{component:?} reached the transport"
);
}
}
/// An identifier the service accepts is untouched by the encoding, so the
/// route reads like the table and Function routes beside it.
#[tokio::test]
async fn test_an_admissible_identifier_is_not_encoded() {
use std::sync::{Arc, Mutex};
let seen = Arc::new(Mutex::new(String::new()));
let captured = seen.clone();
let conn = Connection::new_with_handler(move |request| {
*captured.lock().unwrap() = request.url().path().to_string();
http::Response::builder().status(200).body("{}").unwrap()
});
conn.drop_secret(
"openai-prod.v1",
&["prod".to_string(), "vision_2".to_string()],
)
.await
.unwrap();
assert_eq!(
*seen.lock().unwrap(),
"/v1/secret/prod$vision_2$openai-prod.v1/drop"
);
}
/// A table name is joined into the route the same way a Secret's is, so the
/// same two failures are reachable: a dot-only name leaves the table route
/// entirely, and a name holding the delimiter is indistinguishable from a
/// namespace boundary.
#[tokio::test]
async fn test_a_table_name_cannot_choose_its_own_route() {
use std::sync::{Arc, Mutex};
for name in ["..", ".", "../jobs", "a/b", "a$b"] {
let reached = Arc::new(Mutex::new(false));
let flag = reached.clone();
let conn = Connection::new_with_handler(move |_| {
*flag.lock().unwrap() = true;
http::Response::builder().status(200).body("{}").unwrap()
});
let error = conn
.drop_table(name, &[])
.await
.expect_err("an unaddressable table name must be refused");
assert!(!*reached.lock().unwrap(), "{name:?} reached the transport");
assert!(!error.to_string().is_empty(), "{name:?}");
}
}
/// The namespace half of the same join.
#[tokio::test]
async fn test_a_namespace_segment_cannot_choose_its_own_route() {
use std::sync::{Arc, Mutex};
for segment in ["..", "a$b", ""] {
let reached = Arc::new(Mutex::new(false));
let flag = reached.clone();
let conn = Connection::new_with_handler(move |_| {
*flag.lock().unwrap() = true;
http::Response::builder().status(200).body("{}").unwrap()
});
let error = conn
.drop_table("t", &[segment.to_string()])
.await
.expect_err("an unaddressable namespace segment must be refused");
assert!(
!*reached.lock().unwrap(),
"{segment:?} reached the transport"
);
assert!(!error.to_string().is_empty(), "{segment:?}");
}
}
/// A segment outside the table charset still addresses one segment: the
/// service decides whether it may exist, and percent-encoding is what keeps
/// the question reaching the right route. A catalog database is named this
/// way.
#[tokio::test]
async fn test_a_namespace_segment_outside_the_charset_is_encoded_not_refused() {
use std::sync::{Arc, Mutex};
let seen = Arc::new(Mutex::new(String::new()));
let path = seen.clone();
let conn = Connection::new_with_handler(move |request| {
*path.lock().unwrap() = request.url().path().to_string();
http::Response::builder().status(200).body("{}").unwrap()
});
conn.drop_table("t", &["team/search".to_string()])
.await
.unwrap();
assert_eq!(*seen.lock().unwrap(), "/v1/table/team%2Fsearch$t/drop/");
}
/// A Function name is percent-encoded, which covers everything but the
/// relative segment: `..` is unreserved, so it survives encoding and is
/// then resolved away, posting a registration body to `/v1/create`.
#[tokio::test]
async fn test_a_relative_segment_function_name_is_refused() {
use std::sync::{Arc, Mutex};
for name in [".", "..", "%2E%2E"] {
let reached = Arc::new(Mutex::new(false));
let flag = reached.clone();
let conn = Connection::new_with_handler(move |_| {
*flag.lock().unwrap() = true;
http::Response::builder().status(200).body("{}").unwrap()
});
let error = conn
.drop_function(name, "fv_1", &[])
.await
.expect_err("a dot-only Function name must be refused");
assert!(
error.to_string().contains("relative path segments"),
"{error}"
);
assert!(!*reached.lock().unwrap(), "{name:?} reached the transport");
}
}
/// Only `.` and `..` are relative segments. `...` and longer runs are
/// ordinary and address perfectly well, so refusing them would make an
/// object that works today stop working on upgrade. This pins that.
#[tokio::test]
async fn test_a_longer_run_of_periods_is_an_ordinary_name() {
use std::sync::{Arc, Mutex};
for name in ["...", "....", "a.", ".a", "a..b"] {
let seen = Arc::new(Mutex::new(String::new()));
let captured = seen.clone();
let conn = Connection::new_with_handler(move |request| {
*captured.lock().unwrap() = request.url().path().to_string();
http::Response::builder().status(200).body("{}").unwrap()
});
conn.drop_secret(name, &[])
.await
.unwrap_or_else(|error| panic!("{name:?} must remain addressable: {error}"));
assert_eq!(
*seen.lock().unwrap(),
format!("/v1/secret/{name}/drop"),
"{name:?} did not reach its own route"
);
}
}
+25
View File
@@ -0,0 +1,25 @@
// SPDX-License-Identifier: Apache-2.0
// SPDX-FileCopyrightText: Copyright The LanceDB Authors
use super::{NamespaceHeaderProviderContext, build_cache_key};
use crate::connection::ConnectBuilder;
use crate::database::Database;
use crate::materialized_view::CreateMaterializedViewRequest;
use crate::{
Connection, Error,
database::CreateTableMode,
job::JobEventsRequest,
remote::{ARROW_STREAM_CONTENT_TYPE, ClientConfig, HeaderProvider, JSON_CONTENT_TYPE},
};
use arrow_array::{Int32Array, RecordBatch};
use arrow_schema::{DataType, Field, Schema};
use lance_namespace_impls::{DynamicContextProvider, OperationInfo};
use rstest::rstest;
use std::collections::HashMap;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::{Arc, OnceLock};
mod catalog;
mod jobs;
mod namespace;
mod tables;
@@ -0,0 +1,561 @@
// SPDX-License-Identifier: Apache-2.0
// SPDX-FileCopyrightText: Copyright The LanceDB Authors
use super::*;
#[tokio::test]
async fn test_header_provider_in_request() {
// Test HeaderProvider implementation that adds custom headers
#[derive(Debug, Clone)]
struct TestHeaderProvider {
headers: HashMap<String, String>,
}
#[async_trait::async_trait]
impl HeaderProvider for TestHeaderProvider {
async fn get_headers(&self) -> crate::Result<HashMap<String, String>> {
Ok(self.headers.clone())
}
}
// Create a test header provider with custom headers
let mut headers = HashMap::new();
headers.insert("X-Custom-Auth".to_string(), "test-token".to_string());
headers.insert("X-Request-Id".to_string(), "test-123".to_string());
let provider = Arc::new(TestHeaderProvider { headers }) as Arc<dyn HeaderProvider>;
// Create client config with the header provider
let client_config = ClientConfig {
header_provider: Some(provider),
..Default::default()
};
// Create connection with handler that verifies the headers are present
let conn = Connection::new_with_handler_and_config(
move |request| {
// Verify that our custom headers are present
assert_eq!(
request.headers().get("X-Custom-Auth").unwrap(),
"test-token"
);
assert_eq!(request.headers().get("X-Request-Id").unwrap(), "test-123");
// Also check standard headers are still there
assert_eq!(request.method(), &reqwest::Method::GET);
assert_eq!(request.url().path(), "/v1/table/");
http::Response::builder()
.status(200)
.body(r#"{"tables": ["table1", "table2"]}"#)
.unwrap()
},
client_config,
);
// Make a request that should include the custom headers
let names = conn.table_names().execute().await.unwrap();
assert_eq!(names, vec!["table1", "table2"]);
}
#[tokio::test]
async fn test_header_provider_error_handling() {
// Test HeaderProvider that returns an error
#[derive(Debug)]
struct ErrorHeaderProvider;
#[async_trait::async_trait]
impl HeaderProvider for ErrorHeaderProvider {
async fn get_headers(&self) -> crate::Result<HashMap<String, String>> {
Err(crate::Error::Runtime {
message: "Failed to fetch auth token".to_string(),
})
}
}
let provider = Arc::new(ErrorHeaderProvider) as Arc<dyn HeaderProvider>;
let client_config = ClientConfig {
header_provider: Some(provider),
..Default::default()
};
// Create connection - handler won't be called because header provider fails
let conn = Connection::new_with_handler_and_config(
move |_request| -> http::Response<&'static str> {
panic!("Handler should not be called when header provider fails");
},
client_config,
);
// Request should fail due to header provider error
let result = conn.table_names().execute().await;
assert!(result.is_err());
match result.unwrap_err() {
crate::Error::Runtime { message } => {
assert_eq!(message, "Failed to fetch auth token");
}
_ => panic!("Expected Runtime error from header provider"),
}
}
#[tokio::test]
async fn test_clone_table() {
let conn = Connection::new_with_handler(|request| {
assert_eq!(request.method(), &reqwest::Method::POST);
assert_eq!(request.url().path(), "/v1/table/cloned_table/clone");
assert_eq!(
request.headers().get("Content-Type").unwrap(),
JSON_CONTENT_TYPE
);
let body = request.body().unwrap().as_bytes().unwrap();
let body: serde_json::Value = serde_json::from_slice(body).unwrap();
assert_eq!(body["source_location"], "s3://bucket/source_table");
assert_eq!(body["is_shallow"], true);
http::Response::builder().status(200).body("").unwrap()
});
let table = conn
.clone_table("cloned_table", "s3://bucket/source_table")
.execute()
.await
.unwrap();
assert_eq!(table.name(), "cloned_table");
}
#[tokio::test]
async fn test_clone_table_with_version() {
let conn = Connection::new_with_handler(|request| {
assert_eq!(request.method(), &reqwest::Method::POST);
assert_eq!(request.url().path(), "/v1/table/cloned_table/clone");
let body = request.body().unwrap().as_bytes().unwrap();
let body: serde_json::Value = serde_json::from_slice(body).unwrap();
assert_eq!(body["source_location"], "s3://bucket/source_table");
assert_eq!(body["source_version"], 42);
assert_eq!(body["is_shallow"], true);
http::Response::builder().status(200).body("").unwrap()
});
let table = conn
.clone_table("cloned_table", "s3://bucket/source_table")
.source_version(42)
.execute()
.await
.unwrap();
assert_eq!(table.name(), "cloned_table");
}
#[tokio::test]
async fn test_clone_table_with_tag() {
let conn = Connection::new_with_handler(|request| {
assert_eq!(request.method(), &reqwest::Method::POST);
assert_eq!(request.url().path(), "/v1/table/cloned_table/clone");
let body = request.body().unwrap().as_bytes().unwrap();
let body: serde_json::Value = serde_json::from_slice(body).unwrap();
assert_eq!(body["source_location"], "s3://bucket/source_table");
assert_eq!(body["source_tag"], "v1.0");
assert_eq!(body["is_shallow"], true);
http::Response::builder().status(200).body("").unwrap()
});
let table = conn
.clone_table("cloned_table", "s3://bucket/source_table")
.source_tag("v1.0")
.execute()
.await
.unwrap();
assert_eq!(table.name(), "cloned_table");
}
#[tokio::test]
async fn test_clone_table_deep_clone() {
let conn = Connection::new_with_handler(|request| {
assert_eq!(request.method(), &reqwest::Method::POST);
assert_eq!(request.url().path(), "/v1/table/cloned_table/clone");
let body = request.body().unwrap().as_bytes().unwrap();
let body: serde_json::Value = serde_json::from_slice(body).unwrap();
assert_eq!(body["source_location"], "s3://bucket/source_table");
assert_eq!(body["is_shallow"], false);
http::Response::builder().status(200).body("").unwrap()
});
let table = conn
.clone_table("cloned_table", "s3://bucket/source_table")
.is_shallow(false)
.execute()
.await
.unwrap();
assert_eq!(table.name(), "cloned_table");
}
#[tokio::test]
async fn test_clone_table_with_namespace() {
let conn = Connection::new_with_handler(|request| {
assert_eq!(request.method(), &reqwest::Method::POST);
assert_eq!(request.url().path(), "/v1/table/ns1$ns2$cloned_table/clone");
let body = request.body().unwrap().as_bytes().unwrap();
let body: serde_json::Value = serde_json::from_slice(body).unwrap();
assert_eq!(body["source_location"], "s3://bucket/source_table");
assert_eq!(body["is_shallow"], true);
http::Response::builder().status(200).body("").unwrap()
});
let table = conn
.clone_table("cloned_table", "s3://bucket/source_table")
.target_namespace(vec!["ns1".to_string(), "ns2".to_string()])
.execute()
.await
.unwrap();
assert_eq!(table.name(), "cloned_table");
}
#[tokio::test]
async fn test_clone_table_error() {
let conn = Connection::new_with_handler(|_| {
http::Response::builder()
.status(500)
.body("Internal server error")
.unwrap()
});
let result = conn
.clone_table("cloned_table", "s3://bucket/source_table")
.execute()
.await;
assert!(result.is_err());
if let Err(crate::Error::Http { source, .. }) = result {
assert!(source.to_string().contains("Failed to clone table"));
} else {
panic!("Expected HTTP error");
}
}
#[tokio::test]
async fn test_namespace_client() {
let conn = Connection::new_with_handler(|_| {
http::Response::builder()
.status(200)
.body(r#"{"tables": []}"#)
.unwrap()
});
// Get the namespace client from the connection's internal database
let namespace_client = conn.namespace_client().await;
assert!(namespace_client.is_ok());
}
#[tokio::test]
async fn test_namespace_client_with_tls_config() {
use crate::remote::client::TlsConfig;
let tls_config = TlsConfig {
cert_file: Some("/path/to/cert.pem".to_string()),
key_file: Some("/path/to/key.pem".to_string()),
ssl_ca_cert: Some("/path/to/ca.pem".to_string()),
assert_hostname: true,
};
let client_config = ClientConfig {
tls_config: Some(tls_config),
..Default::default()
};
let conn = Connection::new_with_handler_and_config(
|_| {
http::Response::builder()
.status(200)
.body(r#"{"tables": []}"#)
.unwrap()
},
client_config,
);
// Get the namespace client - it should be created with the TLS config
let namespace_client = conn.namespace_client().await;
assert!(namespace_client.is_ok());
}
#[tokio::test]
async fn test_namespace_client_with_headers() {
let mut extra_headers = HashMap::new();
extra_headers.insert("X-Custom-Header".to_string(), "custom-value".to_string());
let client_config = ClientConfig {
extra_headers,
..Default::default()
};
let conn = Connection::new_with_handler_and_config(
|_| {
http::Response::builder()
.status(200)
.body(r#"{"tables": []}"#)
.unwrap()
},
client_config,
);
// Get the namespace client - it should be created with the extra headers
let namespace_client = conn.namespace_client().await;
assert!(namespace_client.is_ok());
}
#[test]
fn test_namespace_header_provider_context_maps_headers() {
#[derive(Debug)]
struct TestHeaderProvider;
#[async_trait::async_trait]
impl HeaderProvider for TestHeaderProvider {
async fn get_headers(&self) -> crate::Result<HashMap<String, String>> {
Ok(HashMap::from([(
"authorization".to_string(),
"Bearer token".to_string(),
)]))
}
}
let context_provider = NamespaceHeaderProviderContext {
header_provider: Arc::new(TestHeaderProvider) as Arc<dyn HeaderProvider>,
};
let context = context_provider.provide_context(&OperationInfo::new("list_tables", "namespace"));
assert_eq!(
context.get("headers.authorization"),
Some(&"Bearer token".to_string())
);
}
#[tokio::test]
async fn test_namespace_client_supports_dynamic_headers() {
#[derive(Debug)]
struct TestHeaderProvider;
#[async_trait::async_trait]
impl HeaderProvider for TestHeaderProvider {
async fn get_headers(&self) -> crate::Result<HashMap<String, String>> {
Ok(HashMap::from([(
"authorization".to_string(),
"Bearer token".to_string(),
)]))
}
}
let client_config = ClientConfig {
header_provider: Some(Arc::new(TestHeaderProvider) as Arc<dyn HeaderProvider>),
..Default::default()
};
let conn = Connection::new_with_handler_and_config(
|_| {
http::Response::builder()
.status(200)
.body(r#"{"tables": []}"#)
.unwrap()
},
client_config,
);
let namespace_client = conn.namespace_client().await;
assert!(namespace_client.is_ok());
match conn.namespace_client_config().await {
Err(Error::NotSupported { message })
if message.contains("dynamic headers are configured") => {}
Err(err) => panic!("expected NotSupported, got {err:?}"),
Ok(_) => panic!("expected namespace_client_config to reject dynamic headers"),
}
}
/// Integration tests using RestAdapter to run RemoteDatabase against a real namespace server
mod rest_adapter_integration {
use super::*;
use lance_namespace::models::ListTablesRequest;
use lance_namespace_impls::{DirectoryNamespaceBuilder, RestAdapter, RestAdapterConfig};
use std::sync::Arc;
use tempfile::TempDir;
/// Test fixture that manages a REST server backed by DirectoryNamespace
struct RestServerFixture {
_temp_dir: TempDir,
server_handle: lance_namespace_impls::RestAdapterHandle,
server_url: String,
}
impl RestServerFixture {
async fn new() -> Self {
let temp_dir = TempDir::new().unwrap();
let temp_path = temp_dir.path().to_str().unwrap().to_string();
// Create DirectoryNamespace backend
let backend = DirectoryNamespaceBuilder::new(&temp_path)
.build()
.await
.unwrap();
let backend = Arc::new(backend);
// Start REST server with port 0 (OS assigns available port)
let config = RestAdapterConfig {
port: 0,
..Default::default()
};
let server = RestAdapter::new(backend, config);
let server_handle = server.start().await.unwrap();
// Get the actual port assigned by OS
let actual_port = server_handle.port();
let server_url = format!("http://127.0.0.1:{}", actual_port);
Self {
_temp_dir: temp_dir,
server_handle,
server_url,
}
}
}
impl Drop for RestServerFixture {
fn drop(&mut self) {
self.server_handle.shutdown();
}
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_remote_database_with_rest_adapter() {
use lance_namespace::models::CreateNamespaceRequest;
let fixture = RestServerFixture::new().await;
// Connect to the REST server using lancedb Connection
// Use db://dummy as URI and set actual server URL via host_override
let conn = ConnectBuilder::new("db://dummy")
.api_key("test-api-key")
.region("us-east-1")
.host_override(&fixture.server_url)
.execute()
.await
.unwrap();
// Create a child namespace first
let namespace = vec!["test_ns".to_string()];
conn.create_namespace(CreateNamespaceRequest {
id: Some(namespace.clone()),
..Default::default()
})
.await
.expect("Failed to create namespace");
// Create a table in the child namespace
let schema = Arc::new(Schema::new(vec![Field::new("a", DataType::Int32, false)]));
let data = RecordBatch::try_new(
schema.clone(),
vec![Arc::new(Int32Array::from(vec![1, 2, 3]))],
)
.unwrap();
let table = conn
.create_table("test_table", data)
.namespace(namespace.clone())
.execute()
.await;
assert!(table.is_ok(), "Failed to create table: {:?}", table.err());
// List tables in the child namespace
let list_response = conn
.list_tables(ListTablesRequest {
id: Some(namespace.clone()),
..Default::default()
})
.await
.expect("Failed to list tables");
assert_eq!(list_response.tables, vec!["test_table"]);
// Get namespace client and verify it can also list tables
let namespace_client = conn.namespace_client().await.unwrap();
let list_response = namespace_client
.list_tables(ListTablesRequest {
id: Some(namespace.clone()),
..Default::default()
})
.await
.unwrap();
assert_eq!(list_response.tables, vec!["test_table"]);
// Open the table from the child namespace
let opened_table = conn
.open_table("test_table")
.namespace(namespace.clone())
.execute()
.await;
assert!(
opened_table.is_ok(),
"Failed to open table: {:?}",
opened_table.err()
);
assert_eq!(opened_table.unwrap().name(), "test_table");
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_remote_database_with_multiple_tables() {
use lance_namespace::models::CreateNamespaceRequest;
let fixture = RestServerFixture::new().await;
// Connect to the REST server
// Use db://dummy as URI and set actual server URL via host_override
let conn = ConnectBuilder::new("db://dummy")
.api_key("test-api-key")
.region("us-east-1")
.host_override(&fixture.server_url)
.execute()
.await
.unwrap();
// Create a child namespace first
let namespace = vec!["multi_table_ns".to_string()];
conn.create_namespace(CreateNamespaceRequest {
id: Some(namespace.clone()),
..Default::default()
})
.await
.expect("Failed to create namespace");
// Create multiple tables in the child namespace
let schema = Arc::new(Schema::new(vec![Field::new("id", DataType::Int32, false)]));
for i in 1..=3 {
let data =
RecordBatch::try_new(schema.clone(), vec![Arc::new(Int32Array::from(vec![i]))])
.unwrap();
conn.create_table(format!("table{}", i), data)
.namespace(namespace.clone())
.execute()
.await
.unwrap_or_else(|e| panic!("Failed to create table{}: {:?}", i, e));
}
// List tables in the child namespace
let list_response = conn
.list_tables(ListTablesRequest {
id: Some(namespace.clone()),
..Default::default()
})
.await
.unwrap();
assert_eq!(list_response.tables.len(), 3);
assert!(list_response.tables.contains(&"table1".to_string()));
assert!(list_response.tables.contains(&"table2".to_string()));
assert!(list_response.tables.contains(&"table3".to_string()));
}
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,478 @@
// SPDX-License-Identifier: Apache-2.0
// SPDX-FileCopyrightText: Copyright The LanceDB Authors
use super::*;
#[test]
fn test_authorization_callback_parsing() {
let callback = parse_authorization_callback(
"GET /callback?code=abc%20123&state=expected HTTP/1.1\r\nHost: localhost\r\n",
"/callback",
"expected",
)
.unwrap();
assert_eq!(callback, AuthorizationCallback::Code("abc 123".to_string()));
}
#[test]
fn test_authorization_callback_rejects_state_mismatch() {
let err = parse_authorization_callback(
"GET /callback?code=abc&state=wrong HTTP/1.1\r\nHost: localhost\r\n",
"/callback",
"expected",
)
.unwrap_err();
assert!(matches!(
err,
Error::Runtime { message }
if message == "OAuth authorization callback state did not match"
));
}
#[test]
fn test_authorization_callback_reports_provider_error() {
let callback = parse_authorization_callback(
"GET /callback?error=access_denied&error_description=user+cancelled&state=expected HTTP/1.1\r\nHost: localhost\r\n",
"/callback",
"expected",
)
.unwrap();
assert_eq!(
callback,
AuthorizationCallback::ProviderError(
"OAuth authorization failed: user cancelled".to_string()
)
);
}
#[tokio::test]
async fn test_authorization_callback_ignores_unrelated_connection_and_partial_read() {
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let port = listener.local_addr().unwrap().port();
let source = AuthorizationCodeSource::new(
"http://127.0.0.1:1".to_string(),
"client-id".to_string(),
None,
ClientAuthMethod::None,
vec!["openid".to_string()],
None,
None,
AuthorizationCodeOptions::new().redirect_uri(format!("http://127.0.0.1:{port}/callback")),
)
.unwrap();
let browser = tokio::spawn(async move {
let mut unrelated = TcpStream::connect(("127.0.0.1", port)).await.unwrap();
unrelated
.write_all(b"GET /favicon.ico HTTP/1.1\r\nHost: localhost\r\n\r\n")
.await
.unwrap();
let mut response = Vec::new();
unrelated.read_to_end(&mut response).await.unwrap();
assert!(
String::from_utf8(response)
.unwrap()
.starts_with("HTTP/1.1 400")
);
let mut callback = TcpStream::connect(("127.0.0.1", port)).await.unwrap();
callback
.write_all(b"GET /callback?code=auth")
.await
.unwrap();
tokio::task::yield_now().await;
callback
.write_all(b"-code&state=expected HTTP/1.1\r\nHost: localhost\r\n\r\n")
.await
.unwrap();
});
assert_eq!(
source
.wait_for_callback(&listener, "expected")
.await
.unwrap(),
"auth-code"
);
browser.await.unwrap();
}
#[tokio::test]
async fn test_authorization_callback_read_respects_overall_deadline() {
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let port = listener.local_addr().unwrap().port();
let client = tokio::spawn(async move {
let _stream = TcpStream::connect(("127.0.0.1", port)).await.unwrap();
tokio::time::sleep(Duration::from_secs(1)).await;
});
let (mut stream, _) = listener.accept().await.unwrap();
let err = read_authorization_callback(
&mut stream,
"/callback",
"expected",
TokioInstant::now() + Duration::from_millis(20),
)
.await
.unwrap_err();
assert!(matches!(
err,
Error::Runtime { message }
if message == "Timed out reading the OAuth authorization callback"
));
client.abort();
}
#[tokio::test]
async fn test_authorization_request_uses_pkce_by_default() {
let (issuer_url, server) = spawn_discovery_server(1).await;
let source = AuthorizationCodeSource::new(
issuer_url,
"client-id".to_string(),
None,
ClientAuthMethod::None,
vec!["openid".to_string(), "profile".to_string()],
None,
None,
AuthorizationCodeOptions::new(),
)
.unwrap();
let request = source.build_authorization_request().await.unwrap();
let params: HashMap<_, _> = request.url.query_pairs().into_owned().collect();
assert_eq!(
params.get("response_type").map(String::as_str),
Some("code")
);
assert_eq!(
params.get("client_id").map(String::as_str),
Some("client-id")
);
assert_eq!(
params.get("redirect_uri").map(String::as_str),
Some("http://127.0.0.1:8400/callback")
);
assert_eq!(
params.get("scope").map(String::as_str),
Some("openid profile")
);
assert!(params.get("state").is_some_and(|state| state.len() >= 16));
assert_eq!(
params.get("code_challenge_method").map(String::as_str),
Some("S256")
);
assert!(params.contains_key("code_challenge"));
assert!(request.code_verifier.is_some());
server.await.unwrap();
}
#[tokio::test]
async fn test_authorization_request_rejects_plaintext_provider_endpoint() {
let (issuer_url, server) = spawn_insecure_authorization_discovery_server().await;
let source = AuthorizationCodeSource::new(
issuer_url,
"client-id".to_string(),
None,
ClientAuthMethod::None,
vec!["openid".to_string()],
None,
None,
AuthorizationCodeOptions::new(),
)
.unwrap();
let err = source.build_authorization_request().await.unwrap_err();
assert!(matches!(
err,
Error::InvalidInput { message }
if message.contains("authorization_endpoint must use https")
));
server.await.unwrap();
}
#[tokio::test]
async fn test_authorization_request_can_disable_pkce() {
let (issuer_url, server) = spawn_discovery_server(1).await;
let source = AuthorizationCodeSource::new(
issuer_url,
"client-id".to_string(),
Some("secret".to_string()),
ClientAuthMethod::ClientSecretBasic,
vec!["openid".to_string()],
None,
None,
AuthorizationCodeOptions::new().use_pkce(false),
)
.unwrap();
let request = source.build_authorization_request().await.unwrap();
let params: HashMap<_, _> = request.url.query_pairs().into_owned().collect();
assert!(!params.contains_key("code_challenge"));
assert!(!params.contains_key("code_challenge_method"));
assert!(request.code_verifier.is_none());
server.await.unwrap();
}
#[tokio::test]
async fn test_authorization_code_exchange_public_client_sends_client_id_only() {
let (issuer_url, request, server) = spawn_captured_token_server().await;
let source = AuthorizationCodeSource::new(
issuer_url,
"client-id".to_string(),
None,
ClientAuthMethod::None,
vec!["openid".to_string()],
None,
None,
AuthorizationCodeOptions::new(),
)
.unwrap();
let response = source
.exchange_code(
"auth-code",
Some(PkceCodeVerifier::new("verifier".to_string())),
)
.await
.unwrap();
assert_eq!(response.access_token.secret(), "token");
let request = request.lock().unwrap().take().unwrap();
assert_eq!(request.header("authorization"), None);
assert!(request.body.contains("grant_type=authorization_code"));
assert!(request.body.contains("code=auth-code"));
assert!(request.body.contains("code_verifier=verifier"));
assert!(request.body.contains("client_id=client-id"));
assert!(!request.body.contains("client_secret"));
server.await.unwrap();
}
#[tokio::test]
async fn test_authorization_code_exchange_uses_basic_auth_by_default() {
let (issuer_url, request, server) = spawn_captured_token_server().await;
let source = AuthorizationCodeSource::new(
issuer_url,
"client-id".to_string(),
Some("secret".to_string()),
ClientAuthMethod::ClientSecretBasic,
vec!["openid".to_string()],
None,
None,
AuthorizationCodeOptions::new(),
)
.unwrap();
source
.exchange_code(
"auth-code",
Some(PkceCodeVerifier::new("verifier".to_string())),
)
.await
.unwrap();
let request = request.lock().unwrap().take().unwrap();
assert_eq!(
request.header("authorization").as_deref(),
Some(basic_authorization("client-id", "secret").as_str())
);
assert!(request.body.contains("grant_type=authorization_code"));
assert!(request.body.contains("code=auth-code"));
assert!(request.body.contains("code_verifier=verifier"));
assert!(!request.body.contains("client_secret"));
server.await.unwrap();
}
#[tokio::test]
async fn test_authorization_code_exchange_supports_client_secret_post() {
let (issuer_url, request, server) = spawn_captured_token_server().await;
let source = AuthorizationCodeSource::new(
issuer_url,
"client-id".to_string(),
Some("secret".to_string()),
ClientAuthMethod::ClientSecretPost,
vec!["openid".to_string()],
None,
None,
AuthorizationCodeOptions::new(),
)
.unwrap();
source
.exchange_code(
"auth-code",
Some(PkceCodeVerifier::new("verifier".to_string())),
)
.await
.unwrap();
let request = request.lock().unwrap().take().unwrap();
assert_eq!(request.header("authorization"), None);
assert!(request.body.contains("client_id=client-id"));
assert!(request.body.contains("client_secret=secret"));
server.await.unwrap();
}
#[tokio::test]
async fn test_refresh_uses_basic_auth_by_default() {
let (issuer_url, request, server) = spawn_captured_token_server().await;
let source = AuthorizationCodeSource::new(
issuer_url,
"client-id".to_string(),
Some("secret".to_string()),
ClientAuthMethod::ClientSecretBasic,
vec!["openid".to_string()],
None,
None,
AuthorizationCodeOptions::new(),
)
.unwrap();
assert!(matches!(
source.oidc.refresh_token("refresh-token").await.unwrap(),
RefreshResult::Refreshed(_)
));
let request = request.lock().unwrap().take().unwrap();
assert_eq!(
request.header("authorization").as_deref(),
Some(basic_authorization("client-id", "secret").as_str())
);
assert!(request.body.contains("grant_type=refresh_token"));
assert!(request.body.contains("refresh_token=refresh-token"));
assert!(!request.body.contains("client_secret"));
server.await.unwrap();
}
#[tokio::test]
async fn test_refresh_supports_client_secret_post_and_rotation() {
let (issuer_url, request, server) = spawn_captured_token_server().await;
let source = AuthorizationCodeSource::new(
issuer_url,
"client-id".to_string(),
Some("secret".to_string()),
ClientAuthMethod::ClientSecretPost,
vec!["openid".to_string()],
None,
None,
AuthorizationCodeOptions::new(),
)
.unwrap();
let response = match source.oidc.refresh_token("old-refresh").await.unwrap() {
RefreshResult::Refreshed(response) => response,
other => panic!("expected refresh, got {other:?}"),
};
assert_eq!(response.refresh_token().unwrap().secret(), "refresh");
let request = request.lock().unwrap().take().unwrap();
assert_eq!(request.header("authorization"), None);
assert!(request.body.contains("grant_type=refresh_token"));
assert!(request.body.contains("refresh_token=old-refresh"));
assert!(request.body.contains("client_id=client-id"));
assert!(request.body.contains("client_secret=secret"));
server.await.unwrap();
}
#[tokio::test]
async fn test_refresh_invalid_grant_requires_reauthentication() {
let (issuer_url, server) =
spawn_refresh_error_server("400 Bad Request", r#"{"error":"invalid_grant"}"#).await;
let source = AuthorizationCodeSource::new(
issuer_url,
"client-id".to_string(),
None,
ClientAuthMethod::None,
vec!["openid".to_string()],
None,
None,
AuthorizationCodeOptions::new(),
)
.unwrap();
assert!(matches!(
source.oidc.refresh_token("revoked").await.unwrap(),
RefreshResult::Reauthenticate
));
server.await.unwrap();
}
#[tokio::test]
async fn test_refresh_transient_failure_remains_retryable() {
let (issuer_url, server) = spawn_refresh_error_server(
"503 Service Unavailable",
r#"{"error":"temporarily_unavailable"}"#,
)
.await;
let source = AuthorizationCodeSource::new(
issuer_url,
"client-id".to_string(),
None,
ClientAuthMethod::None,
vec!["openid".to_string()],
None,
None,
AuthorizationCodeOptions::new(),
)
.unwrap();
let err = source.oidc.refresh_token("still-valid").await.unwrap_err();
assert!(matches!(
err,
Error::Runtime { message }
if message.contains("503 Service Unavailable") && message.contains("transient")
));
server.await.unwrap();
}
#[tokio::test]
async fn test_token_request_rejects_insecure_redirect() {
let (issuer_url, server) = spawn_redirecting_token_server().await;
let source = ClientCredentialsSource::new(
issuer_url,
"client-id".to_string(),
Some("secret".to_string()),
ClientAuthMethod::ClientSecretBasic,
vec!["scope".to_string()],
None,
None,
)
.unwrap();
let err = TokenSource::fetch_token(&source).await.unwrap_err();
let Error::Runtime { message } = &err else {
panic!("expected runtime error, got {err:?}");
};
assert!(message.contains("redirect"));
// The insecure redirect target must never be contacted.
assert!(!message.contains("idp.example.com"));
server.await.unwrap();
}
#[tokio::test]
async fn test_malformed_token_response_error_does_not_leak_body() {
let (issuer_url, server) = spawn_malformed_token_server().await;
let source = ClientCredentialsSource::new(
issuer_url,
"client-id".to_string(),
Some("secret".to_string()),
ClientAuthMethod::ClientSecretBasic,
vec!["scope".to_string()],
None,
None,
)
.unwrap();
let err = TokenSource::fetch_token(&source).await.unwrap_err();
let message = format!("{err:?}");
assert!(!message.contains("leak-marker"));
assert!(matches!(
err,
Error::Runtime { message }
if message.contains("could not be parsed") || message.contains("Content-Type")
));
server.await.unwrap();
}
@@ -0,0 +1,255 @@
// SPDX-License-Identifier: Apache-2.0
// SPDX-FileCopyrightText: Copyright The LanceDB Authors
use super::*;
fn test_config(flow: OAuthFlow, client_secret: Option<String>) -> OAuthConfig {
OAuthConfig {
issuer_url: "https://issuer.example.com".to_string(),
client_id: "client-id".to_string(),
client_secret,
scopes: vec!["scope".to_string()],
resource: None,
audience: None,
flow,
client_auth_method: None,
refresh_buffer_secs: None,
token_cache: None,
}
}
#[test]
fn test_oauth_config_debug_redacts_client_secret() {
let mut config = test_config(
OAuthFlow::ClientCredentials,
Some("super-secret".to_string()),
);
config.client_auth_method = Some(ClientAuthMethod::ClientSecretBasic);
let debug = format!("{config:?}");
assert!(!debug.contains("super-secret"));
assert!(debug.contains("client_secret: Some(\"<redacted>\")"));
assert!(debug.contains("client_auth_method"));
}
#[test]
fn test_oauth_header_provider_debug_redacts_client_secret() {
let config = test_config(
OAuthFlow::ClientCredentials,
Some("super-secret".to_string()),
);
let provider = OAuthHeaderProvider::new(config).unwrap();
let debug = format!("{provider:?}");
assert!(!debug.contains("super-secret"));
assert!(debug.contains("client_secret: Some(\"<redacted>\")"));
}
#[test]
fn test_managed_identity_resource_from_default_scope() {
assert_eq!(
AzureImdsSource::resource_from_scopes(&["api://test/.default".to_string()]).unwrap(),
"api://test"
);
}
#[test]
fn test_managed_identity_resource_without_default_suffix() {
assert_eq!(
AzureImdsSource::resource_from_scopes(&["api://test".to_string()]).unwrap(),
"api://test"
);
}
#[test]
fn test_managed_identity_rejects_multiple_scopes() {
let config = OAuthConfig {
issuer_url: "https://login.microsoftonline.com/tenant/v2.0".to_string(),
client_id: "app-id".to_string(),
client_secret: None,
scopes: vec![
"api://test-a/.default".to_string(),
"api://test-b/.default".to_string(),
],
resource: None,
audience: None,
flow: OAuthFlow::AzureManagedIdentity { client_id: None },
client_auth_method: None,
refresh_buffer_secs: None,
token_cache: None,
};
assert!(OAuthHeaderProvider::new(config).is_err());
}
#[tokio::test]
async fn test_token_endpoint_requires_discovery_success() {
let (issuer_url, server) = spawn_discovery_error_server().await;
let source = ClientCredentialsSource::new(
issuer_url,
"client-id".to_string(),
Some("secret".to_string()),
ClientAuthMethod::ClientSecretBasic,
vec!["scope".to_string()],
None,
None,
)
.unwrap();
let err = source.oidc.get_token_endpoint().await.unwrap_err();
assert!(matches!(
err,
Error::Runtime { message }
if message.contains("OIDC discovery failed with status 503")
));
server.await.unwrap();
}
#[test]
fn test_client_credentials_requires_secret() {
let config = test_config(OAuthFlow::ClientCredentials, None);
assert!(OAuthHeaderProvider::new(config).is_err());
}
#[test]
fn test_client_credentials_rejects_insecure_non_loopback_issuer() {
let mut config = test_config(OAuthFlow::ClientCredentials, Some("secret".to_string()));
config.issuer_url = "http://issuer.example.com".to_string();
let err = OAuthHeaderProvider::new(config).unwrap_err();
assert!(matches!(
err,
Error::InvalidInput { message }
if message
== "OAuth issuer_url must use https, except for http on a loopback host"
));
}
#[test]
fn test_empty_scopes_rejected() {
let mut config = test_config(OAuthFlow::AzureManagedIdentity { client_id: None }, None);
config.scopes = vec![];
assert!(OAuthHeaderProvider::new(config).is_err());
}
#[tokio::test]
async fn test_client_credentials_token_lifecycle() {
let (issuer_url, token_requests, server) = spawn_oauth_server().await;
let config = OAuthConfig {
issuer_url,
client_id: "client-id".to_string(),
client_secret: Some("secret".to_string()),
scopes: vec!["scope".to_string()],
resource: None,
audience: None,
flow: OAuthFlow::ClientCredentials,
client_auth_method: None,
refresh_buffer_secs: Some(0),
token_cache: None,
};
let provider = OAuthHeaderProvider::new(config).unwrap();
let headers = provider.get_headers().await.unwrap();
assert_eq!(headers.get("authorization").unwrap(), "Bearer token-1");
assert_eq!(token_requests.load(Ordering::SeqCst), 1);
let headers = provider.get_headers().await.unwrap();
assert_eq!(headers.get("authorization").unwrap(), "Bearer token-1");
assert_eq!(token_requests.load(Ordering::SeqCst), 1);
provider.token_state.write().await.expires_at = Some(Instant::now() - Duration::from_secs(1));
let headers = provider.get_headers().await.unwrap();
assert_eq!(headers.get("authorization").unwrap(), "Bearer token-2");
assert_eq!(token_requests.load(Ordering::SeqCst), 2);
server.await.unwrap();
}
#[tokio::test]
async fn test_client_credentials_supports_client_secret_post() {
let (issuer_url, request, server) = spawn_captured_token_server().await;
let config = OAuthConfig {
issuer_url,
client_id: "client-id".to_string(),
client_secret: Some("secret".to_string()),
scopes: vec!["scope".to_string()],
resource: None,
audience: None,
flow: OAuthFlow::ClientCredentials,
client_auth_method: Some(ClientAuthMethod::ClientSecretPost),
refresh_buffer_secs: None,
token_cache: None,
};
let provider = OAuthHeaderProvider::new(config).unwrap();
provider.get_headers().await.unwrap();
let request = request.lock().unwrap().take().unwrap();
assert_eq!(request.header("authorization"), None);
assert!(request.body.contains("grant_type=client_credentials"));
assert!(request.body.contains("client_id=client-id"));
assert!(request.body.contains("client_secret=secret"));
assert!(request.body.contains("scope=scope"));
server.await.unwrap();
}
async fn spawn_oauth_server() -> (String, Arc<AtomicUsize>, JoinHandle<()>) {
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
let issuer_url = format!("http://{addr}");
let token_requests = Arc::new(AtomicUsize::new(0));
let server_token_requests = Arc::clone(&token_requests);
let server = tokio::spawn(async move {
for _ in 0..3 {
let (mut stream, _) = listener.accept().await.unwrap();
let request = read_http_request(&mut stream).await;
if request
.line
.starts_with("GET /.well-known/openid-configuration ")
{
let discovery = format!(r#"{{"token_endpoint":"http://{addr}/token"}}"#);
write_json_response(&mut stream, "200 OK", &discovery).await;
} else if request.line.starts_with("POST /token ") {
assert_eq!(
request.header("authorization").as_deref(),
Some(basic_authorization("client-id", "secret").as_str())
);
assert!(request.body.contains("grant_type=client_credentials"));
assert!(request.body.contains("scope=scope"));
assert!(!request.body.contains("client_secret"));
assert!(!request.body.contains("client_id"));
let token_num = server_token_requests.fetch_add(1, Ordering::SeqCst) + 1;
let token = format!(
r#"{{"access_token":"token-{token_num}","expires_in":3600,"token_type":"Bearer"}}"#
);
write_json_response(&mut stream, "200 OK", &token).await;
} else {
write_json_response(&mut stream, "404 Not Found", "{}").await;
}
}
});
(issuer_url, token_requests, server)
}
async fn spawn_discovery_error_server() -> (String, JoinHandle<()>) {
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
let issuer_url = format!("http://{addr}");
let server = tokio::spawn(async move {
let (mut stream, _) = listener.accept().await.unwrap();
let request = read_http_request(&mut stream).await;
assert!(
request
.line
.starts_with("GET /.well-known/openid-configuration ")
);
write_json_response(&mut stream, "503 Service Unavailable", "{}").await;
});
(issuer_url, server)
}
@@ -0,0 +1,300 @@
// SPDX-License-Identifier: Apache-2.0
// SPDX-FileCopyrightText: Copyright The LanceDB Authors
use super::*;
#[tokio::test]
async fn test_device_authorization_polls_until_success() {
let (issuer_url, token_requests, server) = spawn_device_server().await;
let source = DeviceCodeSource::new(
issuer_url,
"client-id".to_string(),
Some("secret".to_string()),
ClientAuthMethod::ClientSecretBasic,
vec!["openid".to_string()],
None,
None,
)
.unwrap();
let device = source.request_device_authorization().await.unwrap();
let response = source.poll_for_token(&device).await.unwrap();
assert_eq!(response.access_token.secret(), "device-token");
assert_eq!(
response
.refresh_token()
.map(|token| token.secret().as_str()),
Some("device-refresh")
);
assert_eq!(token_requests.load(Ordering::SeqCst), 3);
server.await.unwrap();
}
#[tokio::test]
async fn test_device_authorization_rejects_plaintext_verification_uri() {
let (issuer_url, server) = spawn_insecure_device_verification_server().await;
let source = DeviceCodeSource::new(
issuer_url,
"client-id".to_string(),
None,
ClientAuthMethod::None,
vec!["openid".to_string()],
None,
None,
)
.unwrap();
let Err(err) = source.request_device_authorization().await else {
panic!("expected insecure verification URI to be rejected");
};
assert!(matches!(
err,
Error::InvalidInput { message }
if message.contains("verification_uri must use https")
));
server.await.unwrap();
}
#[tokio::test]
async fn test_device_authorization_retries_transient_failures() {
let (issuer_url, token_requests, server) = spawn_device_transient_server().await;
let source = DeviceCodeSource::new(
issuer_url,
"client-id".to_string(),
None,
ClientAuthMethod::None,
vec!["openid".to_string()],
None,
None,
)
.unwrap();
let device = test_device_authorization_response(60, 1);
let response = source.poll_for_token(&device).await.unwrap();
assert_eq!(response.access_token.secret(), "device-token");
assert_eq!(token_requests.load(Ordering::SeqCst), 4);
server.await.unwrap();
}
#[tokio::test]
async fn test_device_authorization_reports_access_denied() {
let (issuer_url, server) = spawn_device_error_server("access_denied").await;
let source = DeviceCodeSource::new(
issuer_url,
"client-id".to_string(),
None,
ClientAuthMethod::None,
vec!["openid".to_string()],
None,
None,
)
.unwrap();
let device = test_device_authorization_response(60, 1);
let err = source.poll_for_token(&device).await.unwrap_err();
assert!(matches!(
err,
Error::Runtime { message }
if message == "Device authorization was denied by the user"
));
server.await.unwrap();
}
#[tokio::test]
async fn test_device_authorization_reports_provider_expiry() {
let (issuer_url, server) = spawn_device_error_server("expired_token").await;
let source = DeviceCodeSource::new(
issuer_url,
"client-id".to_string(),
None,
ClientAuthMethod::None,
vec!["openid".to_string()],
None,
None,
)
.unwrap();
let device = test_device_authorization_response(60, 1);
let err = source.poll_for_token(&device).await.unwrap_err();
assert!(matches!(
err,
Error::Runtime { message }
if message == "Device authorization expired before authentication completed"
));
server.await.unwrap();
}
#[tokio::test]
async fn test_device_authorization_stops_at_local_deadline() {
let (issuer_url, server) = spawn_discovery_server(1).await;
let source = DeviceCodeSource::new(
issuer_url,
"client-id".to_string(),
None,
ClientAuthMethod::None,
vec!["openid".to_string()],
None,
None,
)
.unwrap();
let device = test_device_authorization_response(1, 1);
let err = source.poll_for_token(&device).await.unwrap_err();
assert!(matches!(
err,
Error::Runtime { message }
if message == "Device authorization expired before authentication completed"
));
server.await.unwrap();
}
#[derive(Debug)]
struct RefreshingTokenSource {
fetches: Arc<AtomicUsize>,
refreshes: Arc<AtomicUsize>,
}
#[async_trait]
impl TokenSource for RefreshingTokenSource {
async fn fetch_token(&self) -> Result<TokenResponse> {
self.fetches.fetch_add(1, Ordering::SeqCst);
Ok(token_response("initial", Some("refresh"), Some(3600)))
}
async fn refresh_token(&self, refresh_token: &str) -> Result<RefreshResult> {
assert_eq!(refresh_token, "refresh");
self.refreshes.fetch_add(1, Ordering::SeqCst);
Ok(RefreshResult::Refreshed(token_response(
"refreshed",
None,
Some(3600),
)))
}
}
#[tokio::test]
async fn test_header_provider_uses_and_retains_refresh_token() {
let fetches = Arc::new(AtomicUsize::new(0));
let refreshes = Arc::new(AtomicUsize::new(0));
let provider = OAuthHeaderProvider {
token_source: Box::new(RefreshingTokenSource {
fetches: Arc::clone(&fetches),
refreshes: Arc::clone(&refreshes),
}),
token_state: Arc::new(RwLock::new(TokenState::new())),
refresh_buffer: Duration::ZERO,
token_cache: None,
};
assert_eq!(provider.get_valid_token().await.unwrap(), "initial");
provider.token_state.write().await.expires_at = Some(Instant::now() - Duration::from_secs(1));
assert_eq!(provider.get_valid_token().await.unwrap(), "refreshed");
assert_eq!(fetches.load(Ordering::SeqCst), 1);
assert_eq!(refreshes.load(Ordering::SeqCst), 1);
assert_eq!(
provider.token_state.read().await.refresh_token.as_deref(),
Some("refresh")
);
}
#[derive(Debug)]
struct FailedRefreshTokenSource {
fetches: Arc<AtomicUsize>,
refreshes: Arc<AtomicUsize>,
}
#[async_trait]
impl TokenSource for FailedRefreshTokenSource {
async fn fetch_token(&self) -> Result<TokenResponse> {
self.fetches.fetch_add(1, Ordering::SeqCst);
Ok(token_response(
"reauthenticated",
Some("new-refresh"),
Some(3600),
))
}
async fn refresh_token(&self, refresh_token: &str) -> Result<RefreshResult> {
assert_eq!(refresh_token, "revoked-refresh");
self.refreshes.fetch_add(1, Ordering::SeqCst);
Ok(RefreshResult::Reauthenticate)
}
}
#[tokio::test]
async fn test_header_provider_reauthenticates_after_refresh_failure() {
let fetches = Arc::new(AtomicUsize::new(0));
let refreshes = Arc::new(AtomicUsize::new(0));
let provider = OAuthHeaderProvider {
token_source: Box::new(FailedRefreshTokenSource {
fetches: Arc::clone(&fetches),
refreshes: Arc::clone(&refreshes),
}),
token_state: Arc::new(RwLock::new(TokenState {
access_token: Some("expired".to_string()),
refresh_token: Some("revoked-refresh".to_string()),
expires_at: Some(Instant::now() - Duration::from_secs(1)),
})),
refresh_buffer: Duration::ZERO,
token_cache: None,
};
assert_eq!(provider.get_valid_token().await.unwrap(), "reauthenticated");
assert_eq!(fetches.load(Ordering::SeqCst), 1);
assert_eq!(refreshes.load(Ordering::SeqCst), 1);
assert_eq!(
provider.token_state.read().await.refresh_token.as_deref(),
Some("new-refresh")
);
}
#[derive(Debug)]
struct TransientRefreshFailureSource {
fetches: Arc<AtomicUsize>,
}
#[async_trait]
impl TokenSource for TransientRefreshFailureSource {
async fn fetch_token(&self) -> Result<TokenResponse> {
self.fetches.fetch_add(1, Ordering::SeqCst);
unreachable!("a transient refresh failure must not start an interactive flow")
}
async fn refresh_token(&self, refresh_token: &str) -> Result<RefreshResult> {
assert_eq!(refresh_token, "valid-refresh");
Err(Error::Runtime {
message: "token endpoint temporarily unavailable".to_string(),
})
}
}
#[tokio::test]
async fn test_header_provider_preserves_refresh_token_after_transient_failure() {
let fetches = Arc::new(AtomicUsize::new(0));
let provider = OAuthHeaderProvider {
token_source: Box::new(TransientRefreshFailureSource {
fetches: Arc::clone(&fetches),
}),
token_state: Arc::new(RwLock::new(TokenState {
access_token: Some("expired".to_string()),
refresh_token: Some("valid-refresh".to_string()),
expires_at: Some(Instant::now() - Duration::from_secs(1)),
})),
refresh_buffer: Duration::ZERO,
token_cache: None,
};
let err = provider.get_valid_token().await.unwrap_err();
assert!(matches!(
err,
Error::Runtime { message }
if message == "token endpoint temporarily unavailable"
));
assert_eq!(fetches.load(Ordering::SeqCst), 0);
assert_eq!(
provider.token_state.read().await.refresh_token.as_deref(),
Some("valid-refresh")
);
}
@@ -0,0 +1,361 @@
// SPDX-License-Identifier: Apache-2.0
// SPDX-FileCopyrightText: Copyright The LanceDB Authors
use super::*;
#[tokio::test]
async fn test_target_parameters_across_oauth_flows() {
for (resource, audience) in [
(None, None),
(Some("https://api.example.com/a?x=1&y=two"), None),
(None, Some("audience + & / ü")),
(Some("urn:example:resource"), Some("audience + & / ü")),
] {
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
let issuer = format!("http://{addr}");
let server = tokio::spawn(async move {
let mut grants = Vec::new();
// Three discovery requests and six form submissions.
for _ in 0..9 {
let (mut stream, _) = listener.accept().await.unwrap();
let request = read_http_request(&mut stream).await;
let response = if request.line.starts_with("GET ") {
serde_json::json!({
"token_endpoint": format!("http://{addr}/token"),
"authorization_endpoint": format!("http://{addr}/authorize"),
"device_authorization_endpoint": format!("http://{addr}/device"),
})
} else {
let params: Vec<_> =
url::form_urlencoded::parse(request.body.as_bytes()).collect();
for (key, expected) in [("resource", resource), ("audience", audience)] {
let values: Vec<_> = params
.iter()
.filter(|(name, _)| name == key)
.map(|(_, value)| value.as_ref())
.collect();
assert_eq!(values, expected.into_iter().collect::<Vec<_>>());
}
if request.line.starts_with("POST /device ") {
serde_json::json!({
"device_code": "device-code", "user_code": "ABCD",
"verification_uri": format!("http://{addr}/verify"),
"expires_in": 60, "interval": 1,
})
} else {
grants.push(
params
.iter()
.find(|(key, _)| key == "grant_type")
.unwrap()
.1
.to_string(),
);
serde_json::json!({"access_token": "access", "refresh_token": "refresh", "expires_in": 3600})
}
};
write_json_response(&mut stream, "200 OK", &response.to_string()).await;
}
assert_eq!(
grants,
[
"client_credentials",
"authorization_code",
"refresh_token",
"urn:ietf:params:oauth:grant-type:device_code",
"refresh_token"
]
);
});
let credentials = ClientCredentialsSource::new(
issuer.clone(),
"client".into(),
Some("secret".into()),
ClientAuthMethod::ClientSecretBasic,
vec!["scope".into()],
resource.map(str::to_owned),
audience.map(str::to_owned),
)
.unwrap();
credentials.fetch_token().await.unwrap();
let browser = AuthorizationCodeSource::new(
issuer.clone(),
"client".into(),
None,
ClientAuthMethod::None,
vec!["scope".into()],
resource.map(str::to_owned),
audience.map(str::to_owned),
AuthorizationCodeOptions::new(),
)
.unwrap();
let request = browser.build_authorization_request().await.unwrap();
for (key, expected) in [("resource", resource), ("audience", audience)] {
let values: Vec<_> = request
.url
.query_pairs()
.filter(|(name, _)| name == key)
.map(|(_, value)| value.into_owned())
.collect();
assert_eq!(
values,
expected.into_iter().map(str::to_owned).collect::<Vec<_>>()
);
}
browser
.exchange_code("code", Some(PkceCodeVerifier::new("verifier".to_string())))
.await
.unwrap();
browser.refresh_token("refresh").await.unwrap();
let device = DeviceCodeSource::new(
issuer,
"client".into(),
None,
ClientAuthMethod::None,
vec!["scope".into()],
resource.map(str::to_owned),
audience.map(str::to_owned),
)
.unwrap();
let authorization = device.request_device_authorization().await.unwrap();
device.poll_for_token(&authorization).await.unwrap();
device.refresh_token("refresh").await.unwrap();
server.await.unwrap();
}
}
#[test]
fn test_managed_identity_rejects_target_parameters() {
for (resource, audience) in [(Some("urn:resource"), None), (None, Some("audience"))] {
let config = OAuthConfig {
issuer_url: "https://issuer.example.com".into(),
client_id: "client".into(),
client_secret: None,
scopes: vec!["api://app/.default".into()],
resource: resource.map(str::to_owned),
audience: audience.map(str::to_owned),
flow: OAuthFlow::AzureManagedIdentity { client_id: None },
client_auth_method: None,
refresh_buffer_secs: None,
token_cache: None,
};
let error = OAuthHeaderProvider::new(config).unwrap_err().to_string();
assert!(error.contains("resource and audience are not supported for AzureManagedIdentity"));
}
}
#[test]
fn test_token_state_expiry() {
let mut state = TokenState::new();
assert!(state.is_expired(Duration::from_secs(0)));
state.access_token = Some("tok".to_string());
state.expires_at = Some(Instant::now() + Duration::from_secs(600));
assert!(!state.is_expired(Duration::from_secs(300)));
assert!(state.is_expired(Duration::from_secs(601)));
state.expires_at = None;
assert!(state.is_expired(Duration::from_secs(0)));
}
#[test]
fn test_token_state_uses_default_expiry() {
let mut state = TokenState::new();
state.update(&token_response("tok", None, None));
assert!(!state.is_expired(Duration::from_secs(DEFAULT_TOKEN_TTL_SECS - 1)));
assert!(state.is_expired(Duration::from_secs(DEFAULT_TOKEN_TTL_SECS + 1)));
}
#[test]
fn test_token_state_retains_refresh_token_when_not_rotated() {
let mut state = TokenState::new();
state.update(&token_response("token-1", Some("refresh-1"), Some(60)));
state.update(&token_response("token-2", None, Some(60)));
assert_eq!(state.refresh_token.as_deref(), Some("refresh-1"));
}
#[test]
fn test_token_response_accepts_float_expires_in() {
let response: TokenResponse =
serde_json::from_str(r#"{"access_token":"tok","expires_in":3600.0}"#).unwrap();
assert_eq!(response.expires_in, Some(3600));
}
#[test]
fn test_token_response_rejects_negative_expires_in() {
let err = serde_json::from_str::<TokenResponse>(r#"{"access_token":"tok","expires_in":-1}"#)
.unwrap_err();
assert!(err.to_string().contains("invalid expires_in value: -1"));
}
#[test]
fn test_token_response_debug_redacts_access_token() {
let response = TokenResponse {
access_token: AccessToken::new("secret-token".to_string()),
refresh_token: Some(RefreshToken::new("secret-refresh-token".to_string())),
expires_in: Some(3600),
token_type: Some(BasicTokenType::Bearer),
};
let debug = format!("{response:?}");
assert!(!debug.contains("secret-token"));
assert!(!debug.contains("secret-refresh-token"));
assert!(debug.contains("access_token: \"<redacted>\""));
}
#[test]
fn test_client_auth_method_defaults() {
assert_eq!(
resolve_client_auth_method(None, Some("secret")).unwrap(),
ClientAuthMethod::ClientSecretBasic
);
assert_eq!(
resolve_client_auth_method(None, None).unwrap(),
ClientAuthMethod::None
);
}
#[test]
fn test_client_auth_method_explicit_values() {
for method in [
ClientAuthMethod::None,
ClientAuthMethod::ClientSecretBasic,
ClientAuthMethod::ClientSecretPost,
] {
let secret = (method != ClientAuthMethod::None).then_some("secret");
assert_eq!(
resolve_client_auth_method(Some(method), secret).unwrap(),
method
);
}
}
#[test]
fn test_client_auth_method_rejects_inconsistent_configuration() {
let err = resolve_client_auth_method(Some(ClientAuthMethod::None), Some("secret")).unwrap_err();
assert!(matches!(
err,
Error::InvalidInput { message }
if message == "client_auth_method None cannot be combined with client_secret"
));
for method in [
ClientAuthMethod::ClientSecretBasic,
ClientAuthMethod::ClientSecretPost,
] {
let err = resolve_client_auth_method(Some(method), None).unwrap_err();
assert!(matches!(
err,
Error::InvalidInput { message }
if message == format!("client_auth_method {method:?} requires client_secret to be set")
));
}
}
#[test]
fn test_oauth_transport_requires_https_except_for_loopback() {
assert!(validate_oauth_url("https://idp.example.com/token", "endpoint").is_ok());
assert!(validate_oauth_url("http://localhost:8080/token", "endpoint").is_ok());
assert!(validate_oauth_url("http://127.0.0.1:8080/token", "endpoint").is_ok());
assert!(validate_oauth_url("http://[::1]:8080/token", "endpoint").is_ok());
let err = validate_oauth_url("http://idp.example.com/token", "endpoint").unwrap_err();
assert!(matches!(
err,
Error::InvalidInput { message }
if message == "OAuth endpoint must use https, except for http on a loopback host"
));
}
#[test]
fn test_interactive_prompts_use_default_visible_output() {
let authorization_url = Url::parse("https://idp.example.com/authorize?state=abc").unwrap();
let authorization = authorization_prompt(&authorization_url);
let device = device_prompt("https://idp.example.com/device", "ABCD-EFGH");
let mut output = Vec::new();
write_oauth_prompt(&mut output, &authorization);
write_oauth_prompt(&mut output, &device);
let output = String::from_utf8(output).unwrap();
assert!(output.contains(authorization_url.as_str()));
assert!(output.contains("https://idp.example.com/device"));
assert!(output.contains("ABCD-EFGH"));
}
#[test]
fn test_authorization_code_options_default_to_pkce() {
let options = AuthorizationCodeOptions::new();
assert!(options.use_pkce);
assert!(options.redirect_uri.is_none());
assert!(options.callback_port.is_none());
}
#[test]
fn test_authorization_redirect_defaults_to_ipv4_loopback() {
let redirect = ResolvedRedirect::new(&AuthorizationCodeOptions::new()).unwrap();
assert_eq!(
redirect.uri,
format!("http://127.0.0.1:{DEFAULT_CALLBACK_PORT}/callback")
);
assert!(redirect.bind_addr.ip().is_loopback());
assert_eq!(redirect.bind_addr.port(), DEFAULT_CALLBACK_PORT);
assert_eq!(redirect.callback_path, "/callback");
}
#[test]
fn test_authorization_redirect_rejects_non_loopback_host() {
let options =
AuthorizationCodeOptions::new().redirect_uri("https://client.example.com/oauth/callback");
let err = ResolvedRedirect::new(&options).unwrap_err();
assert!(matches!(
err,
Error::InvalidInput { message }
if message == "OAuth redirect_uri must use http with a loopback host"
));
}
#[test]
fn test_authorization_redirect_rejects_mismatched_port() {
let options = AuthorizationCodeOptions::new()
.redirect_uri("http://127.0.0.1:8401/callback")
.callback_port(8400);
let err = ResolvedRedirect::new(&options).unwrap_err();
assert!(matches!(
err,
Error::InvalidInput { message }
if message.contains("does not match redirect_uri port")
));
}
#[test]
fn test_authorization_redirect_requires_explicit_port() {
let options = AuthorizationCodeOptions::new().redirect_uri("http://127.0.0.1/callback");
let err = ResolvedRedirect::new(&options).unwrap_err();
assert!(matches!(
err,
Error::InvalidInput { message }
if message == "OAuth redirect_uri must include a port"
));
}
#[test]
fn test_authorization_redirect_accepts_ipv6_loopback() {
let options = AuthorizationCodeOptions::new().redirect_uri("http://[::1]:8400/callback");
let redirect = ResolvedRedirect::new(&options).unwrap();
assert_eq!(
redirect.bind_addr,
"[::1]:8400".parse::<SocketAddr>().unwrap()
);
}
+485
View File
@@ -0,0 +1,485 @@
// SPDX-License-Identifier: Apache-2.0
// SPDX-FileCopyrightText: Copyright The LanceDB Authors
use super::*;
use base64::Engine;
use oauth2::TokenResponse as _;
use std::sync::atomic::{AtomicUsize, Ordering};
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::{TcpListener, TcpStream};
use tokio::task::JoinHandle;
fn token_response(
access_token: &str,
refresh_token: Option<&str>,
expires_in: Option<u64>,
) -> TokenResponse {
TokenResponse {
access_token: AccessToken::new(access_token.to_string()),
refresh_token: refresh_token.map(|token| RefreshToken::new(token.to_string())),
expires_in,
token_type: None,
}
}
fn basic_authorization(client_id: &str, client_secret: &str) -> String {
format!(
"Basic {}",
base64::engine::general_purpose::STANDARD.encode(format!("{client_id}:{client_secret}"))
)
}
struct CapturedRequest {
line: String,
headers: String,
body: String,
}
impl CapturedRequest {
fn header(&self, name: &str) -> Option<String> {
self.headers.lines().find_map(|line| {
let (key, value) = line.split_once(':')?;
key.trim()
.eq_ignore_ascii_case(name)
.then(|| value.trim().to_string())
})
}
}
async fn spawn_discovery_server(expected_requests: usize) -> (String, JoinHandle<()>) {
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
let issuer_url = format!("http://{addr}");
let server = tokio::spawn(async move {
for _ in 0..expected_requests {
let (mut stream, _) = listener.accept().await.unwrap();
let request = read_http_request(&mut stream).await;
assert!(
request
.line
.starts_with("GET /.well-known/openid-configuration ")
);
let discovery = format!(
r#"{{"token_endpoint":"http://{addr}/token","authorization_endpoint":"http://{addr}/authorize","device_authorization_endpoint":"http://{addr}/device"}}"#
);
write_json_response(&mut stream, "200 OK", &discovery).await;
}
});
(issuer_url, server)
}
async fn spawn_insecure_authorization_discovery_server() -> (String, JoinHandle<()>) {
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
let issuer_url = format!("http://{addr}");
let server = tokio::spawn(async move {
let (mut stream, _) = listener.accept().await.unwrap();
let request = read_http_request(&mut stream).await;
assert!(
request
.line
.starts_with("GET /.well-known/openid-configuration ")
);
write_json_response(
&mut stream,
"200 OK",
r#"{"token_endpoint":"https://idp.example.com/token","authorization_endpoint":"http://idp.example.com/authorize"}"#,
)
.await;
});
(issuer_url, server)
}
async fn spawn_insecure_device_verification_server() -> (String, JoinHandle<()>) {
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
let issuer_url = format!("http://{addr}");
let server = tokio::spawn(async move {
for _ in 0..2 {
let (mut stream, _) = listener.accept().await.unwrap();
let request = read_http_request(&mut stream).await;
if request
.line
.starts_with("GET /.well-known/openid-configuration ")
{
let discovery = format!(
r#"{{"token_endpoint":"http://{addr}/token","device_authorization_endpoint":"http://{addr}/device"}}"#
);
write_json_response(&mut stream, "200 OK", &discovery).await;
} else {
assert!(request.line.starts_with("POST /device "));
write_json_response(
&mut stream,
"200 OK",
r#"{"device_code":"device-code","user_code":"ABCD-EFGH","verification_uri":"http://idp.example.com/device","expires_in":60}"#,
)
.await;
}
}
});
(issuer_url, server)
}
async fn spawn_captured_token_server() -> (
String,
Arc<std::sync::Mutex<Option<CapturedRequest>>>,
JoinHandle<()>,
) {
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
let issuer_url = format!("http://{addr}");
let request = Arc::new(std::sync::Mutex::new(None));
let server_request = Arc::clone(&request);
let server = tokio::spawn(async move {
for _ in 0..2 {
let (mut stream, _) = listener.accept().await.unwrap();
let captured = read_http_request(&mut stream).await;
if captured
.line
.starts_with("GET /.well-known/openid-configuration ")
{
let discovery = format!(r#"{{"token_endpoint":"http://{addr}/token"}}"#);
write_json_response(&mut stream, "200 OK", &discovery).await;
} else if captured.line.starts_with("POST /token ") {
*server_request.lock().unwrap() = Some(captured);
write_json_response(
&mut stream,
"200 OK",
r#"{"access_token":"token","refresh_token":"refresh","expires_in":3600,"token_type":"Bearer"}"#,
)
.await;
} else {
write_json_response(&mut stream, "404 Not Found", "{}").await;
}
}
});
(issuer_url, request, server)
}
async fn spawn_redirecting_token_server() -> (String, JoinHandle<()>) {
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
let issuer_url = format!("http://{addr}");
let server = tokio::spawn(async move {
for _ in 0..2 {
let (mut stream, _) = listener.accept().await.unwrap();
let request = read_http_request(&mut stream).await;
if request
.line
.starts_with("GET /.well-known/openid-configuration ")
{
let discovery = format!(r#"{{"token_endpoint":"http://{addr}/token"}}"#);
write_json_response(&mut stream, "200 OK", &discovery).await;
} else {
assert!(request.line.starts_with("POST /token "));
let response = "HTTP/1.1 302 Found\r\nlocation: http://idp.example.com/steal\r\ncontent-length: 0\r\nconnection: close\r\n\r\n".to_string();
stream.write_all(response.as_bytes()).await.unwrap();
}
}
});
(issuer_url, server)
}
async fn spawn_malformed_token_server() -> (String, JoinHandle<()>) {
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
let issuer_url = format!("http://{addr}");
let server = tokio::spawn(async move {
for _ in 0..2 {
let (mut stream, _) = listener.accept().await.unwrap();
let request = read_http_request(&mut stream).await;
if request
.line
.starts_with("GET /.well-known/openid-configuration ")
{
let discovery = format!(r#"{{"token_endpoint":"http://{addr}/token"}}"#);
write_json_response(&mut stream, "200 OK", &discovery).await;
} else {
assert!(request.line.starts_with("POST /token "));
write_json_response(
&mut stream,
"200 OK",
r#"{"access_token":{"nested":"leak-marker-12345"}}"#,
)
.await;
}
}
});
(issuer_url, server)
}
async fn spawn_refresh_error_server(
status: &'static str,
response_body: &'static str,
) -> (String, JoinHandle<()>) {
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
let issuer_url = format!("http://{addr}");
let server = tokio::spawn(async move {
for _ in 0..2 {
let (mut stream, _) = listener.accept().await.unwrap();
let request = read_http_request(&mut stream).await;
if request
.line
.starts_with("GET /.well-known/openid-configuration ")
{
let discovery = format!(r#"{{"token_endpoint":"http://{addr}/token"}}"#);
write_json_response(&mut stream, "200 OK", &discovery).await;
} else if request.line.starts_with("POST /token ") {
assert!(request.body.contains("grant_type=refresh_token"));
assert!(request.body.contains("refresh_token="));
write_json_response(&mut stream, status, response_body).await;
} else {
write_json_response(&mut stream, "404 Not Found", "{}").await;
}
}
});
(issuer_url, server)
}
async fn spawn_device_server() -> (String, Arc<AtomicUsize>, JoinHandle<()>) {
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
let issuer_url = format!("http://{addr}");
let token_requests = Arc::new(AtomicUsize::new(0));
let server_token_requests = Arc::clone(&token_requests);
let server = tokio::spawn(async move {
for _ in 0..5 {
let (mut stream, _) = listener.accept().await.unwrap();
let request = read_http_request(&mut stream).await;
if request
.line
.starts_with("GET /.well-known/openid-configuration ")
{
let discovery = format!(
r#"{{"token_endpoint":"http://{addr}/token","device_authorization_endpoint":"http://{addr}/device"}}"#
);
write_json_response(&mut stream, "200 OK", &discovery).await;
} else if request.line.starts_with("POST /device ") {
// The resolved default for a confidential client is HTTP Basic.
assert_eq!(
request.header("authorization").as_deref(),
Some(basic_authorization("client-id", "secret").as_str())
);
assert!(request.body.contains("scope=openid"));
assert!(!request.body.contains("client_secret"));
let device = format!(
r#"{{"device_code":"device-code","user_code":"ABCD-EFGH","verification_uri":"http://{addr}/verify","verification_uri_complete":"http://{addr}/verify?user_code=ABCD-EFGH","expires_in":60,"interval":1}}"#
);
write_json_response(&mut stream, "200 OK", &device).await;
} else if request.line.starts_with("POST /token ") {
assert_eq!(
request.header("authorization").as_deref(),
Some(basic_authorization("client-id", "secret").as_str())
);
assert!(
request.body.contains(
"grant_type=urn%3Aietf%3Aparams%3Aoauth%3Agrant-type%3Adevice_code"
)
);
assert!(request.body.contains("device_code=device-code"));
assert!(!request.body.contains("client_secret"));
let request = server_token_requests.fetch_add(1, Ordering::SeqCst);
match request {
0 => {
write_json_response(
&mut stream,
"400 Bad Request",
r#"{"error":"authorization_pending"}"#,
)
.await;
}
1 => {
write_json_response(
&mut stream,
"400 Bad Request",
r#"{"error":"slow_down"}"#,
)
.await;
}
_ => {
write_json_response(
&mut stream,
"200 OK",
r#"{"access_token":"device-token","refresh_token":"device-refresh","expires_in":3600,"token_type":"Bearer"}"#,
)
.await;
}
}
} else {
write_json_response(&mut stream, "404 Not Found", "{}").await;
}
}
});
(issuer_url, token_requests, server)
}
async fn spawn_device_transient_server() -> (String, Arc<AtomicUsize>, JoinHandle<()>) {
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
let issuer_url = format!("http://{addr}");
let token_requests = Arc::new(AtomicUsize::new(0));
let server_token_requests = Arc::clone(&token_requests);
let server = tokio::spawn(async move {
for _ in 0..5 {
let (mut stream, _) = listener.accept().await.unwrap();
let request = read_http_request(&mut stream).await;
if request
.line
.starts_with("GET /.well-known/openid-configuration ")
{
let discovery = format!(r#"{{"token_endpoint":"http://{addr}/token"}}"#);
write_json_response(&mut stream, "200 OK", &discovery).await;
continue;
}
assert!(request.line.starts_with("POST /token "));
match server_token_requests.fetch_add(1, Ordering::SeqCst) {
0 => drop(stream),
1 => {
write_json_response(
&mut stream,
"503 Service Unavailable",
r#"{"error":"server_error"}"#,
)
.await;
}
2 => {
write_json_response(
&mut stream,
"400 Bad Request",
r#"{"error":"temporarily_unavailable"}"#,
)
.await;
}
_ => {
write_json_response(
&mut stream,
"200 OK",
r#"{"access_token":"device-token","expires_in":3600,"token_type":"Bearer"}"#,
)
.await;
}
}
}
});
(issuer_url, token_requests, server)
}
fn test_device_authorization_response(
expires_in: u64,
interval: u64,
) -> StandardDeviceAuthorizationResponse {
serde_json::from_str(&format!(
r#"{{"device_code":"device-code","user_code":"ABCD-EFGH","verification_uri":"http://127.0.0.1/verify","expires_in":{expires_in},"interval":{interval}}}"#
))
.unwrap()
}
async fn spawn_device_error_server(error: &'static str) -> (String, JoinHandle<()>) {
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
let issuer_url = format!("http://{addr}");
let server = tokio::spawn(async move {
for _ in 0..2 {
let (mut stream, _) = listener.accept().await.unwrap();
let request = read_http_request(&mut stream).await;
if request
.line
.starts_with("GET /.well-known/openid-configuration ")
{
let discovery = format!(r#"{{"token_endpoint":"http://{addr}/token"}}"#);
write_json_response(&mut stream, "200 OK", &discovery).await;
} else if request.line.starts_with("POST /token ") {
write_json_response(
&mut stream,
"400 Bad Request",
&format!(r#"{{"error":"{error}"}}"#),
)
.await;
} else {
write_json_response(&mut stream, "404 Not Found", "{}").await;
}
}
});
(issuer_url, server)
}
async fn read_http_request(stream: &mut TcpStream) -> CapturedRequest {
let mut buffer = Vec::new();
let mut header_end = None;
while header_end.is_none() {
let mut chunk = [0; 1024];
let read = stream.read(&mut chunk).await.unwrap();
assert_ne!(read, 0, "connection closed before request headers");
buffer.extend_from_slice(&chunk[..read]);
header_end = find_subsequence(&buffer, b"\r\n\r\n").map(|pos| pos + 4);
}
let header_end = header_end.unwrap();
let headers = String::from_utf8_lossy(&buffer[..header_end]).to_string();
let line = headers.lines().next().unwrap_or_default().to_string();
let content_length = headers
.lines()
.find_map(|line| {
let (name, value) = line.split_once(':')?;
name.eq_ignore_ascii_case("content-length")
.then(|| value.trim().parse::<usize>().ok())
.flatten()
})
.unwrap_or(0);
while buffer.len() < header_end + content_length {
let mut chunk = [0; 1024];
let read = stream.read(&mut chunk).await.unwrap();
assert_ne!(read, 0, "connection closed before request body");
buffer.extend_from_slice(&chunk[..read]);
}
let body =
String::from_utf8_lossy(&buffer[header_end..header_end + content_length]).to_string();
CapturedRequest {
line,
headers,
body,
}
}
fn find_subsequence(haystack: &[u8], needle: &[u8]) -> Option<usize> {
haystack
.windows(needle.len())
.position(|window| window == needle)
}
async fn write_json_response(stream: &mut TcpStream, status: &str, body: &str) {
let response = format!(
"HTTP/1.1 {status}\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{body}",
body.len()
);
stream.write_all(response.as_bytes()).await.unwrap();
}
mod authorization;
mod config;
mod device;
mod flows;
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+1 -727
View File
@@ -869,730 +869,4 @@ impl<S: HttpSend + 'static> ExecutionPlan for RemoteWriteExec<S> {
}
#[cfg(test)]
mod tests {
use arrow_array::record_batch;
use arrow_schema::{DataType, Field, Schema as ArrowSchema};
use datafusion::prelude::SessionContext;
use datafusion_catalog::MemTable;
use datafusion_common::{DataFusionError, Result as DataFusionResult};
use datafusion_execution::{SendableRecordBatchStream, TaskContext};
use datafusion_physical_expr::EquivalenceProperties;
use datafusion_physical_plan::stream::RecordBatchStreamAdapter;
use datafusion_physical_plan::{DisplayAs, DisplayFormatType, ExecutionPlan, PlanProperties};
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::{Arc, Mutex};
use super::RemoteWriteExec;
use super::WriteOp;
use crate::Table;
use crate::remote::ARROW_STREAM_CONTENT_TYPE;
use crate::remote::table::MergeInsertRequest;
use crate::table::datafusion::BaseTableAdapter;
fn schema_json() -> &'static str {
r#"{"fields": [{"name": "id", "type": {"type": "int32"}, "nullable": true}]}"#
}
#[tokio::test]
async fn test_remote_insert_exec_execute_empty() {
let request_count = Arc::new(AtomicUsize::new(0));
let request_count_clone = request_count.clone();
let table = Table::new_with_handler("my_table", move |request| {
let path = request.url().path();
if path == "/v1/table/my_table/describe/" {
// Return schema for BaseTableAdapter::try_new
return http::Response::builder()
.status(200)
.body(format!(r#"{{"version": 1, "schema": {}}}"#, schema_json()))
.unwrap();
}
if path == "/v1/table/my_table/insert/" {
assert_eq!(request.method(), "POST");
assert_eq!(
request.headers().get("Content-Type").unwrap(),
ARROW_STREAM_CONTENT_TYPE
);
request_count_clone.fetch_add(1, Ordering::SeqCst);
return http::Response::builder()
.status(200)
.body(r#"{"version": 2}"#.to_string())
.unwrap();
}
panic!("Unexpected request path: {}", path);
});
let schema = Arc::new(ArrowSchema::new(vec![Field::new(
"id",
DataType::Int32,
true,
)]));
// Create empty MemTable (no batches)
let source_table = MemTable::try_new(schema, vec![vec![]]).unwrap();
let ctx = SessionContext::new();
// Register the remote table as insert target
let provider = BaseTableAdapter::try_new(table.base_table().clone())
.await
.unwrap();
ctx.register_table("my_table", Arc::new(provider)).unwrap();
// Register empty source
ctx.register_table("empty_source", Arc::new(source_table))
.unwrap();
// Execute the INSERT
ctx.sql("INSERT INTO my_table SELECT * FROM empty_source")
.await
.unwrap()
.collect()
.await
.unwrap();
// Verify: should have made exactly one HTTP request even with empty input
assert_eq!(request_count.load(Ordering::SeqCst), 1);
}
#[tokio::test]
async fn test_remote_insert_exec_multi_partition() {
let request_count = Arc::new(AtomicUsize::new(0));
let request_count_clone = request_count.clone();
let table = Table::new_with_handler("my_table", move |request| {
let path = request.url().path();
if path == "/v1/table/my_table/describe/" {
// Return schema for BaseTableAdapter::try_new
return http::Response::builder()
.status(200)
.body(format!(r#"{{"version": 1, "schema": {}}}"#, schema_json()))
.unwrap();
}
if path == "/v1/table/my_table/insert/" {
assert_eq!(request.method(), "POST");
assert_eq!(
request.headers().get("Content-Type").unwrap(),
ARROW_STREAM_CONTENT_TYPE
);
request_count_clone.fetch_add(1, Ordering::SeqCst);
return http::Response::builder()
.status(200)
.body(r#"{"version": 2}"#.to_string())
.unwrap();
}
panic!("Unexpected request path: {}", path);
});
let schema = Arc::new(ArrowSchema::new(vec![Field::new(
"id",
DataType::Int32,
true,
)]));
// Create MemTable with multiple partitions and multiple batches
let source_table = MemTable::try_new(
schema,
vec![
// Partition 0
vec![
record_batch!(("id", Int32, [1, 2])).unwrap(),
record_batch!(("id", Int32, [3, 4])).unwrap(),
],
// Partition 1
vec![record_batch!(("id", Int32, [5, 6, 7])).unwrap()],
// Partition 2
vec![record_batch!(("id", Int32, [8])).unwrap()],
],
)
.unwrap();
let ctx = SessionContext::new();
// Register the remote table as insert target
let provider = BaseTableAdapter::try_new(table.base_table().clone())
.await
.unwrap();
ctx.register_table("my_table", Arc::new(provider)).unwrap();
// Register multi-partition source
ctx.register_table("multi_partition_source", Arc::new(source_table))
.unwrap();
// Get the physical plan and verify it includes a repartition to 1
let df = ctx
.sql("INSERT INTO my_table SELECT * FROM multi_partition_source")
.await
.unwrap();
let plan = df.clone().create_physical_plan().await.unwrap();
let plan_str = datafusion::physical_plan::displayable(plan.as_ref())
.indent(true)
.to_string();
// The plan should include a CoalescePartitionsExec to merge partitions
assert!(
plan_str.contains("CoalescePartitionsExec"),
"Expected CoalescePartitionsExec in plan:\n{}",
plan_str
);
// Execute the INSERT
df.collect().await.unwrap();
// Verify: should have made exactly one HTTP request despite multiple input partitions
assert_eq!(request_count.load(Ordering::SeqCst), 1);
}
/// Build a single-partition input plan from the given batches.
async fn input_plan_from_batches(
schema: Arc<ArrowSchema>,
batches: Vec<arrow_array::RecordBatch>,
) -> Arc<dyn ExecutionPlan> {
use datafusion_catalog::TableProvider;
let mem = MemTable::try_new(schema, vec![batches]).unwrap();
let ctx = SessionContext::new();
mem.scan(&ctx.state(), None, &[], None).await.unwrap()
}
/// Build a single-partition input plan from the batches spread across the
/// given partitions.
async fn input_plan_from_partitions(
schema: Arc<ArrowSchema>,
partitions: Vec<Vec<arrow_array::RecordBatch>>,
) -> Arc<dyn ExecutionPlan> {
use datafusion_catalog::TableProvider;
let mem = MemTable::try_new(schema, partitions).unwrap();
let ctx = SessionContext::new();
mem.scan(&ctx.state(), None, &[], None).await.unwrap()
}
fn counting_insert_client(
counter: Arc<AtomicUsize>,
) -> crate::remote::client::RestfulLanceDbClient<crate::remote::client::test_utils::MockSender>
{
crate::remote::client::test_utils::client_with_handler(move |request| {
let path = request.url().path();
assert_eq!(path, "/v1/table/my_table/insert/");
let query = request.url().query().unwrap_or("");
assert!(query.contains("upload_id=upload-1"), "query: {query}");
assert!(query.contains("upload_part_id="), "query: {query}");
counter.fetch_add(1, Ordering::SeqCst);
http::Response::builder()
.status(200)
.body(String::new())
.unwrap()
})
}
/// Insert handler that records the `upload_part_id` of every part request so
/// a test can assert the ids are distinct.
fn recording_insert_client(
part_ids: Arc<Mutex<Vec<String>>>,
) -> crate::remote::client::RestfulLanceDbClient<crate::remote::client::test_utils::MockSender>
{
crate::remote::client::test_utils::client_with_handler(move |request| {
assert_eq!(request.url().path(), "/v1/table/my_table/insert/");
let part_id = request
.url()
.query_pairs()
.find(|(k, _)| k == "upload_part_id")
.map(|(_, v)| v.into_owned())
.expect("upload_part_id query param");
part_ids.lock().unwrap().push(part_id);
http::Response::builder()
.status(200)
.body(String::new())
.unwrap()
})
}
/// Single-partition input plan that yields one good batch and then an error,
/// for exercising the mid-part input-error abort path in `send_one_part`.
#[derive(Debug)]
struct ErroringExec {
schema: Arc<ArrowSchema>,
properties: Arc<PlanProperties>,
}
impl ErroringExec {
fn new() -> Self {
let schema = record_batch!(("id", Int32, [1, 2])).unwrap().schema();
let properties = PlanProperties::new(
EquivalenceProperties::new(schema.clone()),
datafusion_physical_plan::Partitioning::UnknownPartitioning(1),
datafusion_physical_plan::execution_plan::EmissionType::Incremental,
datafusion_physical_plan::execution_plan::Boundedness::Bounded,
);
Self {
schema,
properties: Arc::new(properties),
}
}
}
impl DisplayAs for ErroringExec {
fn fmt_as(
&self,
_t: DisplayFormatType,
f: &mut std::fmt::Formatter<'_>,
) -> std::fmt::Result {
write!(f, "ErroringExec")
}
}
impl ExecutionPlan for ErroringExec {
fn name(&self) -> &str {
"ErroringExec"
}
fn properties(&self) -> &Arc<PlanProperties> {
&self.properties
}
fn children(&self) -> Vec<&Arc<dyn ExecutionPlan>> {
vec![]
}
fn with_new_children(
self: Arc<Self>,
_children: Vec<Arc<dyn ExecutionPlan>>,
) -> DataFusionResult<Arc<dyn ExecutionPlan>> {
Ok(self)
}
fn execute(
&self,
_partition: usize,
_context: Arc<TaskContext>,
) -> DataFusionResult<SendableRecordBatchStream> {
let batch = record_batch!(("id", Int32, [1, 2])).unwrap();
let stream = futures::stream::iter(vec![
Ok(batch),
Err(DataFusionError::Execution("boom".to_string())),
]);
Ok(Box::pin(RecordBatchStreamAdapter::new(
self.schema.clone(),
stream,
)))
}
}
#[tokio::test]
async fn test_multipart_chunked_splits_into_parts() {
use futures::StreamExt;
let insert_count = Arc::new(AtomicUsize::new(0));
let client = counting_insert_client(insert_count.clone());
let schema = Arc::new(ArrowSchema::new(vec![Field::new(
"id",
DataType::Int32,
true,
)]));
let batches = vec![
record_batch!(("id", Int32, [1, 2])).unwrap(),
record_batch!(("id", Int32, [3, 4])).unwrap(),
record_batch!(("id", Int32, [5, 6])).unwrap(),
];
let input = input_plan_from_batches(schema, batches).await;
// A 1-byte budget forces every batch into its own part.
let exec = RemoteWriteExec::new_multipart(
"my_table".to_string(),
"my_table".to_string(),
client,
input,
false,
"upload-1".to_string(),
None,
None,
Some(1),
None,
);
let mut stream = exec.execute(0, Arc::new(TaskContext::default())).unwrap();
while stream.next().await.transpose().unwrap().is_some() {}
assert_eq!(insert_count.load(Ordering::SeqCst), 3);
}
#[tokio::test]
async fn test_multipart_single_part_when_under_budget() {
use futures::StreamExt;
let insert_count = Arc::new(AtomicUsize::new(0));
let client = counting_insert_client(insert_count.clone());
let schema = Arc::new(ArrowSchema::new(vec![Field::new(
"id",
DataType::Int32,
true,
)]));
let batches = vec![
record_batch!(("id", Int32, [1, 2])).unwrap(),
record_batch!(("id", Int32, [3, 4])).unwrap(),
record_batch!(("id", Int32, [5, 6])).unwrap(),
];
let input = input_plan_from_batches(schema, batches).await;
// A large byte budget and no time limit keep the whole partition in a
// single part.
let exec = RemoteWriteExec::new_multipart(
"my_table".to_string(),
"my_table".to_string(),
client,
input,
false,
"upload-1".to_string(),
None,
None,
Some(64 * 1024 * 1024),
None,
);
let mut stream = exec.execute(0, Arc::new(TaskContext::default())).unwrap();
while stream.next().await.transpose().unwrap().is_some() {}
assert_eq!(insert_count.load(Ordering::SeqCst), 1);
}
#[tokio::test]
async fn test_multipart_chunked_splits_by_duration() {
use futures::StreamExt;
let insert_count = Arc::new(AtomicUsize::new(0));
let client = counting_insert_client(insert_count.clone());
let schema = Arc::new(ArrowSchema::new(vec![Field::new(
"id",
DataType::Int32,
true,
)]));
let batches = vec![
record_batch!(("id", Int32, [1, 2])).unwrap(),
record_batch!(("id", Int32, [3, 4])).unwrap(),
record_batch!(("id", Int32, [5, 6])).unwrap(),
];
let input = input_plan_from_batches(schema, batches).await;
// A large byte budget but a tiny duration budget: writing and sending
// one batch already takes longer than the limit, so each batch is cut
// into its own part on the time check rather than the byte check.
let exec = RemoteWriteExec::new_multipart(
"my_table".to_string(),
"my_table".to_string(),
client,
input,
false,
"upload-1".to_string(),
None,
None,
Some(64 * 1024 * 1024),
Some(std::time::Duration::from_nanos(1)),
);
let mut stream = exec.execute(0, Arc::new(TaskContext::default())).unwrap();
while stream.next().await.transpose().unwrap().is_some() {}
assert_eq!(insert_count.load(Ordering::SeqCst), 3);
}
#[tokio::test]
async fn test_multipart_empty_partition_stages_nothing() {
use futures::StreamExt;
let insert_count = Arc::new(AtomicUsize::new(0));
let client = counting_insert_client(insert_count.clone());
let schema = Arc::new(ArrowSchema::new(vec![Field::new(
"id",
DataType::Int32,
true,
)]));
// An empty partition should stage no parts; on the multipart path the
// write relies on another partition having data to commit.
let input = input_plan_from_batches(schema, vec![]).await;
let exec = RemoteWriteExec::new_multipart(
"my_table".to_string(),
"my_table".to_string(),
client,
input,
false,
"upload-1".to_string(),
None,
None,
Some(64 * 1024 * 1024),
None,
);
let mut stream = exec.execute(0, Arc::new(TaskContext::default())).unwrap();
while stream.next().await.transpose().unwrap().is_some() {}
assert_eq!(insert_count.load(Ordering::SeqCst), 0);
}
#[tokio::test]
async fn test_multipart_chunked_uses_distinct_part_ids() {
use futures::StreamExt;
use std::collections::HashSet;
let part_ids = Arc::new(Mutex::new(Vec::new()));
let client = recording_insert_client(part_ids.clone());
let schema = Arc::new(ArrowSchema::new(vec![Field::new(
"id",
DataType::Int32,
true,
)]));
let batches = vec![
record_batch!(("id", Int32, [1, 2])).unwrap(),
record_batch!(("id", Int32, [3, 4])).unwrap(),
record_batch!(("id", Int32, [5, 6])).unwrap(),
];
let input = input_plan_from_batches(schema, batches).await;
// A 1-byte budget forces every batch into its own part.
let exec = RemoteWriteExec::new_multipart(
"my_table".to_string(),
"my_table".to_string(),
client,
input,
false,
"upload-1".to_string(),
None,
None,
Some(1),
None,
);
let mut stream = exec.execute(0, Arc::new(TaskContext::default())).unwrap();
while stream.next().await.transpose().unwrap().is_some() {}
let ids = part_ids.lock().unwrap().clone();
assert_eq!(ids.len(), 3, "expected one part id per part: {ids:?}");
assert!(
ids.iter().all(|id| !id.is_empty()),
"part ids must be non-empty: {ids:?}"
);
let unique: HashSet<&String> = ids.iter().collect();
assert_eq!(unique.len(), 3, "part ids must be distinct: {ids:?}");
}
#[tokio::test]
async fn test_multipart_chunks_each_partition_independently() {
use futures::StreamExt;
let insert_count = Arc::new(AtomicUsize::new(0));
let client = counting_insert_client(insert_count.clone());
let schema = Arc::new(ArrowSchema::new(vec![Field::new(
"id",
DataType::Int32,
true,
)]));
let partitions = vec![
// Partition 0: two batches, split into two parts by the 1-byte budget.
vec![
record_batch!(("id", Int32, [1, 2])).unwrap(),
record_batch!(("id", Int32, [3, 4])).unwrap(),
],
// Partition 1: one batch, one part.
vec![record_batch!(("id", Int32, [5, 6])).unwrap()],
];
let input = input_plan_from_partitions(schema, partitions).await;
let exec = RemoteWriteExec::new_multipart(
"my_table".to_string(),
"my_table".to_string(),
client,
input,
false,
"upload-1".to_string(),
None,
None,
Some(1),
None,
);
for partition in 0..2 {
let mut stream = exec
.execute(partition, Arc::new(TaskContext::default()))
.unwrap();
while stream.next().await.transpose().unwrap().is_some() {}
}
// 2 parts from partition 0 + 1 part from partition 1.
assert_eq!(insert_count.load(Ordering::SeqCst), 3);
}
#[tokio::test]
async fn test_multipart_input_error_surfaces_original() {
use futures::StreamExt;
let insert_count = Arc::new(AtomicUsize::new(0));
let client = counting_insert_client(insert_count.clone());
// A large byte budget keeps the good batch and the following error in
// the same part, exercising the mid-part abort path.
let input: Arc<dyn ExecutionPlan> = Arc::new(ErroringExec::new());
let exec = RemoteWriteExec::new_multipart(
"my_table".to_string(),
"my_table".to_string(),
client,
input,
false,
"upload-1".to_string(),
None,
None,
Some(64 * 1024 * 1024),
None,
);
let mut stream = exec.execute(0, Arc::new(TaskContext::default())).unwrap();
let mut err = None;
while let Some(item) = stream.next().await {
if let Err(e) = item {
err = Some(e);
break;
}
}
let err = err.expect("expected the input stream error to surface");
// The original DataFusion error must win over the HTTP error it induces.
assert!(
err.to_string().contains("boom"),
"expected original input error, got: {err}"
);
}
#[tokio::test]
async fn test_merge_insert_input_error_surfaces_original() {
// Regression test for #2339 on the single-request merge_insert path.
// When the input stream errors mid-body, Hyper masks it under HTTP2 as a
// generic "stream error sent by user" message. The error side-channel
// must recover and surface the original DataFusion error instead.
use futures::StreamExt;
let client = crate::remote::client::test_utils::client_with_handler(|request| {
assert_eq!(request.url().path(), "/v1/table/my_table/merge_insert/");
http::Response::builder()
.status(200)
.body(
r#"{"version": 2, "num_updated_rows": 0, "num_inserted_rows": 0, "num_deleted_rows": 0}"#
.to_string(),
)
.unwrap()
});
let query = MergeInsertRequest {
on: vec!["id".to_string()],
when_matched_update_all: false,
when_matched_update_all_filt: None,
when_not_matched_insert_all: false,
when_not_matched_by_source_delete: false,
when_not_matched_by_source_delete_filt: None,
use_index: true,
use_lsm: None,
};
let input: Arc<dyn ExecutionPlan> = Arc::new(ErroringExec::new());
let exec = RemoteWriteExec::new(
"my_table".to_string(),
"my_table".to_string(),
client,
input,
WriteOp::MergeInsert {
query,
timeout: None,
},
None,
None,
);
let mut stream = exec.execute(0, Arc::new(TaskContext::default())).unwrap();
let mut err = None;
while let Some(item) = stream.next().await {
if let Err(e) = item {
err = Some(e);
break;
}
}
let err = err.expect("expected the input stream error to surface");
assert!(
err.to_string().contains("boom"),
"expected original input error, got: {err}"
);
}
#[tokio::test]
async fn test_multipart_records_progress_within_a_part() {
use crate::table::write_progress::{ProgressCallback, WriteProgress, WriteProgressTracker};
use futures::StreamExt;
let insert_count = Arc::new(AtomicUsize::new(0));
let client = counting_insert_client(insert_count.clone());
let schema = Arc::new(ArrowSchema::new(vec![Field::new(
"id",
DataType::Int32,
true,
)]));
let batches = vec![
record_batch!(("id", Int32, [1, 2])).unwrap(),
record_batch!(("id", Int32, [3, 4])).unwrap(),
record_batch!(("id", Int32, [5, 6])).unwrap(),
];
let input = input_plan_from_batches(schema, batches).await;
let observed = Arc::new(Mutex::new(Vec::<usize>::new()));
let observed_cb = observed.clone();
let callback: ProgressCallback = Arc::new(Mutex::new(move |p: &WriteProgress| {
observed_cb.lock().unwrap().push(p.output_bytes());
}));
let tracker = Arc::new(WriteProgressTracker::new(callback, None));
// A large byte budget keeps all three batches in one part; smooth
// progress therefore requires bytes to be reported per chunk rather than
// once when the part completes.
let exec = RemoteWriteExec::new_multipart(
"my_table".to_string(),
"my_table".to_string(),
client,
input,
false,
"upload-1".to_string(),
Some(tracker),
None,
Some(64 * 1024 * 1024),
None,
);
let mut stream = exec.execute(0, Arc::new(TaskContext::default())).unwrap();
while stream.next().await.transpose().unwrap().is_some() {}
assert_eq!(
insert_count.load(Ordering::SeqCst),
1,
"batches should all land in a single part"
);
let observed = observed.lock().unwrap();
assert!(
observed.len() > 1,
"expected multiple incremental progress updates within the part: {observed:?}"
);
assert!(
observed.windows(2).all(|w| w[1] >= w[0]),
"progress bytes should be monotonic: {observed:?}"
);
assert!(
*observed.last().unwrap() > 0,
"final progress should report bytes: {observed:?}"
);
}
}
mod tests;
@@ -0,0 +1,722 @@
// SPDX-License-Identifier: Apache-2.0
// SPDX-FileCopyrightText: Copyright The LanceDB Authors
use arrow_array::record_batch;
use arrow_schema::{DataType, Field, Schema as ArrowSchema};
use datafusion::prelude::SessionContext;
use datafusion_catalog::MemTable;
use datafusion_common::{DataFusionError, Result as DataFusionResult};
use datafusion_execution::{SendableRecordBatchStream, TaskContext};
use datafusion_physical_expr::EquivalenceProperties;
use datafusion_physical_plan::stream::RecordBatchStreamAdapter;
use datafusion_physical_plan::{DisplayAs, DisplayFormatType, ExecutionPlan, PlanProperties};
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::{Arc, Mutex};
use super::RemoteWriteExec;
use super::WriteOp;
use crate::Table;
use crate::remote::ARROW_STREAM_CONTENT_TYPE;
use crate::remote::table::MergeInsertRequest;
use crate::table::datafusion::BaseTableAdapter;
fn schema_json() -> &'static str {
r#"{"fields": [{"name": "id", "type": {"type": "int32"}, "nullable": true}]}"#
}
#[tokio::test]
async fn test_remote_insert_exec_execute_empty() {
let request_count = Arc::new(AtomicUsize::new(0));
let request_count_clone = request_count.clone();
let table = Table::new_with_handler("my_table", move |request| {
let path = request.url().path();
if path == "/v1/table/my_table/describe/" {
// Return schema for BaseTableAdapter::try_new
return http::Response::builder()
.status(200)
.body(format!(r#"{{"version": 1, "schema": {}}}"#, schema_json()))
.unwrap();
}
if path == "/v1/table/my_table/insert/" {
assert_eq!(request.method(), "POST");
assert_eq!(
request.headers().get("Content-Type").unwrap(),
ARROW_STREAM_CONTENT_TYPE
);
request_count_clone.fetch_add(1, Ordering::SeqCst);
return http::Response::builder()
.status(200)
.body(r#"{"version": 2}"#.to_string())
.unwrap();
}
panic!("Unexpected request path: {}", path);
});
let schema = Arc::new(ArrowSchema::new(vec![Field::new(
"id",
DataType::Int32,
true,
)]));
// Create empty MemTable (no batches)
let source_table = MemTable::try_new(schema, vec![vec![]]).unwrap();
let ctx = SessionContext::new();
// Register the remote table as insert target
let provider = BaseTableAdapter::try_new(table.base_table().clone())
.await
.unwrap();
ctx.register_table("my_table", Arc::new(provider)).unwrap();
// Register empty source
ctx.register_table("empty_source", Arc::new(source_table))
.unwrap();
// Execute the INSERT
ctx.sql("INSERT INTO my_table SELECT * FROM empty_source")
.await
.unwrap()
.collect()
.await
.unwrap();
// Verify: should have made exactly one HTTP request even with empty input
assert_eq!(request_count.load(Ordering::SeqCst), 1);
}
#[tokio::test]
async fn test_remote_insert_exec_multi_partition() {
let request_count = Arc::new(AtomicUsize::new(0));
let request_count_clone = request_count.clone();
let table = Table::new_with_handler("my_table", move |request| {
let path = request.url().path();
if path == "/v1/table/my_table/describe/" {
// Return schema for BaseTableAdapter::try_new
return http::Response::builder()
.status(200)
.body(format!(r#"{{"version": 1, "schema": {}}}"#, schema_json()))
.unwrap();
}
if path == "/v1/table/my_table/insert/" {
assert_eq!(request.method(), "POST");
assert_eq!(
request.headers().get("Content-Type").unwrap(),
ARROW_STREAM_CONTENT_TYPE
);
request_count_clone.fetch_add(1, Ordering::SeqCst);
return http::Response::builder()
.status(200)
.body(r#"{"version": 2}"#.to_string())
.unwrap();
}
panic!("Unexpected request path: {}", path);
});
let schema = Arc::new(ArrowSchema::new(vec![Field::new(
"id",
DataType::Int32,
true,
)]));
// Create MemTable with multiple partitions and multiple batches
let source_table = MemTable::try_new(
schema,
vec![
// Partition 0
vec![
record_batch!(("id", Int32, [1, 2])).unwrap(),
record_batch!(("id", Int32, [3, 4])).unwrap(),
],
// Partition 1
vec![record_batch!(("id", Int32, [5, 6, 7])).unwrap()],
// Partition 2
vec![record_batch!(("id", Int32, [8])).unwrap()],
],
)
.unwrap();
let ctx = SessionContext::new();
// Register the remote table as insert target
let provider = BaseTableAdapter::try_new(table.base_table().clone())
.await
.unwrap();
ctx.register_table("my_table", Arc::new(provider)).unwrap();
// Register multi-partition source
ctx.register_table("multi_partition_source", Arc::new(source_table))
.unwrap();
// Get the physical plan and verify it includes a repartition to 1
let df = ctx
.sql("INSERT INTO my_table SELECT * FROM multi_partition_source")
.await
.unwrap();
let plan = df.clone().create_physical_plan().await.unwrap();
let plan_str = datafusion::physical_plan::displayable(plan.as_ref())
.indent(true)
.to_string();
// The plan should include a CoalescePartitionsExec to merge partitions
assert!(
plan_str.contains("CoalescePartitionsExec"),
"Expected CoalescePartitionsExec in plan:\n{}",
plan_str
);
// Execute the INSERT
df.collect().await.unwrap();
// Verify: should have made exactly one HTTP request despite multiple input partitions
assert_eq!(request_count.load(Ordering::SeqCst), 1);
}
/// Build a single-partition input plan from the given batches.
async fn input_plan_from_batches(
schema: Arc<ArrowSchema>,
batches: Vec<arrow_array::RecordBatch>,
) -> Arc<dyn ExecutionPlan> {
use datafusion_catalog::TableProvider;
let mem = MemTable::try_new(schema, vec![batches]).unwrap();
let ctx = SessionContext::new();
mem.scan(&ctx.state(), None, &[], None).await.unwrap()
}
/// Build a single-partition input plan from the batches spread across the
/// given partitions.
async fn input_plan_from_partitions(
schema: Arc<ArrowSchema>,
partitions: Vec<Vec<arrow_array::RecordBatch>>,
) -> Arc<dyn ExecutionPlan> {
use datafusion_catalog::TableProvider;
let mem = MemTable::try_new(schema, partitions).unwrap();
let ctx = SessionContext::new();
mem.scan(&ctx.state(), None, &[], None).await.unwrap()
}
fn counting_insert_client(
counter: Arc<AtomicUsize>,
) -> crate::remote::client::RestfulLanceDbClient<crate::remote::client::test_utils::MockSender> {
crate::remote::client::test_utils::client_with_handler(move |request| {
let path = request.url().path();
assert_eq!(path, "/v1/table/my_table/insert/");
let query = request.url().query().unwrap_or("");
assert!(query.contains("upload_id=upload-1"), "query: {query}");
assert!(query.contains("upload_part_id="), "query: {query}");
counter.fetch_add(1, Ordering::SeqCst);
http::Response::builder()
.status(200)
.body(String::new())
.unwrap()
})
}
/// Insert handler that records the `upload_part_id` of every part request so
/// a test can assert the ids are distinct.
fn recording_insert_client(
part_ids: Arc<Mutex<Vec<String>>>,
) -> crate::remote::client::RestfulLanceDbClient<crate::remote::client::test_utils::MockSender> {
crate::remote::client::test_utils::client_with_handler(move |request| {
assert_eq!(request.url().path(), "/v1/table/my_table/insert/");
let part_id = request
.url()
.query_pairs()
.find(|(k, _)| k == "upload_part_id")
.map(|(_, v)| v.into_owned())
.expect("upload_part_id query param");
part_ids.lock().unwrap().push(part_id);
http::Response::builder()
.status(200)
.body(String::new())
.unwrap()
})
}
/// Single-partition input plan that yields one good batch and then an error,
/// for exercising the mid-part input-error abort path in `send_one_part`.
#[derive(Debug)]
struct ErroringExec {
schema: Arc<ArrowSchema>,
properties: Arc<PlanProperties>,
}
impl ErroringExec {
fn new() -> Self {
let schema = record_batch!(("id", Int32, [1, 2])).unwrap().schema();
let properties = PlanProperties::new(
EquivalenceProperties::new(schema.clone()),
datafusion_physical_plan::Partitioning::UnknownPartitioning(1),
datafusion_physical_plan::execution_plan::EmissionType::Incremental,
datafusion_physical_plan::execution_plan::Boundedness::Bounded,
);
Self {
schema,
properties: Arc::new(properties),
}
}
}
impl DisplayAs for ErroringExec {
fn fmt_as(&self, _t: DisplayFormatType, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "ErroringExec")
}
}
impl ExecutionPlan for ErroringExec {
fn name(&self) -> &str {
"ErroringExec"
}
fn properties(&self) -> &Arc<PlanProperties> {
&self.properties
}
fn children(&self) -> Vec<&Arc<dyn ExecutionPlan>> {
vec![]
}
fn with_new_children(
self: Arc<Self>,
_children: Vec<Arc<dyn ExecutionPlan>>,
) -> DataFusionResult<Arc<dyn ExecutionPlan>> {
Ok(self)
}
fn execute(
&self,
_partition: usize,
_context: Arc<TaskContext>,
) -> DataFusionResult<SendableRecordBatchStream> {
let batch = record_batch!(("id", Int32, [1, 2])).unwrap();
let stream = futures::stream::iter(vec![
Ok(batch),
Err(DataFusionError::Execution("boom".to_string())),
]);
Ok(Box::pin(RecordBatchStreamAdapter::new(
self.schema.clone(),
stream,
)))
}
}
#[tokio::test]
async fn test_multipart_chunked_splits_into_parts() {
use futures::StreamExt;
let insert_count = Arc::new(AtomicUsize::new(0));
let client = counting_insert_client(insert_count.clone());
let schema = Arc::new(ArrowSchema::new(vec![Field::new(
"id",
DataType::Int32,
true,
)]));
let batches = vec![
record_batch!(("id", Int32, [1, 2])).unwrap(),
record_batch!(("id", Int32, [3, 4])).unwrap(),
record_batch!(("id", Int32, [5, 6])).unwrap(),
];
let input = input_plan_from_batches(schema, batches).await;
// A 1-byte budget forces every batch into its own part.
let exec = RemoteWriteExec::new_multipart(
"my_table".to_string(),
"my_table".to_string(),
client,
input,
false,
"upload-1".to_string(),
None,
None,
Some(1),
None,
);
let mut stream = exec.execute(0, Arc::new(TaskContext::default())).unwrap();
while stream.next().await.transpose().unwrap().is_some() {}
assert_eq!(insert_count.load(Ordering::SeqCst), 3);
}
#[tokio::test]
async fn test_multipart_single_part_when_under_budget() {
use futures::StreamExt;
let insert_count = Arc::new(AtomicUsize::new(0));
let client = counting_insert_client(insert_count.clone());
let schema = Arc::new(ArrowSchema::new(vec![Field::new(
"id",
DataType::Int32,
true,
)]));
let batches = vec![
record_batch!(("id", Int32, [1, 2])).unwrap(),
record_batch!(("id", Int32, [3, 4])).unwrap(),
record_batch!(("id", Int32, [5, 6])).unwrap(),
];
let input = input_plan_from_batches(schema, batches).await;
// A large byte budget and no time limit keep the whole partition in a
// single part.
let exec = RemoteWriteExec::new_multipart(
"my_table".to_string(),
"my_table".to_string(),
client,
input,
false,
"upload-1".to_string(),
None,
None,
Some(64 * 1024 * 1024),
None,
);
let mut stream = exec.execute(0, Arc::new(TaskContext::default())).unwrap();
while stream.next().await.transpose().unwrap().is_some() {}
assert_eq!(insert_count.load(Ordering::SeqCst), 1);
}
#[tokio::test]
async fn test_multipart_chunked_splits_by_duration() {
use futures::StreamExt;
let insert_count = Arc::new(AtomicUsize::new(0));
let client = counting_insert_client(insert_count.clone());
let schema = Arc::new(ArrowSchema::new(vec![Field::new(
"id",
DataType::Int32,
true,
)]));
let batches = vec![
record_batch!(("id", Int32, [1, 2])).unwrap(),
record_batch!(("id", Int32, [3, 4])).unwrap(),
record_batch!(("id", Int32, [5, 6])).unwrap(),
];
let input = input_plan_from_batches(schema, batches).await;
// A large byte budget but a tiny duration budget: writing and sending
// one batch already takes longer than the limit, so each batch is cut
// into its own part on the time check rather than the byte check.
let exec = RemoteWriteExec::new_multipart(
"my_table".to_string(),
"my_table".to_string(),
client,
input,
false,
"upload-1".to_string(),
None,
None,
Some(64 * 1024 * 1024),
Some(std::time::Duration::from_nanos(1)),
);
let mut stream = exec.execute(0, Arc::new(TaskContext::default())).unwrap();
while stream.next().await.transpose().unwrap().is_some() {}
assert_eq!(insert_count.load(Ordering::SeqCst), 3);
}
#[tokio::test]
async fn test_multipart_empty_partition_stages_nothing() {
use futures::StreamExt;
let insert_count = Arc::new(AtomicUsize::new(0));
let client = counting_insert_client(insert_count.clone());
let schema = Arc::new(ArrowSchema::new(vec![Field::new(
"id",
DataType::Int32,
true,
)]));
// An empty partition should stage no parts; on the multipart path the
// write relies on another partition having data to commit.
let input = input_plan_from_batches(schema, vec![]).await;
let exec = RemoteWriteExec::new_multipart(
"my_table".to_string(),
"my_table".to_string(),
client,
input,
false,
"upload-1".to_string(),
None,
None,
Some(64 * 1024 * 1024),
None,
);
let mut stream = exec.execute(0, Arc::new(TaskContext::default())).unwrap();
while stream.next().await.transpose().unwrap().is_some() {}
assert_eq!(insert_count.load(Ordering::SeqCst), 0);
}
#[tokio::test]
async fn test_multipart_chunked_uses_distinct_part_ids() {
use futures::StreamExt;
use std::collections::HashSet;
let part_ids = Arc::new(Mutex::new(Vec::new()));
let client = recording_insert_client(part_ids.clone());
let schema = Arc::new(ArrowSchema::new(vec![Field::new(
"id",
DataType::Int32,
true,
)]));
let batches = vec![
record_batch!(("id", Int32, [1, 2])).unwrap(),
record_batch!(("id", Int32, [3, 4])).unwrap(),
record_batch!(("id", Int32, [5, 6])).unwrap(),
];
let input = input_plan_from_batches(schema, batches).await;
// A 1-byte budget forces every batch into its own part.
let exec = RemoteWriteExec::new_multipart(
"my_table".to_string(),
"my_table".to_string(),
client,
input,
false,
"upload-1".to_string(),
None,
None,
Some(1),
None,
);
let mut stream = exec.execute(0, Arc::new(TaskContext::default())).unwrap();
while stream.next().await.transpose().unwrap().is_some() {}
let ids = part_ids.lock().unwrap().clone();
assert_eq!(ids.len(), 3, "expected one part id per part: {ids:?}");
assert!(
ids.iter().all(|id| !id.is_empty()),
"part ids must be non-empty: {ids:?}"
);
let unique: HashSet<&String> = ids.iter().collect();
assert_eq!(unique.len(), 3, "part ids must be distinct: {ids:?}");
}
#[tokio::test]
async fn test_multipart_chunks_each_partition_independently() {
use futures::StreamExt;
let insert_count = Arc::new(AtomicUsize::new(0));
let client = counting_insert_client(insert_count.clone());
let schema = Arc::new(ArrowSchema::new(vec![Field::new(
"id",
DataType::Int32,
true,
)]));
let partitions = vec![
// Partition 0: two batches, split into two parts by the 1-byte budget.
vec![
record_batch!(("id", Int32, [1, 2])).unwrap(),
record_batch!(("id", Int32, [3, 4])).unwrap(),
],
// Partition 1: one batch, one part.
vec![record_batch!(("id", Int32, [5, 6])).unwrap()],
];
let input = input_plan_from_partitions(schema, partitions).await;
let exec = RemoteWriteExec::new_multipart(
"my_table".to_string(),
"my_table".to_string(),
client,
input,
false,
"upload-1".to_string(),
None,
None,
Some(1),
None,
);
for partition in 0..2 {
let mut stream = exec
.execute(partition, Arc::new(TaskContext::default()))
.unwrap();
while stream.next().await.transpose().unwrap().is_some() {}
}
// 2 parts from partition 0 + 1 part from partition 1.
assert_eq!(insert_count.load(Ordering::SeqCst), 3);
}
#[tokio::test]
async fn test_multipart_input_error_surfaces_original() {
use futures::StreamExt;
let insert_count = Arc::new(AtomicUsize::new(0));
let client = counting_insert_client(insert_count.clone());
// A large byte budget keeps the good batch and the following error in
// the same part, exercising the mid-part abort path.
let input: Arc<dyn ExecutionPlan> = Arc::new(ErroringExec::new());
let exec = RemoteWriteExec::new_multipart(
"my_table".to_string(),
"my_table".to_string(),
client,
input,
false,
"upload-1".to_string(),
None,
None,
Some(64 * 1024 * 1024),
None,
);
let mut stream = exec.execute(0, Arc::new(TaskContext::default())).unwrap();
let mut err = None;
while let Some(item) = stream.next().await {
if let Err(e) = item {
err = Some(e);
break;
}
}
let err = err.expect("expected the input stream error to surface");
// The original DataFusion error must win over the HTTP error it induces.
assert!(
err.to_string().contains("boom"),
"expected original input error, got: {err}"
);
}
#[tokio::test]
async fn test_merge_insert_input_error_surfaces_original() {
// Regression test for #2339 on the single-request merge_insert path.
// When the input stream errors mid-body, Hyper masks it under HTTP2 as a
// generic "stream error sent by user" message. The error side-channel
// must recover and surface the original DataFusion error instead.
use futures::StreamExt;
let client = crate::remote::client::test_utils::client_with_handler(|request| {
assert_eq!(request.url().path(), "/v1/table/my_table/merge_insert/");
http::Response::builder()
.status(200)
.body(
r#"{"version": 2, "num_updated_rows": 0, "num_inserted_rows": 0, "num_deleted_rows": 0}"#
.to_string(),
)
.unwrap()
});
let query = MergeInsertRequest {
on: vec!["id".to_string()],
when_matched_update_all: false,
when_matched_update_all_filt: None,
when_not_matched_insert_all: false,
when_not_matched_by_source_delete: false,
when_not_matched_by_source_delete_filt: None,
use_index: true,
use_lsm: None,
};
let input: Arc<dyn ExecutionPlan> = Arc::new(ErroringExec::new());
let exec = RemoteWriteExec::new(
"my_table".to_string(),
"my_table".to_string(),
client,
input,
WriteOp::MergeInsert {
query,
timeout: None,
},
None,
None,
);
let mut stream = exec.execute(0, Arc::new(TaskContext::default())).unwrap();
let mut err = None;
while let Some(item) = stream.next().await {
if let Err(e) = item {
err = Some(e);
break;
}
}
let err = err.expect("expected the input stream error to surface");
assert!(
err.to_string().contains("boom"),
"expected original input error, got: {err}"
);
}
#[tokio::test]
async fn test_multipart_records_progress_within_a_part() {
use crate::table::write_progress::{ProgressCallback, WriteProgress, WriteProgressTracker};
use futures::StreamExt;
let insert_count = Arc::new(AtomicUsize::new(0));
let client = counting_insert_client(insert_count.clone());
let schema = Arc::new(ArrowSchema::new(vec![Field::new(
"id",
DataType::Int32,
true,
)]));
let batches = vec![
record_batch!(("id", Int32, [1, 2])).unwrap(),
record_batch!(("id", Int32, [3, 4])).unwrap(),
record_batch!(("id", Int32, [5, 6])).unwrap(),
];
let input = input_plan_from_batches(schema, batches).await;
let observed = Arc::new(Mutex::new(Vec::<usize>::new()));
let observed_cb = observed.clone();
let callback: ProgressCallback = Arc::new(Mutex::new(move |p: &WriteProgress| {
observed_cb.lock().unwrap().push(p.output_bytes());
}));
let tracker = Arc::new(WriteProgressTracker::new(callback, None));
// A large byte budget keeps all three batches in one part; smooth
// progress therefore requires bytes to be reported per chunk rather than
// once when the part completes.
let exec = RemoteWriteExec::new_multipart(
"my_table".to_string(),
"my_table".to_string(),
client,
input,
false,
"upload-1".to_string(),
Some(tracker),
None,
Some(64 * 1024 * 1024),
None,
);
let mut stream = exec.execute(0, Arc::new(TaskContext::default())).unwrap();
while stream.next().await.transpose().unwrap().is_some() {}
assert_eq!(
insert_count.load(Ordering::SeqCst),
1,
"batches should all land in a single part"
);
let observed = observed.lock().unwrap();
assert!(
observed.len() > 1,
"expected multiple incremental progress updates within the part: {observed:?}"
);
assert!(
observed.windows(2).all(|w| w[1] >= w[0]),
"progress bytes should be monotonic: {observed:?}"
);
assert!(
*observed.last().unwrap() > 0,
"final progress should report bytes: {observed:?}"
);
}
@@ -0,0 +1,753 @@
// SPDX-License-Identifier: Apache-2.0
// SPDX-FileCopyrightText: Copyright The LanceDB Authors
use super::*;
#[tokio::test]
async fn test_query_plain() {
let expected_data = RecordBatch::try_new(
Arc::new(Schema::new(vec![Field::new("a", DataType::Int32, false)])),
vec![Arc::new(Int32Array::from(vec![1, 2, 3]))],
)
.unwrap();
let expected_data_ref = expected_data.clone();
let table = Table::new_with_handler("my_table", move |request| {
assert_eq!(request.method(), "POST");
assert_eq!(request.url().path(), "/v1/table/my_table/query/");
assert_eq!(
request.headers().get("Content-Type").unwrap(),
JSON_CONTENT_TYPE
);
let body = request.body().unwrap().as_bytes().unwrap();
let body: serde_json::Value = serde_json::from_slice(body).unwrap();
let expected_body = serde_json::json!({
"filter": "`A` > 0",
"k": isize::MAX as usize,
"prefilter": true,
"vector": [], // Empty vector means no vector query.
"version": null,
});
assert_eq!(body, expected_body);
let response_body = write_ipc_file(&expected_data_ref);
http::Response::builder()
.status(200)
.header(CONTENT_TYPE, ARROW_FILE_CONTENT_TYPE)
.body(response_body)
.unwrap()
});
let query = AnyQuery::Query(QueryRequest {
filter: Some(QueryFilter::Sql(r#""A" > 0"#.into())),
..Default::default()
});
let data = table
.base_table()
.query(&query, Default::default())
.await
.unwrap()
.collect::<Vec<_>>()
.await;
assert_eq!(data.len(), 1);
assert_eq!(data[0].as_ref().unwrap(), &expected_data);
}
fn blob_describe_response() -> http::Response<String> {
let schema = Schema::new(vec![
Field::new("id", DataType::Int64, false),
crate::blob("image", true),
Field::new("caption", DataType::Utf8, true),
crate::blob("thumbnail", true),
]);
let json_schema = JsonSchema::try_from(&schema).unwrap();
http::Response::builder()
.status(200)
.body(serde_json::json!({ "version": 1, "schema": json_schema }).to_string())
.unwrap()
}
#[rstest]
#[case(semver::Version::new(0, 1, 0))]
#[case(semver::Version::new(0, 5, 0))]
#[tokio::test]
async fn test_blob_columns_read_the_schema_on_any_server_version(#[case] version: semver::Version) {
let table = Table::new_with_handler_version("my_table", version, |request| {
assert_eq!(request.url().path(), "/v1/table/my_table/describe/");
blob_describe_response()
});
let columns = table.blob_columns().await.unwrap();
assert_eq!(columns, vec!["image".to_string(), "thumbnail".to_string()]);
}
#[tokio::test]
async fn test_fetch_blobs_decodes_null_aligned_bytes() {
let mut builder = LargeBinaryBuilder::new();
builder.append_value(b"alpha");
builder.append_null();
builder.append_value(b"gamma");
let blobs = builder.finish();
let expected = RecordBatch::try_new(
Arc::new(Schema::new(vec![Field::new(
"image",
DataType::LargeBinary,
true,
)])),
vec![Arc::new(blobs)],
)
.unwrap();
let expected_ref = expected.clone();
let table = Table::new_with_handler_version(
"my_table",
semver::Version::new(0, 5, 0),
move |request| {
assert_eq!(request.method(), "POST");
assert_eq!(request.url().path(), "/v1/table/my_table/fetch_blobs/");
let body: serde_json::Value =
serde_json::from_slice(request.body().unwrap().as_bytes().unwrap()).unwrap();
assert_eq!(body["column"], "image");
assert_eq!(body["row_ids"], serde_json::json!([10, 20, 30]));
http::Response::builder()
.status(200)
.header(CONTENT_TYPE, ARROW_STREAM_CONTENT_TYPE)
.body(write_ipc_stream_uncompressed(&expected_ref))
.unwrap()
},
);
let blobs = table.fetch_blobs("image", &[10, 20, 30]).await.unwrap();
assert_eq!(blobs.len(), 3);
assert_eq!(blobs.value(0), b"alpha");
assert!(blobs.is_null(1));
assert_eq!(blobs.value(2), b"gamma");
}
#[tokio::test]
async fn test_fetch_blobs_concatenates_multiple_batches() {
let schema = Arc::new(Schema::new(vec![Field::new(
"image",
DataType::LargeBinary,
true,
)]));
let mut first_builder = LargeBinaryBuilder::new();
first_builder.append_value(b"alpha");
first_builder.append_null();
let first_batch =
RecordBatch::try_new(schema.clone(), vec![Arc::new(first_builder.finish())]).unwrap();
let mut second_builder = LargeBinaryBuilder::new();
second_builder.append_value(b"gamma");
let second_batch =
RecordBatch::try_new(schema.clone(), vec![Arc::new(second_builder.finish())]).unwrap();
let mut body = Vec::new();
{
let mut writer = arrow_ipc::writer::StreamWriter::try_new(&mut body, &schema).unwrap();
writer.write(&first_batch).unwrap();
writer.write(&second_batch).unwrap();
writer.finish().unwrap();
}
let table = Table::new_with_handler_version(
"my_table",
semver::Version::new(0, 5, 0),
move |request| {
assert_eq!(request.url().path(), "/v1/table/my_table/fetch_blobs/");
http::Response::builder()
.status(200)
.header(CONTENT_TYPE, ARROW_STREAM_CONTENT_TYPE)
.body(body.clone())
.unwrap()
},
);
let blobs = table.fetch_blobs("image", &[10, 20, 30]).await.unwrap();
assert_eq!(blobs.len(), 3);
assert_eq!(blobs.value(0), b"alpha");
assert!(blobs.is_null(1));
assert_eq!(blobs.value(2), b"gamma");
}
#[tokio::test]
async fn test_fetch_blobs_splits_row_ids_at_one_version_and_preserves_order() {
let request_sizes = Arc::new(std::sync::Mutex::new(Vec::new()));
let seen = request_sizes.clone();
let table = Table::new_with_handler_version(
"my_table",
semver::Version::new(0, 5, 0),
move |request| {
if request.url().path() == "/v1/table/my_table/describe/" {
return http::Response::builder()
.status(200)
.body(br#"{"version":7,"schema":{"fields":[]}}"#.to_vec())
.unwrap();
}
assert_eq!(request.url().path(), "/v1/table/my_table/fetch_blobs/");
let body = request_body_json(&request);
assert_eq!(body["version"], 7);
let ids = body["row_ids"].as_array().unwrap();
seen.lock().unwrap().push(ids.len());
if ids.len() > 1024 {
return http::Response::builder()
.status(400)
.body(b"fetch_blobs accepts at most 1024 row IDs".to_vec())
.unwrap();
}
let mut builder = LargeBinaryBuilder::new();
for id in ids {
let id = id.as_u64().unwrap();
if id == 1023 {
builder.append_null();
} else {
builder.append_value(id.to_string().as_bytes());
}
}
let batch = RecordBatch::try_new(
Arc::new(Schema::new(vec![Field::new(
"image",
DataType::LargeBinary,
true,
)])),
vec![Arc::new(builder.finish())],
)
.unwrap();
http::Response::builder()
.status(200)
.header(CONTENT_TYPE, ARROW_STREAM_CONTENT_TYPE)
.body(write_ipc_stream_uncompressed(&batch))
.unwrap()
},
);
let ids: Vec<u64> = (0..1024).chain([42]).collect();
let blobs = table.fetch_blobs("image", &ids).await.unwrap();
assert_eq!(blobs.len(), 1025);
assert_eq!(blobs.value(0), b"0");
assert_eq!(blobs.value(1022), b"1022");
assert!(blobs.is_null(1023));
assert_eq!(blobs.value(1024), b"42");
assert_eq!(request_sizes.lock().unwrap().as_slice(), &[1024, 1]);
}
#[tokio::test]
async fn test_fetch_blobs_splits_byte_limited_requests_and_reads_large_blob_by_range() {
// Simulate a lower byte cap so this test exercises the same 400 response
// without allocating 64 MiB of blob data.
let requests = Arc::new(std::sync::Mutex::new(Vec::new()));
let seen = requests.clone();
let table = Table::new_with_handler_version(
"my_table",
semver::Version::new(0, 5, 0),
move |request| {
let path = request.url().path();
if path == "/v1/table/my_table/describe/" {
return http::Response::builder()
.status(200)
.body(br#"{"version":42,"schema":{"fields":[]}}"#.to_vec())
.unwrap();
}
if path == "/v1/table/my_table/fetch_blobs/" {
let body = request_body_json(&request);
let ids = body["row_ids"].as_array().unwrap();
if body["version"].is_null() {
// Only the initial failed request may read live latest.
assert_eq!(ids.len(), 5);
} else {
assert_eq!(body["version"], 42);
}
seen.lock().unwrap().push(format!("POST {}", ids.len()));
let mut builder = LargeBinaryBuilder::new();
let mut total_bytes = 0;
for id in ids {
let value: Option<&[u8]> = match id.as_u64().unwrap() {
10 => Some(b"aaaa"),
20 => Some(b"bbb"),
30 => None,
40 => Some(b"0123456789"),
id => panic!("unexpected row id {id}"),
};
if let Some(value) = value {
total_bytes += value.len();
builder.append_value(value);
} else {
builder.append_null();
}
}
if total_bytes > 6 {
return http::Response::builder()
.status(400)
.body(br#"{"error":"Bad request: fetch_blobs accepts at most 67108864 total blob bytes"}"#.to_vec())
.unwrap();
}
let batch = RecordBatch::try_new(
Arc::new(Schema::new(vec![Field::new(
"image",
DataType::LargeBinary,
true,
)])),
vec![Arc::new(builder.finish())],
)
.unwrap();
return http::Response::builder()
.status(200)
.header(CONTENT_TYPE, ARROW_STREAM_CONTENT_TYPE)
.body(write_ipc_stream_uncompressed(&batch))
.unwrap();
}
assert_eq!(path, "/v1/table/my_table/blob/image/40/bytes");
assert!(request.url().query().unwrap().contains("version=42"));
let range = request
.headers()
.get(reqwest::header::RANGE)
.unwrap()
.to_str()
.unwrap();
seen.lock().unwrap().push(format!("GET {range}"));
match range {
"bytes=0-0" => http::Response::builder()
.status(206)
.header(reqwest::header::CONTENT_RANGE, "bytes 0-0/10")
.header(VERSION_HEADER, "42")
.body(b"0".to_vec())
.unwrap(),
"bytes=0-" => http::Response::builder()
.status(206)
.header(reqwest::header::CONTENT_RANGE, "bytes 0-9/10")
.body(b"0123456789".to_vec())
.unwrap(),
_ => panic!("unexpected range: {range}"),
}
},
);
let blobs = table
.fetch_blobs("image", &[10, 20, 30, 40, 20])
.await
.unwrap();
assert_eq!(blobs.len(), 5);
assert_eq!(blobs.value(0), b"aaaa");
assert_eq!(blobs.value(1), b"bbb");
assert!(blobs.is_null(2));
assert_eq!(blobs.value(3), b"0123456789");
assert_eq!(blobs.value(4), b"bbb");
let requests = requests.lock().unwrap();
assert!(requests.contains(&"GET bytes=0-0".to_string()));
assert!(requests.contains(&"GET bytes=0-".to_string()));
}
#[tokio::test]
async fn test_fetch_blobs_does_not_split_unrelated_bad_requests() {
let requests = Arc::new(AtomicUsize::new(0));
let seen = requests.clone();
let table =
Table::new_with_handler_version("my_table", semver::Version::new(0, 5, 0), move |_| {
seen.fetch_add(1, Ordering::SeqCst);
http::Response::builder()
.status(400)
.body(b"unknown blob column".to_vec())
.unwrap()
});
assert_fetch_blobs_http_error(
table.fetch_blobs("missing", &[10, 20]).await.unwrap_err(),
"unknown blob column",
);
assert_eq!(requests.load(Ordering::SeqCst), 1);
}
fn table_with_fetch_blobs_response(body: Vec<u8>) -> Table {
table_with_fetch_blobs_content_type(Some(ARROW_STREAM_CONTENT_TYPE), body)
}
fn table_with_fetch_blobs_content_type(content_type: Option<&'static str>, body: Vec<u8>) -> Table {
Table::new_with_handler_version("my_table", semver::Version::new(0, 5, 0), move |request| {
assert_eq!(request.url().path(), "/v1/table/my_table/fetch_blobs/");
let mut response = http::Response::builder().status(200);
if let Some(content_type) = content_type {
response = response.header(CONTENT_TYPE, content_type);
}
response.body(body.clone()).unwrap()
})
}
fn one_row_blob_batch(column: &str) -> RecordBatch {
let mut builder = LargeBinaryBuilder::new();
builder.append_value(b"alpha");
RecordBatch::try_new(
Arc::new(Schema::new(vec![Field::new(
column,
DataType::LargeBinary,
true,
)])),
vec![Arc::new(builder.finish())],
)
.unwrap()
}
fn one_row_blob_ipc_stream(column: &str) -> Vec<u8> {
write_ipc_stream_uncompressed(&one_row_blob_batch(column))
}
#[tokio::test]
async fn test_remote_scan_order_is_not_deterministic() {
// A distributed scan answers in no fixed order, so callers that assign meaning
// to row position have to sort for themselves.
let table = Table::new_with_handler("my_table", |_| {
http::Response::builder()
.status(200)
.body(Vec::new())
.unwrap()
});
assert!(!table.base_table().scan_order_is_deterministic());
}
#[tokio::test]
async fn test_checkout_branch_pins_without_touching_the_original() {
let seen = Arc::new(std::sync::Mutex::new(Vec::new()));
let recorder = seen.clone();
let table = Table::new_with_handler_version(
"my_table",
semver::Version::new(0, 5, 0),
move |request| match request.url().path() {
"/v1/table/my_table/describe/" => http::Response::builder()
.status(200)
.body(br#"{"version": 42, "schema": {"fields": []}}"#.to_vec())
.unwrap(),
"/v1/table/my_table/count_rows/" => {
let body = request_body_json(&request);
recorder.lock().unwrap().push(body["version"].clone());
http::Response::builder()
.status(200)
.body(b"0".to_vec())
.unwrap()
}
path => panic!("unexpected request path: {path}"),
},
);
let pinned = table.checkout_branch("main", Some(42)).await.unwrap();
pinned.count_rows(None).await.unwrap();
table.count_rows(None).await.unwrap();
let seen = seen.lock().unwrap();
assert_eq!(seen[0], 42, "the pinned handle must send its version");
assert!(
seen[1].is_null(),
"the original handle must still track latest, got {:?}",
seen[1]
);
}
#[tokio::test]
async fn test_fetch_blobs_sends_the_checked_out_version() {
let ipc = one_row_blob_ipc_stream("image");
let table = Table::new_with_handler_version(
"my_table",
semver::Version::new(0, 5, 0),
move |request| match request.url().path() {
"/v1/table/my_table/describe/" => http::Response::builder()
.status(200)
.body(br#"{"version": 42, "schema": {"fields": []}}"#.to_vec())
.unwrap(),
"/v1/table/my_table/fetch_blobs/" => {
let body = request_body_json(&request);
assert_eq!(
body["version"], 42,
"blob reads must use the same snapshot as the query"
);
http::Response::builder()
.status(200)
.header(CONTENT_TYPE, ARROW_STREAM_CONTENT_TYPE)
.body(ipc.clone())
.unwrap()
}
path => panic!("unexpected request path: {path}"),
},
);
table.checkout(42).await.unwrap();
assert_eq!(table.fetch_blobs("image", &[10]).await.unwrap().len(), 1);
}
#[tokio::test]
async fn test_fetch_blobs_sends_the_checked_out_branch() {
use lance::dataset::refs::Ref;
let ipc = one_row_blob_ipc_stream("image");
let table = Table::new_with_handler_version(
"my_table",
semver::Version::new(0, 5, 0),
move |request| match request.url().path() {
"/v1/table/my_table/branches/create/" => http::Response::builder()
.status(200)
.body(b"{}".to_vec())
.unwrap(),
"/v1/table/my_table/fetch_blobs/" => {
let body = request_body_json(&request);
assert_eq!(body["branch"], "exp", "blob reads must stay on the branch");
http::Response::builder()
.status(200)
.header(CONTENT_TYPE, ARROW_STREAM_CONTENT_TYPE)
.body(ipc.clone())
.unwrap()
}
path => panic!("unexpected request path: {path}"),
},
);
let branch = table
.create_branch("exp", Ref::Version(None, None))
.await
.unwrap();
assert_eq!(branch.fetch_blobs("image", &[10]).await.unwrap().len(), 1);
}
#[tokio::test]
async fn test_fetch_blobs_sends_a_nested_column_as_a_dotted_path() {
let ipc = one_row_blob_ipc_stream("info.blob");
let table = Table::new_with_handler_version(
"my_table",
semver::Version::new(0, 5, 0),
move |request| {
let body = request_body_json(&request);
assert_eq!(body["column"], "info.blob");
http::Response::builder()
.status(200)
.header(CONTENT_TYPE, ARROW_STREAM_CONTENT_TYPE)
.body(ipc.clone())
.unwrap()
},
);
let blobs = table.fetch_blobs("info.blob", &[10]).await.unwrap();
assert_eq!(blobs.value(0), b"alpha");
}
fn write_empty_ipc_stream(schema: &Schema) -> Vec<u8> {
let mut body = Vec::new();
arrow_ipc::writer::StreamWriter::try_new(&mut body, schema)
.unwrap()
.finish()
.unwrap();
body
}
fn assert_fetch_blobs_http_error(error: Error, expected: &str) {
match error {
Error::Http {
source, request_id, ..
} => {
assert!(source.to_string().contains(expected));
assert!(!request_id.is_empty());
}
error => panic!("expected HTTP error, got {error}"),
}
}
fn assert_not_supported_error(error: Error, expected: &str) {
match error {
Error::NotSupported { message } => assert!(message.contains(expected)),
error => panic!("expected not-supported error, got {error}"),
}
}
#[tokio::test]
async fn test_fetch_blobs_rejects_missing_column() {
let batch = RecordBatch::try_new(
Arc::new(Schema::new(vec![Field::new(
"other",
DataType::LargeBinary,
true,
)])),
vec![Arc::new(LargeBinaryArray::from(vec![Some(
b"value".as_slice(),
)]))],
)
.unwrap();
let table = table_with_fetch_blobs_response(write_ipc_stream_uncompressed(&batch));
let error = table.fetch_blobs("image", &[10]).await.unwrap_err();
assert_fetch_blobs_http_error(error, "missing the 'image' column");
}
#[tokio::test]
async fn test_fetch_blobs_rejects_wrong_column_type() {
let batch = RecordBatch::try_new(
Arc::new(Schema::new(vec![Field::new(
"image",
DataType::Int32,
false,
)])),
vec![Arc::new(Int32Array::from(vec![1]))],
)
.unwrap();
let table = table_with_fetch_blobs_response(write_ipc_stream_uncompressed(&batch));
let error = table.fetch_blobs("image", &[10]).await.unwrap_err();
assert_fetch_blobs_http_error(
error,
"type Int32, expected Binary, LargeBinary, or BinaryView",
);
}
#[rstest]
#[case(DataType::Binary)]
#[case(DataType::LargeBinary)]
#[case(DataType::BinaryView)]
#[tokio::test]
async fn test_fetch_blobs_accepts_binary_large_binary_and_binary_view(#[case] data_type: DataType) {
let binary_values = BinaryArray::from(vec![
Some(b"alpha".as_slice()),
None,
Some(b"gamma".as_slice()),
]);
let typed_column = arrow::compute::cast(&binary_values, &data_type).unwrap();
let batch = RecordBatch::try_new(
Arc::new(Schema::new(vec![Field::new("image", data_type, true)])),
vec![typed_column],
)
.unwrap();
let table = table_with_fetch_blobs_response(write_ipc_stream_uncompressed(&batch));
let blobs = table.fetch_blobs("image", &[10, 20, 30]).await.unwrap();
assert_eq!(blobs.len(), 3);
assert_eq!(blobs.value(0), b"alpha");
assert!(blobs.is_null(1));
assert_eq!(blobs.value(2), b"gamma");
}
#[tokio::test]
async fn test_fetch_blobs_rejects_row_count_mismatch() {
let batch = RecordBatch::try_new(
Arc::new(Schema::new(vec![Field::new(
"image",
DataType::LargeBinary,
true,
)])),
vec![Arc::new(LargeBinaryArray::from(vec![Some(
b"value".as_slice(),
)]))],
)
.unwrap();
let table = table_with_fetch_blobs_response(write_ipc_stream_uncompressed(&batch));
let error = table.fetch_blobs("image", &[10, 20]).await.unwrap_err();
assert_fetch_blobs_http_error(error, "returned 1 rows for 2 row ids");
}
#[tokio::test]
async fn test_fetch_blobs_rejects_zero_batches_for_nonempty_row_ids() {
let schema = Schema::new(vec![Field::new("image", DataType::LargeBinary, true)]);
let table = table_with_fetch_blobs_response(write_empty_ipc_stream(&schema));
let error = table.fetch_blobs("image", &[10]).await.unwrap_err();
assert_fetch_blobs_http_error(error, "returned 0 rows for 1 row ids");
}
#[tokio::test]
async fn test_fetch_blobs_skips_the_request_for_empty_row_ids() {
let table = Table::new_with_handler("my_table", |_| -> http::Response<String> {
panic!("fetch_blobs must not call the server for an empty selection");
});
let blobs = table.fetch_blobs("image", &[]).await.unwrap();
assert!(blobs.is_empty());
}
#[tokio::test]
async fn test_blob_byte_apis_not_supported_on_old_server() {
let table = Table::new_with_handler("my_table", |_| -> http::Response<String> {
panic!("blob request must not reach a server without blob support");
});
assert_not_supported_error(
table.fetch_blobs("image", &[1]).await.unwrap_err(),
"fetch_blobs",
);
assert_not_supported_error(
table.fetch_blob_files("image", &[1]).await.unwrap_err(),
"requires LanceDB Cloud server 0.5.0 or newer",
);
}
#[rstest]
#[case(ARROW_STREAM_CONTENT_TYPE)]
#[case("application/vnd.apache.arrow.stream; charset=utf-8")]
#[case("APPLICATION/VND.APACHE.ARROW.STREAM")]
#[tokio::test]
async fn test_fetch_blobs_accepts_stream_content_type_variants(#[case] content_type: &'static str) {
let table = table_with_fetch_blobs_content_type(
Some(content_type),
write_ipc_stream_uncompressed(&one_row_blob_batch("image")),
);
let blobs = table.fetch_blobs("image", &[10]).await.unwrap();
assert_eq!(blobs.value(0), b"alpha");
}
// Shared decoder accepts file framing because /query still returns it.
// fetch_blobs wire contract is stream. Enforced on the server.
#[rstest]
#[case(ARROW_FILE_CONTENT_TYPE)]
#[case("application/vnd.apache.arrow.file; charset=utf-8")]
#[case("APPLICATION/VND.APACHE.ARROW.FILE")]
#[tokio::test]
async fn test_fetch_blobs_accepts_file_content_type_variants(#[case] content_type: &'static str) {
let table = table_with_fetch_blobs_content_type(
Some(content_type),
write_ipc_file(&one_row_blob_batch("image")),
);
let blobs = table.fetch_blobs("image", &[10]).await.unwrap();
assert_eq!(blobs.value(0), b"alpha");
}
#[rstest]
#[case(ARROW_STREAM_CONTENT_TYPE, write_ipc_file(&one_row_blob_batch("image")))]
#[case(
ARROW_FILE_CONTENT_TYPE,
write_ipc_stream_uncompressed(&one_row_blob_batch("image"))
)]
#[tokio::test]
async fn test_fetch_blobs_fails_when_the_body_contradicts_the_content_type(
#[case] content_type: &'static str,
#[case] body: Vec<u8>,
) {
let table = table_with_fetch_blobs_content_type(Some(content_type), body);
let error = table.fetch_blobs("image", &[10]).await.unwrap_err();
assert!(
matches!(error, Error::Arrow { .. }),
"expected an Arrow decode failure, got {error}"
);
}
#[tokio::test]
async fn test_fetch_blobs_rejects_a_response_that_is_not_arrow_ipc() {
let table =
table_with_fetch_blobs_content_type(Some("application/json"), br#"{"blobs": []}"#.to_vec());
let error = table.fetch_blobs("image", &[10]).await.unwrap_err();
assert_fetch_blobs_http_error(error, "got 'application/json'");
}
#[tokio::test]
async fn test_fetch_blobs_without_content_type_falls_back_to_file_framing() {
let table =
table_with_fetch_blobs_content_type(None, write_ipc_file(&one_row_blob_batch("image")));
let blobs = table.fetch_blobs("image", &[10]).await.unwrap();
assert_eq!(blobs.value(0), b"alpha");
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,717 @@
// SPDX-License-Identifier: Apache-2.0
// SPDX-FileCopyrightText: Copyright The LanceDB Authors
use super::*;
// ---- Read freshness header tests ------------------------------------
#[test]
fn test_compute_min_timestamp_combines_baseline_and_interval() {
let now = SystemTime::now();
let baseline = now - Duration::from_secs(60);
// No interval, no baseline -> no header.
assert_eq!(
compute_min_timestamp(&FreshnessState::default(), None, now),
None
);
// Baseline only -> baseline.
let state = FreshnessState {
min_version: None,
checkout_baseline: Some(baseline),
min_read_version: None,
..FreshnessState::default()
};
assert_eq!(compute_min_timestamp(&state, None, now), Some(baseline));
// ZERO interval, no baseline -> now.
assert_eq!(
compute_min_timestamp(&FreshnessState::default(), Some(Duration::ZERO), now),
Some(now)
);
// Positive interval, no baseline -> now - interval.
assert_eq!(
compute_min_timestamp(
&FreshnessState::default(),
Some(Duration::from_secs(10)),
now
),
Some(now - Duration::from_secs(10))
);
// Both: pick the more-recent (i.e. tighter) constraint.
// baseline = now-60, now-interval = now-10. now-10 is newer.
let state = FreshnessState {
min_version: None,
checkout_baseline: Some(baseline),
min_read_version: None,
..FreshnessState::default()
};
assert_eq!(
compute_min_timestamp(&state, Some(Duration::from_secs(10)), now),
Some(now - Duration::from_secs(10))
);
// Both, baseline newer: pick baseline.
let recent_baseline = now - Duration::from_secs(5);
let state = FreshnessState {
min_version: None,
checkout_baseline: Some(recent_baseline),
min_read_version: None,
..FreshnessState::default()
};
assert_eq!(
compute_min_timestamp(&state, Some(Duration::from_secs(60)), now),
Some(recent_baseline)
);
}
/// Allowed slop when comparing a header timestamp against a locally
/// captured wall-clock bound. Tests run fast enough that 1s is plenty.
const FRESHNESS_TOLERANCE: Duration = Duration::from_secs(1);
fn capturing_handler<F>(
body_for: F,
) -> (
impl Fn(reqwest::Request) -> http::Response<String> + Clone + Send + Sync + 'static,
Arc<std::sync::Mutex<Option<http::HeaderMap>>>,
)
where
F: Fn(&str) -> String + Clone + Send + Sync + 'static,
{
let captured = Arc::new(std::sync::Mutex::new(None));
let captured_c = captured.clone();
let handler = move |request: reqwest::Request| {
*captured_c.lock().unwrap() = Some(request.headers().clone());
let path = request.url().path().to_string();
http::Response::builder()
.status(200)
.body(body_for(&path))
.unwrap()
};
(handler, captured)
}
fn parse_min_timestamp(headers: &http::HeaderMap) -> SystemTime {
let value = headers
.get("x-lancedb-min-timestamp")
.expect("expected x-lancedb-min-timestamp header")
.to_str()
.unwrap();
chrono::DateTime::parse_from_rfc3339(value)
.unwrap()
.with_timezone(&chrono::Utc)
.into()
}
#[tokio::test]
async fn test_freshness_default_sends_no_headers() {
let (handler, captured) = capturing_handler(|_| "42".to_string());
let table = Table::new_with_handler("my_table", handler);
let _ = table.count_rows(None).await.unwrap();
let headers = captured.lock().unwrap().clone().unwrap();
assert!(!headers.contains_key("x-lancedb-min-timestamp"));
assert!(!headers.contains_key("x-lancedb-min-version"));
}
#[tokio::test]
async fn test_freshness_zero_interval_sends_now() {
let (handler, captured) = capturing_handler(|_| "42".to_string());
let table =
Table::new_with_handler_and_interval("my_table", handler, Some(Duration::from_secs(0)));
let before = SystemTime::now();
table.count_rows(None).await.unwrap();
let after = SystemTime::now();
let headers = captured.lock().unwrap().clone().unwrap();
let sent = parse_min_timestamp(&headers);
assert!(
sent >= before - FRESHNESS_TOLERANCE && sent <= after + FRESHNESS_TOLERANCE,
"expected timestamp roughly equal to wall clock"
);
assert!(!headers.contains_key("x-lancedb-min-version"));
}
#[tokio::test]
async fn test_checkout_disables_read_consistency_interval() {
let (handler, captured) = capturing_handler(|path| match path {
"/v1/table/my_table/describe/" => r#"{"version":5,"schema":{"fields":[]}}"#.to_string(),
"/v1/table/my_table/count_rows/" => "42".to_string(),
_ => panic!("unexpected path: {}", path),
});
let table =
Table::new_with_handler_and_interval("my_table", handler, Some(Duration::from_secs(0)));
table.checkout(5).await.unwrap();
table.count_rows(None).await.unwrap();
let headers = captured.lock().unwrap().clone().unwrap();
assert!(!headers.contains_key("x-lancedb-min-timestamp"));
assert!(!headers.contains_key("x-lancedb-min-version"));
assert!(!headers.contains_key("x-lancedb-min-read-version"));
}
#[tokio::test]
async fn test_read_snapshot_keeps_selector_and_freshness_generation_bound() {
let table = RemoteTable::new_mock_with_consistency_interval(
"my_table".to_string(),
|_| {
http::Response::builder()
.status(200)
.body(r#"{"version":5,"schema":{"fields":[]}}"#.to_string())
.unwrap()
},
Some(Duration::ZERO),
);
let latest = table.snapshot_read_state().await;
table.checkout(5).await.unwrap();
let latest_request = latest
.freshness
.apply(
table
.client
.post("/v1/table/my_table/count_rows/")
.json(&serde_json::json!({ "version": latest.version })),
)
.build()
.unwrap();
assert!(request_body_json(&latest_request)["version"].is_null());
assert!(latest_request.headers().contains_key(MIN_TIMESTAMP_HEADER));
let pinned = table.snapshot_read_state().await;
let pinned_request = pinned
.freshness
.apply(
table
.client
.post("/v1/table/my_table/count_rows/")
.json(&serde_json::json!({ "version": pinned.version })),
)
.build()
.unwrap();
assert_eq!(request_body_json(&pinned_request)["version"], 5);
assert!(!pinned_request.headers().contains_key(MIN_TIMESTAMP_HEADER));
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_cancelled_checkout_keeps_latest_freshness_enabled() {
let (described_tx, described_rx) = std::sync::mpsc::channel::<()>();
let table = Arc::new(RemoteTable::new_mock_with_consistency_interval(
"my_table".to_string(),
move |_| {
described_tx.send(()).unwrap();
http::Response::builder()
.status(200)
.body(r#"{"version":5,"schema":{"fields":[]}}"#.to_string())
.unwrap()
},
Some(Duration::from_secs(0)),
));
let version_guard = table.version.write().await;
let checkout = tokio::spawn({
let table = table.clone();
async move { table.checkout(5).await }
});
tokio::task::spawn_blocking(move || {
described_rx.recv_timeout(Duration::from_secs(10)).unwrap()
})
.await
.unwrap();
for _ in 0..100 {
if table.freshness.lock().unwrap().pinned {
break;
}
tokio::task::yield_now().await;
}
assert!(!checkout.is_finished());
checkout.abort();
assert!(checkout.await.unwrap_err().is_cancelled());
drop(version_guard);
assert_eq!(*table.version.read().await, None);
assert!(table.snapshot_freshness_headers().min_timestamp.is_some());
}
#[tokio::test]
async fn test_freshness_positive_interval_sends_now_minus_interval() {
let (handler, captured) = capturing_handler(|_| "42".to_string());
let interval = Duration::from_secs(30);
let table = Table::new_with_handler_and_interval("my_table", handler, Some(interval));
let before = SystemTime::now();
table.count_rows(None).await.unwrap();
let after = SystemTime::now();
let headers = captured.lock().unwrap().clone().unwrap();
let sent = parse_min_timestamp(&headers);
assert!(
sent >= before - interval - FRESHNESS_TOLERANCE
&& sent <= after - interval + FRESHNESS_TOLERANCE,
"expected timestamp roughly equal to now - interval"
);
}
#[tokio::test]
async fn test_freshness_checkout_latest_sets_baseline() {
let (handler, captured) = capturing_handler(|path| match path {
"/v1/table/my_table/count_rows/" => "42".to_string(),
_ => panic!("unexpected path: {}", path),
});
// No interval — only the baseline should drive the timestamp.
let table = Table::new_with_handler_and_interval("my_table", handler, None);
let before_checkout = SystemTime::now();
table.checkout_latest().await.unwrap();
let after_checkout = SystemTime::now();
table.count_rows(None).await.unwrap();
let headers = captured.lock().unwrap().clone().unwrap();
let sent = parse_min_timestamp(&headers);
assert!(
sent >= before_checkout - FRESHNESS_TOLERANCE
&& sent <= after_checkout + FRESHNESS_TOLERANCE,
"expected timestamp captured at checkout_latest() time"
);
assert!(!headers.contains_key("x-lancedb-min-version"));
}
#[tokio::test]
async fn test_freshness_min_version_tracked_after_write() {
let (handler, captured) = capturing_handler(|path| match path {
"/v1/table/my_table/update/" => r#"{"rows_updated":1,"version":7}"#.to_string(),
"/v1/table/my_table/count_rows/" => "42".to_string(),
_ => panic!("unexpected path: {}", path),
});
let table = Table::new_with_handler("my_table", handler);
let _ = table.update().column("a", "a + 1").execute().await.unwrap();
// Update headers also pass through captured; reset by reading after.
table.count_rows(None).await.unwrap();
let headers = captured.lock().unwrap().clone().unwrap();
assert_eq!(
headers
.get("x-lancedb-min-version")
.unwrap()
.to_str()
.unwrap(),
"7"
);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_inflight_write_result_cannot_cross_checkout_generation() {
let (release_tx, release_rx) = std::sync::mpsc::channel::<()>();
let release_rx = Arc::new(std::sync::Mutex::new(release_rx));
let (arrived_tx, arrived_rx) = std::sync::mpsc::channel::<()>();
let arrived_tx = Arc::new(std::sync::Mutex::new(arrived_tx));
let count_headers = Arc::new(std::sync::Mutex::new(None));
let captured = count_headers.clone();
let table = Table::new_with_handler("my_table", move |request| match request.url().path() {
"/v1/table/my_table/update/" => {
arrived_tx.lock().unwrap().send(()).unwrap();
release_rx
.lock()
.unwrap()
.recv_timeout(Duration::from_secs(10))
.unwrap();
http::Response::builder()
.status(200)
.body(r#"{"rows_updated":1,"version":100}"#.to_string())
.unwrap()
}
"/v1/table/my_table/describe/" => http::Response::builder()
.status(200)
.body(r#"{"version":5,"schema":{"fields":[]}}"#.to_string())
.unwrap(),
"/v1/table/my_table/count_rows/" => {
*captured.lock().unwrap() = Some(request.headers().clone());
http::Response::builder()
.status(200)
.body("1".to_string())
.unwrap()
}
path => panic!("unexpected path: {path}"),
});
let update = tokio::spawn({
let table = table.clone();
async move { table.update().column("a", "a + 1").execute().await }
});
tokio::task::spawn_blocking(move || arrived_rx.recv_timeout(Duration::from_secs(10)).unwrap())
.await
.unwrap();
table.checkout(5).await.unwrap();
release_tx.send(()).unwrap();
update.await.unwrap().unwrap();
table.count_rows(None).await.unwrap();
let headers = count_headers.lock().unwrap();
let headers = headers.as_ref().unwrap();
assert!(!headers.contains_key("x-lancedb-min-version"));
assert!(!headers.contains_key("x-lancedb-min-read-version"));
}
/// A handler that records every request's headers and answers each read with
/// an `x-lancedb-version` response header taken from `versions` (by call
/// index, saturating at the last entry). An empty string means "no header".
fn read_version_handler(
versions: &'static [&'static str],
) -> (
impl Fn(reqwest::Request) -> http::Response<String> + Clone + Send + Sync + 'static,
Arc<std::sync::Mutex<Vec<http::HeaderMap>>>,
) {
let requests = Arc::new(std::sync::Mutex::new(Vec::new()));
let requests_c = requests.clone();
let call = Arc::new(AtomicUsize::new(0));
let handler = move |request: reqwest::Request| {
requests_c.lock().unwrap().push(request.headers().clone());
let i = call.fetch_add(1, Ordering::SeqCst).min(versions.len() - 1);
let mut builder = http::Response::builder().status(200);
if !versions[i].is_empty() {
builder = builder.header("x-lancedb-version", versions[i]);
}
builder.body("42".to_string()).unwrap()
};
(handler, requests)
}
#[tokio::test]
async fn test_read_version_watermark_tracked_and_sent() {
let (handler, requests) = read_version_handler(&["100", "100"]);
let table = Table::new_with_handler("my_table", handler);
// First read has no watermark yet; the response advertises version 100,
// so the second read must floor the server at 100.
table.count_rows(None).await.unwrap();
table.count_rows(None).await.unwrap();
let reqs = requests.lock().unwrap();
assert!(!reqs[0].contains_key("x-lancedb-min-read-version"));
assert_eq!(
reqs[1]
.get("x-lancedb-min-read-version")
.unwrap()
.to_str()
.unwrap(),
"100"
);
}
#[tokio::test]
async fn test_schema_response_advances_read_watermark() {
let requests = Arc::new(std::sync::Mutex::new(Vec::new()));
let captured = requests.clone();
let table = Table::new_with_handler("my_table", move |request| {
captured
.lock()
.unwrap()
.push((request.url().path().to_string(), request.headers().clone()));
match request.url().path() {
"/v1/table/my_table/describe/" => http::Response::builder()
.status(200)
.header("x-lancedb-version", "100")
.body(r#"{"version":100,"schema":{"fields":[]}}"#.to_string())
.unwrap(),
"/v1/table/my_table/count_rows/" => http::Response::builder()
.status(200)
.body("42".to_string())
.unwrap(),
path => panic!("unexpected path: {path}"),
}
});
table.schema().await.unwrap();
assert_eq!(table.count_rows(None).await.unwrap(), 42);
let requests = requests.lock().unwrap();
let count_headers = &requests[1].1;
assert_eq!(
count_headers
.get("x-lancedb-min-read-version")
.and_then(|value| value.to_str().ok()),
Some("100")
);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_inflight_schema_response_cannot_cross_checkout_generation() {
let (release_tx, release_rx) = std::sync::mpsc::channel::<()>();
let release_rx = Arc::new(std::sync::Mutex::new(release_rx));
let (arrived_tx, arrived_rx) = std::sync::mpsc::channel::<()>();
let arrived_tx = Arc::new(std::sync::Mutex::new(arrived_tx));
let count_headers = Arc::new(std::sync::Mutex::new(None));
let captured = count_headers.clone();
let table = Table::new_with_handler("my_table", move |request| match request.url().path() {
"/v1/table/my_table/describe/" => {
let body = request_body_json(&request);
if body["version"].is_null() {
arrived_tx.lock().unwrap().send(()).unwrap();
release_rx
.lock()
.unwrap()
.recv_timeout(Duration::from_secs(10))
.unwrap();
http::Response::builder()
.status(200)
.header("x-lancedb-version", "100")
.body(r#"{"version":100,"schema":{"fields":[]}}"#.to_string())
.unwrap()
} else {
http::Response::builder()
.status(200)
.body(r#"{"version":5,"schema":{"fields":[]}}"#.to_string())
.unwrap()
}
}
"/v1/table/my_table/count_rows/" => {
*captured.lock().unwrap() = Some(request.headers().clone());
http::Response::builder()
.status(200)
.body("1".to_string())
.unwrap()
}
path => panic!("unexpected path: {path}"),
});
let schema = tokio::spawn({
let table = table.clone();
async move { table.schema().await }
});
tokio::task::spawn_blocking(move || arrived_rx.recv_timeout(Duration::from_secs(10)).unwrap())
.await
.unwrap();
table.checkout(5).await.unwrap();
release_tx.send(()).unwrap();
schema.await.unwrap().unwrap();
table.count_rows(None).await.unwrap();
assert!(
!count_headers
.lock()
.unwrap()
.as_ref()
.unwrap()
.contains_key("x-lancedb-min-read-version")
);
}
#[tokio::test]
async fn test_streaming_write_uses_and_advances_read_watermark() {
let data = RecordBatch::try_new(
Arc::new(Schema::new(vec![Field::new("a", DataType::Int32, false)])),
vec![Arc::new(Int32Array::from(vec![1, 2, 3]))],
)
.unwrap();
let describe_body = serde_json::to_string(&json!({
"version": 7,
"schema": JsonSchema::try_from(data.schema().as_ref()).unwrap(),
}))
.unwrap();
let requests = Arc::new(std::sync::Mutex::new(Vec::new()));
let captured = requests.clone();
let table = Table::new_with_handler("my_table", move |request| {
captured
.lock()
.unwrap()
.push((request.url().path().to_string(), request.headers().clone()));
match request.url().path() {
"/v1/table/my_table/describe/" => http::Response::builder()
.status(200)
.body(describe_body.clone())
.unwrap(),
"/v1/table/my_table/insert/" => http::Response::builder()
.status(200)
.header("x-lancedb-version", "8")
.body(r#"{"version":8}"#.to_string())
.unwrap(),
"/v1/table/my_table/count_rows/" => http::Response::builder()
.status(200)
.body("3".to_string())
.unwrap(),
path => panic!("unexpected path: {path}"),
}
});
assert_eq!(table.add(data).execute().await.unwrap().version, 8);
assert_eq!(table.count_rows(None).await.unwrap(), 3);
let requests = requests.lock().unwrap();
let insert_headers = &requests[1].1;
assert_eq!(
insert_headers
.get("x-lancedb-min-read-version")
.and_then(|value| value.to_str().ok()),
Some("7")
);
let count_headers = &requests[2].1;
assert_eq!(
count_headers
.get("x-lancedb-min-read-version")
.and_then(|value| value.to_str().ok()),
Some("8")
);
}
#[tokio::test]
async fn test_read_version_watermark_keeps_max() {
// Server reports 100 then a stale 50; the watermark must not regress.
let (handler, requests) = read_version_handler(&["100", "50", "50"]);
let table = Table::new_with_handler("my_table", handler);
table.count_rows(None).await.unwrap();
table.count_rows(None).await.unwrap();
table.count_rows(None).await.unwrap();
let reqs = requests.lock().unwrap();
assert_eq!(
reqs[2]
.get("x-lancedb-min-read-version")
.unwrap()
.to_str()
.unwrap(),
"100"
);
}
#[tokio::test]
async fn test_read_version_absent_header_no_watermark() {
// An old server that doesn't return the version header leaves the
// watermark unset, preserving backward compatibility.
let (handler, requests) = read_version_handler(&[""]);
let table = Table::new_with_handler("my_table", handler);
table.count_rows(None).await.unwrap();
table.count_rows(None).await.unwrap();
let reqs = requests.lock().unwrap();
assert!(!reqs[1].contains_key("x-lancedb-min-read-version"));
}
#[tokio::test]
async fn test_read_version_watermark_reset_on_checkout_latest() {
let (handler, requests) = read_version_handler(&["100", "100"]);
let table = Table::new_with_handler("my_table", handler);
table.count_rows(None).await.unwrap();
table.checkout_latest().await.unwrap();
table.count_rows(None).await.unwrap();
// The read after checkout_latest starts from a clean slate.
let reqs = requests.lock().unwrap();
assert!(
!reqs
.last()
.unwrap()
.contains_key("x-lancedb-min-read-version")
);
}
/// Like `capturing_handler`, but keeps a per-path snapshot of the headers
/// from every request so tests can assert on a specific endpoint.
#[allow(clippy::type_complexity)]
fn path_capturing_handler<F>(
body_for: F,
) -> (
impl Fn(reqwest::Request) -> http::Response<String> + Clone + Send + Sync + 'static,
Arc<std::sync::Mutex<HashMap<String, http::HeaderMap>>>,
)
where
F: Fn(&str) -> String + Clone + Send + Sync + 'static,
{
let captured: Arc<std::sync::Mutex<HashMap<String, http::HeaderMap>>> =
Arc::new(std::sync::Mutex::new(HashMap::new()));
let captured_c = captured.clone();
let handler = move |request: reqwest::Request| {
let path = request.url().path().to_string();
captured_c
.lock()
.unwrap()
.insert(path.clone(), request.headers().clone());
http::Response::builder()
.status(200)
.body(body_for(&path))
.unwrap()
};
(handler, captured)
}
#[tokio::test]
async fn test_freshness_checkout_validation_sends_no_min_version() {
// After a write bumps min_version, calling checkout(v) must not let
// that stale header ride along on the validating /describe/ request.
let (handler, captured) = path_capturing_handler(|path| match path {
"/v1/table/my_table/update/" => r#"{"rows_updated":1,"version":7}"#.to_string(),
"/v1/table/my_table/describe/" => r#"{"version":5,"schema":{"fields":[]}}"#.to_string(),
_ => panic!("unexpected path: {}", path),
});
let table = Table::new_with_handler("my_table", handler);
table.update().column("a", "a + 1").execute().await.unwrap();
table.checkout(5).await.unwrap();
let captured = captured.lock().unwrap();
let describe_headers = captured
.get("/v1/table/my_table/describe/")
.expect("describe should have been called by checkout(v)");
assert!(
!describe_headers.contains_key("x-lancedb-min-version"),
"checkout(v) describe must not carry stale min_version",
);
assert!(!describe_headers.contains_key("x-lancedb-min-timestamp"));
}
#[tokio::test]
async fn test_freshness_checkout_tag_resolve_sends_no_min_version() {
// Same invariant for checkout_tag: the tag-resolve request must not
// pick up a stale min_version from a prior write.
let (handler, captured) = path_capturing_handler(|path| match path {
"/v1/table/my_table/update/" => r#"{"rows_updated":1,"version":7}"#.to_string(),
"/v1/table/my_table/tags/version/" => r#"{"version":5}"#.to_string(),
_ => panic!("unexpected path: {}", path),
});
let table = Table::new_with_handler("my_table", handler);
table.update().column("a", "a + 1").execute().await.unwrap();
table.checkout_tag("v_initial").await.unwrap();
let captured = captured.lock().unwrap();
let resolve_headers = captured
.get("/v1/table/my_table/tags/version/")
.expect("tags/version should have been called by checkout_tag");
assert!(
!resolve_headers.contains_key("x-lancedb-min-version"),
"checkout_tag resolve must not carry stale min_version",
);
assert!(!resolve_headers.contains_key("x-lancedb-min-timestamp"));
}
#[tokio::test]
async fn test_freshness_checkout_clears_min_version() {
let (handler, captured) = capturing_handler(|path| match path {
"/v1/table/my_table/update/" => r#"{"rows_updated":1,"version":7}"#.to_string(),
// checkout(5) needs to describe version 5 first
"/v1/table/my_table/describe/" => r#"{"version":5,"schema":{"fields":[]}}"#.to_string(),
"/v1/table/my_table/count_rows/" => "42".to_string(),
_ => panic!("unexpected path: {}", path),
});
let table = Table::new_with_handler("my_table", handler);
table.update().column("a", "a + 1").execute().await.unwrap();
table.checkout(5).await.unwrap();
table.count_rows(None).await.unwrap();
let headers = captured.lock().unwrap().clone().unwrap();
assert!(!headers.contains_key("x-lancedb-min-version"));
assert!(!headers.contains_key("x-lancedb-min-timestamp"));
}
@@ -0,0 +1,250 @@
// SPDX-License-Identifier: Apache-2.0
// SPDX-FileCopyrightText: Copyright The LanceDB Authors
use super::*;
// WAL-PK-FUSION: delete everything from here to the end of `mod tests`.
/// Verbatim from sophon's `LSM_WITH_ROW_ID_UNSUPPORTED`; the fallback keys
/// off this sentence, so a test that paraphrased it would prove nothing.
const WAL_ROW_ID_REFUSAL_BODY: &str = r#"{"code":13,"error":"Bad request: the MemWAL LSM scanner does not support with_row_id (the LSM scanner exposes _rowaddr, not a stable _rowid); set use_lsm(false) to read the base table only (results will exclude un-compacted MemWAL data)"}"#;
fn pk_schema() -> Schema {
use lance_core::datatypes::LANCE_UNENFORCED_PRIMARY_KEY_POSITION;
Schema::new(vec![
Field::new("id", DataType::Utf8, false).with_metadata(
[(
LANCE_UNENFORCED_PRIMARY_KEY_POSITION.to_string(),
"1".to_string(),
)]
.into_iter()
.collect(),
),
Field::new("text", DataType::Utf8, true),
])
}
/// A server that refuses `_rowid` the way a MemWAL table does, recording
/// every query body so a test can see which key each attempt asked for.
fn wal_refusing_table(bodies: Arc<std::sync::Mutex<Vec<serde_json::Value>>>) -> Table {
Table::new_with_handler("my_table", move |request| match request.url().path() {
"/v1/table/my_table/describe/" => http::Response::builder()
.status(200)
.body(describe_response(&pk_schema()).into_bytes())
.unwrap(),
"/v1/table/my_table/query/" => {
let body = request_body_json(&request);
let asked_for_row_id = body["with_row_id"] == serde_json::Value::Bool(true);
let is_fts = body.get("full_text_query").is_some_and(|v| !v.is_null());
bodies.lock().unwrap().push(body);
if asked_for_row_id {
return http::Response::builder()
.status(400)
.body(WAL_ROW_ID_REFUSAL_BODY.as_bytes().to_vec())
.unwrap();
}
let batch = if is_fts {
leg("_score", vec!["a", "b"], false)
} else {
leg("_distance", vec!["a"], false)
};
http::Response::builder()
.status(200)
.body(write_ipc_file(&batch))
.unwrap()
}
path => panic!("unexpected request path: {path}"),
})
}
fn hybrid(table: &Table) -> crate::query::VectorQuery {
table
.query()
.full_text_search(FullTextSearchQuery::new("a".to_string()))
.nearest_to(&[0.0, 0.0])
.unwrap()
}
/// The optimistic path: ask for `_rowid`, and when a MemWAL table refuses,
/// fall back to the primary key rather than failing.
#[tokio::test]
async fn test_hybrid_falls_back_to_the_primary_key_when_row_id_is_refused() {
let bodies = Arc::new(std::sync::Mutex::new(Vec::new()));
let table = wal_refusing_table(bodies.clone());
let results = hybrid(&table)
.select(Select::columns(&["text"]))
.limit(10)
.execute()
.await
.unwrap()
.try_collect::<Vec<_>>()
.await
.unwrap();
let bodies = bodies.lock().unwrap();
let (asked_row_id, asked_pk): (Vec<_>, Vec<_>) = bodies
.iter()
.partition(|b| b["with_row_id"] == serde_json::Value::Bool(true));
// The legs run concurrently under `try_join!`, which abandons the
// sibling as soon as one errors — so the refused attempt is one or two
// requests, never zero, and the retry always sends both.
assert!(
!asked_row_id.is_empty(),
"`_rowid` has to be tried before the fallback means anything"
);
assert_eq!(asked_pk.len(), 2, "both legs are retried on the key");
for body in asked_pk {
let columns = body["columns"].as_array().expect("a column projection");
assert!(
columns.iter().any(|c| c == "id"),
"the retry has to project the key: {body}"
);
}
let batch = &results[0];
assert!(batch.column_by_name(ROW_ID).is_none());
assert!(batch.column_by_name("id").is_none(), "injected key leaked");
assert_eq!(batch.num_rows(), 2, "`a` is in both legs and fuses");
}
/// A hybrid query that matches nothing must return nothing, not fail.
/// When both legs come back empty, `query_schemas` synthesizes a schema
/// that already carries `_rowid`, which the stamping has to tolerate.
#[tokio::test]
async fn test_hybrid_with_no_matches_returns_an_empty_result() {
let table = Table::new_with_handler("my_table", move |request| {
match request.url().path() {
"/v1/table/my_table/describe/" => http::Response::builder()
.status(200)
.body(describe_response(&pk_schema()).into_bytes())
.unwrap(),
"/v1/table/my_table/query/" => {
let body = request_body_json(&request);
if body["with_row_id"] == serde_json::Value::Bool(true) {
return http::Response::builder()
.status(400)
.body(WAL_ROW_ID_REFUSAL_BODY.as_bytes().to_vec())
.unwrap();
}
// A leg that matched nothing: schema, no batches.
let schema = Arc::new(Schema::new(vec![Field::new("id", DataType::Utf8, false)]));
let mut body = Vec::new();
{
let mut writer =
arrow_ipc::writer::FileWriter::try_new(&mut body, &schema).unwrap();
writer.finish().unwrap();
}
http::Response::builder().status(200).body(body).unwrap()
}
path => panic!("unexpected request path: {path}"),
}
});
let results = hybrid(&table)
.limit(10)
.execute()
.await
.unwrap()
.try_collect::<Vec<_>>()
.await
.unwrap();
assert_eq!(results.iter().map(|b| b.num_rows()).sum::<usize>(), 0);
}
/// The refusal is paid once: the second query goes straight to the key.
#[tokio::test]
async fn test_hybrid_remembers_the_refusal() {
let bodies = Arc::new(std::sync::Mutex::new(Vec::new()));
let table = wal_refusing_table(bodies.clone());
for _ in 0..2 {
hybrid(&table)
.limit(10)
.execute()
.await
.unwrap()
.try_collect::<Vec<_>>()
.await
.unwrap();
}
let refused = bodies
.lock()
.unwrap()
.iter()
.filter(|b| b["with_row_id"] == serde_json::Value::Bool(true))
.count();
assert!(
(1..=2).contains(&refused),
"only the first query should pay the refusal, saw {refused} attempts"
);
}
/// A table that never refuses must never pay for the fallback existing.
#[tokio::test]
async fn test_hybrid_on_a_base_table_sends_no_extra_requests() {
let paths = Arc::new(std::sync::Mutex::new(Vec::new()));
let seen = paths.clone();
let table = Table::new_with_handler("my_table", move |request| {
seen.lock().unwrap().push(request.url().path().to_string());
let batch = if request_body_json(&request)
.get("full_text_query")
.is_some_and(|v| !v.is_null())
{
leg("_score", vec!["a", "b"], true)
} else {
leg("_distance", vec!["a"], true)
};
http::Response::builder()
.status(200)
.body(write_ipc_file(&batch))
.unwrap()
});
hybrid(&table)
.limit(10)
.execute()
.await
.unwrap()
.try_collect::<Vec<_>>()
.await
.unwrap();
let paths = paths.lock().unwrap();
assert!(
paths.iter().all(|p| p.ends_with("/query/")),
"a table that never refuses must not be probed, got {paths:?}"
);
assert_eq!(paths.len(), 2, "one request per leg, got {paths:?}");
}
/// A caller who asked for `_rowid` gets the server's refusal, not a
/// surrogate dressed up as one.
#[tokio::test]
async fn test_hybrid_does_not_fall_back_when_the_caller_asked_for_row_id() {
let bodies = Arc::new(std::sync::Mutex::new(Vec::new()));
let table = wal_refusing_table(bodies.clone());
let err = hybrid(&table)
.with_row_id()
.limit(10)
.execute()
.await
.err()
.expect("the refusal must reach the caller");
// Translated, not passed through: the caller gets the reason and the
// way out, which a bare 400 does not carry.
assert!(matches!(err, Error::NotSupported { .. }), "{err:?}");
assert!(err.to_string().contains("use_lsm(false)"), "{err}");
assert!(
bodies
.lock()
.unwrap()
.iter()
.all(|b| b["with_row_id"] == serde_json::Value::Bool(true)),
"no retry should have been attempted"
);
}
File diff suppressed because it is too large Load Diff
+901
View File
@@ -0,0 +1,901 @@
// SPDX-License-Identifier: Apache-2.0
// SPDX-FileCopyrightText: Copyright The LanceDB Authors
use super::*;
#[tokio::test]
async fn test_prewarm_index() {
let table = Table::new_with_handler("my_table", |request| {
assert_eq!(request.method(), "POST");
assert_eq!(
request.url().path(),
"/v1/table/my_table/index/my_index/prewarm/"
);
http::Response::builder().status(200).body("{}").unwrap()
});
table.prewarm_index("my_index").await.unwrap();
}
#[tokio::test]
async fn test_prewarm_index_not_found() {
let table = Table::new_with_handler("my_table", |request| {
assert_eq!(
request.url().path(),
"/v1/table/my_table/index/my_index/prewarm/"
);
http::Response::builder().status(404).body("{}").unwrap()
});
let e = table.prewarm_index("my_index").await.unwrap_err();
assert!(matches!(e, Error::IndexNotFound { .. }));
}
#[tokio::test]
async fn test_prewarm_data() {
let table = Table::new_with_handler("my_table", |request| {
assert_eq!(request.method(), "POST");
assert_eq!(
request.url().path(),
"/v1/table/my_table/page_cache/prewarm/"
);
http::Response::builder().status(200).body("{}").unwrap()
});
table.prewarm_data(None).await.unwrap();
}
#[tokio::test]
async fn test_prewarm_data_with_columns() {
let table = Table::new_with_handler("my_table", |request| {
assert_eq!(request.method(), "POST");
assert_eq!(
request.url().path(),
"/v1/table/my_table/page_cache/prewarm/"
);
let body = request.body().unwrap().as_bytes().unwrap();
let body: serde_json::Value = serde_json::from_slice(body).unwrap();
assert_eq!(body["columns"], serde_json::json!(["col_a", "col_b"]));
http::Response::builder().status(200).body("{}").unwrap()
});
table
.prewarm_data(Some(vec!["col_a".into(), "col_b".into()]))
.await
.unwrap();
}
#[tokio::test]
async fn test_drop_index() {
let table = Table::new_with_handler("my_table", |request| {
assert_eq!(request.method(), "POST");
assert_eq!(
request.url().path(),
"/v1/table/my_table/index/my_index/drop/"
);
http::Response::builder().status(200).body("{}").unwrap()
});
table.drop_index("my_index").await.unwrap();
}
#[tokio::test]
async fn test_drop_index_not_exists() {
let table = Table::new_with_handler("my_table", |request| {
assert_eq!(request.method(), "POST");
assert_eq!(
request.url().path(),
"/v1/table/my_table/index/my_index/drop/"
);
http::Response::builder().status(404).body("{}").unwrap()
});
// Assert that the error is IndexNotFound
let e = table.drop_index("my_index").await.unwrap_err();
assert!(matches!(e, Error::IndexNotFound { .. }));
}
/// Index names are unvalidated, so reserved characters must be
/// percent-encoded or they restructure the request path.
#[tokio::test]
async fn test_per_index_paths_encode_reserved_characters() {
const NAME: &str = "my/index?a#b c";
const PREFIX: &str = "/v1/table/my_table/index/my%2Findex%3Fa%23b%20c";
let table = Table::new_with_handler("my_table", |request| {
assert_eq!(request.url().path(), format!("{PREFIX}/stats/"));
let body = serde_json::json!({
"num_indexed_rows": 1,
"num_unindexed_rows": 0,
"index_type": "IVF_PQ",
"distance_type": "l2"
});
http::Response::builder()
.status(200)
.body(serde_json::to_string(&body).unwrap())
.unwrap()
});
assert!(table.index_stats(NAME).await.unwrap().is_some());
let table = Table::new_with_handler("my_table", |request| {
assert_eq!(request.url().path(), format!("{PREFIX}/drop/"));
http::Response::builder().status(200).body("{}").unwrap()
});
table.drop_index(NAME).await.unwrap();
let table = Table::new_with_handler("my_table", |request| {
assert_eq!(request.url().path(), format!("{PREFIX}/prewarm/"));
http::Response::builder().status(200).body("{}").unwrap()
});
table.prewarm_index(NAME).await.unwrap();
}
#[tokio::test]
async fn test_set_lsm_write_spec_unsharded() {
let table = Table::new_with_handler("my_table", |request| {
assert_eq!(request.method(), "POST");
assert_eq!(
request.url().path(),
"/v1/table/my_table/set_lsm_write_spec/"
);
let body = request.body().unwrap().as_bytes().unwrap();
let body: serde_json::Value = serde_json::from_slice(body).unwrap();
assert_eq!(body["sharding"], serde_json::json!({ "mode": "unsharded" }));
assert_eq!(body["maintained_indexes"], serde_json::json!(["id_idx"]));
assert_eq!(
body["writer_config_defaults"],
serde_json::json!({ "max_memtable_rows": "1000" })
);
http::Response::builder()
.status(200)
.body(r#"{"maintained_indexes":["id_idx"]}"#)
.unwrap()
});
let spec = crate::table::LsmWriteSpec::unsharded()
.with_maintained_indexes(vec!["id_idx".to_string()])
.with_writer_config_defaults([("max_memtable_rows", "1000")]);
table.set_lsm_write_spec(spec).await.unwrap();
}
#[tokio::test]
async fn test_set_lsm_write_spec_bucket() {
let table = Table::new_with_handler("my_table", |request| {
assert_eq!(request.method(), "POST");
assert_eq!(
request.url().path(),
"/v1/table/my_table/set_lsm_write_spec/"
);
let body = request.body().unwrap().as_bytes().unwrap();
let body: serde_json::Value = serde_json::from_slice(body).unwrap();
assert_eq!(
body["sharding"],
serde_json::json!({ "mode": "bucket", "column": "id", "num_buckets": 16 })
);
// An unpinned maintained set sends null: resolve server-side.
assert_eq!(body["maintained_indexes"], serde_json::Value::Null);
http::Response::builder().status(200).body("{}").unwrap()
});
table
.set_lsm_write_spec(crate::table::LsmWriteSpec::bucket("id", 16))
.await
.unwrap();
}
/// `[]` (none) must stay distinguishable on the wire from null (all).
#[tokio::test]
async fn test_set_lsm_write_spec_no_maintained_indexes() {
let table = Table::new_with_handler("my_table", |request| {
let body = request.body().unwrap().as_bytes().unwrap();
let body: serde_json::Value = serde_json::from_slice(body).unwrap();
assert_eq!(body["maintained_indexes"], serde_json::json!([]));
http::Response::builder().status(200).body("{}").unwrap()
});
table
.set_lsm_write_spec(
crate::table::LsmWriteSpec::bucket("id", 16).with_maintained_indexes(Vec::new()),
)
.await
.unwrap();
}
#[tokio::test]
async fn test_set_lsm_write_spec_identity() {
let table = Table::new_with_handler("my_table", |request| {
assert_eq!(request.method(), "POST");
assert_eq!(
request.url().path(),
"/v1/table/my_table/set_lsm_write_spec/"
);
let body = request.body().unwrap().as_bytes().unwrap();
let body: serde_json::Value = serde_json::from_slice(body).unwrap();
assert_eq!(
body["sharding"],
serde_json::json!({ "mode": "identity", "column": "tenant" })
);
http::Response::builder().status(200).body("{}").unwrap()
});
table
.set_lsm_write_spec(crate::table::LsmWriteSpec::identity("tenant"))
.await
.unwrap();
}
#[tokio::test]
async fn test_unset_lsm_write_spec() {
let table = Table::new_with_handler("my_table", |request| {
assert_eq!(request.method(), "POST");
assert_eq!(
request.url().path(),
"/v1/table/my_table/unset_lsm_write_spec/"
);
http::Response::builder().status(200).body("{}").unwrap()
});
table.unset_lsm_write_spec().await.unwrap();
}
#[tokio::test]
async fn test_get_lsm_write_spec() {
let table = Table::new_with_handler("my_table", |request| {
assert_eq!(request.method(), "POST");
assert_eq!(
request.url().path(),
"/v1/table/my_table/get_lsm_write_spec/"
);
// The server resolves the spec and re-encodes it into the same
// sophon-owned shape the set endpoint accepts (`Sharding` internally
// tagged on `mode`, wrapped in `lsm_write_spec`).
let response = serde_json::json!({
"lsm_write_spec": {
"sharding": { "mode": "bucket", "column": "id", "num_buckets": 4 },
"maintained_indexes": ["id_idx"],
"writer_config_defaults": { "durable_write": "false" },
}
});
http::Response::builder()
.status(200)
.body(response.to_string())
.unwrap()
});
let spec = table
.get_lsm_write_spec()
.await
.unwrap()
.expect("a spec should be reported");
match spec {
crate::table::LsmWriteSpec::Bucket {
column,
num_buckets,
maintained_indexes,
writer_config_defaults,
} => {
assert_eq!(column, "id");
assert_eq!(num_buckets, 4);
assert_eq!(maintained_indexes, Some(vec!["id_idx".to_string()]));
assert_eq!(
writer_config_defaults
.get("durable_write")
.map(String::as_str),
Some("false")
);
}
other => panic!("expected a bucket spec, got {:?}", other),
}
}
/// Every selection reads back as the server reported it: every index
/// (`null`), none (`[]`), and a named list.
#[rstest::rstest]
#[case::every_index(serde_json::Value::Null, None)]
#[case::no_index(serde_json::json!([]), Some(vec![]))]
#[case::named(serde_json::json!(["id_idx"]), Some(vec!["id_idx".to_string()]))]
#[tokio::test]
async fn test_get_lsm_write_spec_round_trips_the_selection(
#[case] reported: serde_json::Value,
#[case] expected: Option<Vec<String>>,
) {
let table = Table::new_with_handler("my_table", move |_| {
let response = serde_json::json!({
"lsm_write_spec": {
"sharding": { "mode": "unsharded" },
"maintained_indexes": reported,
"writer_config_defaults": {},
}
});
http::Response::builder()
.status(200)
.body(response.to_string())
.unwrap()
});
let spec = table
.get_lsm_write_spec()
.await
.unwrap()
.expect("a spec should be reported");
assert_eq!(
spec.maintained_indexes().map(<[String]>::to_vec),
expected,
"the selection the server reported must survive the read"
);
}
#[tokio::test]
async fn test_get_lsm_write_spec_absent() {
let table = Table::new_with_handler("my_table", |request| {
assert_eq!(
request.url().path(),
"/v1/table/my_table/get_lsm_write_spec/"
);
// Null spec → the LSM write path is not enabled.
let response = serde_json::json!({ "lsm_write_spec": null });
http::Response::builder()
.status(200)
.body(response.to_string())
.unwrap()
});
assert!(table.get_lsm_write_spec().await.unwrap().is_none());
}
/// `flush_lsm` / `compact_lsm` answer 202 with no body at all.
fn accepted() -> http::Response<String> {
http::Response::builder()
.status(202)
.body(String::new())
.unwrap()
}
fn ok_json(body: String) -> http::Response<String> {
http::Response::builder().status(200).body(body).unwrap()
}
/// A flush landing in an empty L0 finishes on the opening stats read
/// alone. Asserting zero compacts is the point: "it returned Ok" is also
/// true of a loop that ran a pointless pass.
#[tokio::test(start_paused = true)]
async fn test_checkpoint_short_circuits_on_empty_l0() {
let compacts = Arc::new(std::sync::atomic::AtomicUsize::new(0));
let seen = compacts.clone();
let table = Table::new_with_handler("my_table", move |request| {
let path = request.url().path().to_string();
if path.contains("compact_lsm") {
seen.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
panic!("an already-converged table must issue no compact calls");
}
if path.contains("flush_lsm") {
return accepted();
}
assert_eq!(path, "/v1/table/my_table/get_lsm_stats/");
ok_json(stats_body(&[], false))
});
table.checkpoint_lsm().await.unwrap();
assert_eq!(compacts.load(std::sync::atomic::Ordering::SeqCst), 0);
}
/// The loop triggers compaction until every generation that existed at
/// the start is gone, one bounded prefix per pass.
#[tokio::test(start_paused = true)]
async fn test_checkpoint_triggers_until_targets_are_drained() {
let compacts = Arc::new(std::sync::atomic::AtomicUsize::new(0));
let seen = compacts.clone();
let table = Table::new_with_handler("my_table", move |request| {
let path = request.url().path().to_string();
if path.contains("flush_lsm") {
return accepted();
}
if path.contains("compact_lsm") {
seen.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
return accepted();
}
// Each pass drains the oldest generation.
let drained = seen.load(std::sync::atomic::Ordering::SeqCst);
let left: Vec<u64> = [1u64, 2, 3].into_iter().skip(drained).collect();
ok_json(stats_body(&left, false))
});
table.checkpoint_lsm().await.unwrap();
assert_eq!(
compacts.load(std::sync::atomic::Ordering::SeqCst),
3,
"one trigger per generation prefix, then stop"
);
}
/// Generations created *during* the checkpoint are not waited on, which
/// is what lets the loop terminate on a table taking writes where "L0 is
/// empty" never becomes true.
#[tokio::test(start_paused = true)]
async fn test_checkpoint_ignores_generations_created_while_it_runs() {
let compacts = Arc::new(std::sync::atomic::AtomicUsize::new(0));
let seen = compacts.clone();
let table = Table::new_with_handler("my_table", move |request| {
let path = request.url().path().to_string();
if path.contains("flush_lsm") {
return accepted();
}
if path.contains("compact_lsm") {
seen.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
return accepted();
}
// Target is 5. One pass drains it; a writer keeps adding above.
let n = seen.load(std::sync::atomic::Ordering::SeqCst);
let body = if n == 0 {
stats_body(&[5], false)
} else {
stats_body(&[6, 7], false)
};
ok_json(body)
});
table.checkpoint_lsm().await.unwrap();
assert_eq!(
compacts.load(std::sync::atomic::Ordering::SeqCst),
1,
"the loop must not chase generations written after it started"
);
}
/// Contention is a 429 and must be retried. The server keeps it off 503
/// precisely so the client can act on the status alone — reading it as
/// terminal stops the checkpoint early on a healthy node.
#[tokio::test(start_paused = true)]
async fn test_checkpoint_retries_contention() {
let compacts = Arc::new(std::sync::atomic::AtomicUsize::new(0));
let seen = compacts.clone();
let table = Table::new_with_handler("my_table", move |request| {
let path = request.url().path().to_string();
if path.contains("flush_lsm") {
return accepted();
}
if path.contains("compact_lsm") {
// First two triggers: every bucket already latched.
if seen.fetch_add(1, std::sync::atomic::Ordering::SeqCst) < 2 {
return http::Response::builder()
.status(429)
.body(r#"{"code":21,"error":"Too many concurrent writes"}"#.to_string())
.unwrap();
}
return accepted();
}
let accepted_triggers = seen
.load(std::sync::atomic::Ordering::SeqCst)
.saturating_sub(2);
let left: Vec<u64> = if accepted_triggers == 0 {
vec![1]
} else {
vec![]
};
ok_json(stats_body(&left, false))
});
table
.checkpoint_lsm()
.await
.expect("contention must not abort the checkpoint");
assert_eq!(
compacts.load(std::sync::atomic::Ordering::SeqCst),
3,
"assert the retry count, not just the outcome"
);
}
/// A transient fault on the poll must not abort the checkpoint. This route
/// meets the most contention — it runs every `POLL_INTERVAL` for the
/// checkpoint's whole life, with the transport retry layer disabled — yet
/// was the one call reached with a bare `?`.
#[tokio::test(start_paused = true)]
async fn test_checkpoint_retries_a_contended_stats_poll() {
let polls = Arc::new(std::sync::atomic::AtomicUsize::new(0));
let seen = polls.clone();
let table = Table::new_with_handler("my_table", move |request| {
let path = request.url().path().to_string();
if path.contains("flush_lsm") || path.contains("compact_lsm") {
return accepted();
}
// The opening read lands; the next two polls are latched out.
let n = seen.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
if (1..3).contains(&n) {
return http::Response::builder()
.status(429)
.body(r#"{"code":21,"error":"Too many concurrent writes"}"#.to_string())
.unwrap();
}
ok_json(stats_body(if n < 4 { &[1] } else { &[] }, false))
});
table
.checkpoint_lsm()
.await
.expect("a contended poll must be retried, not surfaced");
assert_eq!(
polls.load(std::sync::atomic::Ordering::SeqCst),
5,
"the two rejected polls must be re-issued, not skipped"
);
}
/// Contention and a lost claim draw on separate budgets: five straight
/// 429s on `flush`, more than `MAX_REISSUES`, must still converge. On one
/// shared counter this spent the re-issue cap and then reported a lost
/// claim nothing had ever reported.
#[tokio::test(start_paused = true)]
async fn test_contention_does_not_exhaust_the_reissue_budget() {
let flushes = Arc::new(std::sync::atomic::AtomicUsize::new(0));
let seen = flushes.clone();
let table = Table::new_with_handler("my_table", move |request| {
let path = request.url().path().to_string();
if path.contains("flush_lsm") {
if seen.fetch_add(1, std::sync::atomic::Ordering::SeqCst) < 5 {
return http::Response::builder()
.status(429)
.body(r#"{"code":21,"error":"Too many concurrent writes"}"#.to_string())
.unwrap();
}
return accepted();
}
if path.contains("compact_lsm") {
return accepted();
}
ok_json(stats_body(&[], false))
});
table
.checkpoint_lsm()
.await
.expect("contention must not be reported as a lost claim");
assert_eq!(
flushes.load(std::sync::atomic::Ordering::SeqCst),
6,
"five retries against one seal, then it lands"
);
}
/// An exhausted retry budget surfaces the fault that consumed it, not a
/// message the loop invented: "429, nine times" points an operator at a
/// saturated pool, a generic runtime error points them nowhere.
#[tokio::test(start_paused = true)]
async fn test_exhausted_retries_surface_the_underlying_fault() {
let calls = Arc::new(std::sync::atomic::AtomicUsize::new(0));
let seen = calls.clone();
let table = Table::new_with_handler("my_table", move |_request| {
seen.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
http::Response::builder()
.status(429)
.body(r#"{"code":21,"error":"Too many concurrent writes"}"#.to_string())
.unwrap()
});
let err = table.checkpoint_lsm().await.unwrap_err();
assert!(
matches!(&err, Error::Http { status_code: Some(s), .. } if s.as_u16() == 429),
"the fault that spent the budget must be the one reported: {err:?}"
);
assert_eq!(
calls.load(std::sync::atomic::Ordering::SeqCst),
9,
"one call plus MAX_RETRIES — the re-issue budget is not spent on top"
);
}
/// A draining node is terminal, but the client does not know that from the
/// status: draining and a proxy blip are both 503, and telling them apart
/// takes parsing the body for a namespace code. So it spends the retry
/// budget and then reports what the server said — the drain gate never
/// releases, so the answer does not change, and the operator still reads
/// "WAL node draining" in the error.
#[tokio::test(start_paused = true)]
async fn test_draining_surfaces_after_the_retry_budget() {
let calls = Arc::new(std::sync::atomic::AtomicUsize::new(0));
let seen = calls.clone();
let table = Table::new_with_handler("my_table", move |_request| {
seen.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
http::Response::builder()
.status(503)
.body(r#"{"code":19,"error":"WAL node draining"}"#.to_string())
.unwrap()
});
let err = table.checkpoint_lsm().await.unwrap_err();
let message = err.to_string();
assert!(
matches!(&err, Error::Http { status_code: Some(s), .. } if s.as_u16() == 503),
"the 503 must surface as itself: {err:?}"
);
assert!(
message.contains("WAL node draining"),
"the server's own diagnosis must survive to the caller: {message}"
);
assert_eq!(
calls.load(std::sync::atomic::Ordering::SeqCst),
9,
"one call plus MAX_RETRIES, then it reports rather than spinning"
);
}
/// A long stall with nothing compacting must keep waiting, not fail. The
/// client cannot judge this: a checkpoint queued behind unrelated tables
/// on the pod-wide compactor pool reports exactly these numbers — flat
/// generations, an idle latch — as one whose merges are failing. The
/// deadline is the caller's.
#[tokio::test(start_paused = true)]
async fn test_checkpoint_waits_out_a_long_stall_rather_than_failing() {
let polls = Arc::new(std::sync::atomic::AtomicUsize::new(0));
let seen = polls.clone();
let table = Table::new_with_handler("my_table", move |request| {
let path = request.url().path().to_string();
if path.contains("flush_lsm") || path.contains("compact_lsm") {
return accepted();
}
// Flat for far longer than any bound this loop ever had, with
// `compacting: false` throughout — then it drains.
let n = seen.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
ok_json(stats_body(if n < 40 { &[1, 2] } else { &[] }, false))
});
table
.checkpoint_lsm()
.await
.expect("a stall is the server being slow, not the client's call to make");
assert!(
polls.load(std::sync::atomic::Ordering::SeqCst) > 40,
"the loop must have kept polling well past the old ten-poll bound"
);
}
/// A pass already owns the latch on every outstanding bucket, so the loop
/// waits rather than piling on triggers it would only refuse. This is the
/// sole thing `compacting` is read for.
#[tokio::test(start_paused = true)]
async fn test_checkpoint_waits_while_a_pass_is_running() {
let polls = Arc::new(std::sync::atomic::AtomicUsize::new(0));
let compacts = Arc::new(std::sync::atomic::AtomicUsize::new(0));
let seen_polls = polls.clone();
let seen_compacts = compacts.clone();
let table = Table::new_with_handler("my_table", move |request| {
let path = request.url().path().to_string();
if path.contains("flush_lsm") {
return accepted();
}
if path.contains("compact_lsm") {
seen_compacts.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
return accepted();
}
// Latched for many polls, then done.
let n = seen_polls.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
ok_json(if n > 15 {
stats_body(&[], false)
} else {
stats_body(&[1], true)
})
});
table
.checkpoint_lsm()
.await
.expect("a running pass is progress, not a stall");
assert_eq!(
compacts.load(std::sync::atomic::Ordering::SeqCst),
0,
"never trigger against a bucket already compacting"
);
}
/// WAL off ⇒ `None`; WAL on ⇒ a fully populated `Some` with no field
/// defaulting to a zero it did not measure. `include_generation_rows`
/// rides in the body and is off unless asked for.
#[tokio::test]
async fn test_get_lsm_stats_round_trip() {
let table = Table::new_with_handler("my_table", |request| {
assert_eq!(request.url().path(), "/v1/table/my_table/get_lsm_stats/");
let body = request.body().unwrap().as_bytes().unwrap();
let body: serde_json::Value = serde_json::from_slice(body).unwrap();
assert_eq!(
body["include_generation_rows"], true,
"the flag must reach the server, not be silently dropped"
);
let response = serde_json::json!({
"lsm_stats": {
"buckets": [{
"shard_id": "b0",
"status": "Active",
"writer_epoch": 3,
"manifest_version": 11,
"current_generation": 9,
"replay_after_wal_entry_position": 100,
"wal_entry_position_last_seen": 140,
"generations": [{ "generation": 8, "bytes": 4096, "rows": 30 }],
"compacting": false,
"memtables": [
{ "generation": 9, "rows": 12, "bytes": 900, "batches": 2,
"indexes": ["vec_idx"] }
],
}],
}
});
http::Response::builder()
.status(200)
.body(response.to_string())
.unwrap()
});
let stats = table
.get_lsm_stats(true)
.await
.unwrap()
.expect("a WAL-backed table reports Some");
let bucket = &stats.buckets[0];
assert_eq!(bucket.replay_after_wal_entry_position, 100);
assert_eq!(bucket.wal_entry_position_last_seen, 140);
assert!(!bucket.compacting);
assert_eq!(bucket.generations[0].generation, 8);
assert_eq!(bucket.generations[0].rows, Some(30));
// The line that answers "why is my fresh-tier vector search
// brute-force" — an absent index name is the whole explanation.
let memtables = bucket.memtables.as_ref().unwrap();
assert_eq!(memtables[0].indexes, vec!["vec_idx".to_string()]);
}
/// A 404 arrives as `TableNotFound`, not as a lost claim the loop
/// re-issues from flush until its cap. The two are distinguished by
/// status: 404 is "no such table", 421 is "this node holds no claim".
/// They shared 404 once, and the loop chased a name that never existed.
#[tokio::test(start_paused = true)]
async fn test_missing_table_is_not_read_as_a_lost_claim() {
let calls = Arc::new(std::sync::atomic::AtomicUsize::new(0));
let seen = calls.clone();
let table = Table::new_with_handler("my_table", move |_request| {
seen.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
http::Response::builder()
.status(404)
.body(r#"{"code":4,"error":"Not found: Table not found: my_table"}"#.to_string())
.unwrap()
});
let err = table.checkpoint_lsm().await.unwrap_err();
assert!(
matches!(err, Error::TableNotFound { .. }),
"a missing table must say so: {err:?}"
);
assert_eq!(
calls.load(std::sync::atomic::Ordering::SeqCst),
1,
"no point re-claiming a table that does not exist"
);
}
/// A lost claim — 421, not 404 — does re-issue from flush, the call that
/// re-claims and replays.
#[tokio::test(start_paused = true)]
async fn test_registry_miss_reissues_from_flush() {
let calls = Arc::new(std::sync::atomic::AtomicUsize::new(0));
let seen = calls.clone();
let table = Table::new_with_handler("my_table", move |request| {
let path = request.url().path().to_string();
let n = seen.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
if path.contains("flush_lsm") {
// First flush lands; the claim is then lost, and the
// re-issued flush succeeds.
return accepted();
}
if path.contains("compact_lsm") {
if n < 4 {
return http::Response::builder()
.status(421)
.body(r#"{"code":19,"error":"table not claimed"}"#.to_string())
.unwrap();
}
return accepted();
}
ok_json(stats_body(if n < 6 { &[1] } else { &[] }, false))
});
table
.checkpoint_lsm()
.await
.expect("a lost claim must be recovered by re-flushing, not surfaced");
}
#[tokio::test]
async fn test_get_lsm_stats_absent_when_wal_off() {
let table = Table::new_with_handler("my_table", |_request| {
http::Response::builder()
.status(200)
.body(serde_json::json!({ "lsm_stats": null }).to_string())
.unwrap()
});
assert!(table.get_lsm_stats(false).await.unwrap().is_none());
}
#[tokio::test]
async fn test_wait_for_index() {
let table = _make_table_with_indices(0);
table
.wait_for_index(&["vector_idx", "my_idx"], Duration::from_secs(1))
.await
.unwrap();
}
#[tokio::test]
async fn test_wait_for_index_timeout() {
let table = _make_table_with_indices(100);
let e = table
.wait_for_index(&["vector_idx", "my_idx"], Duration::from_secs(1))
.await
.unwrap_err();
assert_eq!(
e.to_string(),
"Timeout error: timed out waiting for indices: [\"vector_idx\", \"my_idx\"] after 1s"
);
}
#[tokio::test]
async fn test_wait_for_index_timeout_never_created() {
let table = _make_table_with_indices(0);
let e = table
.wait_for_index(&["doesnt_exist_idx"], Duration::from_secs(1))
.await
.unwrap_err();
assert_eq!(
e.to_string(),
"Timeout error: timed out waiting for indices: [\"doesnt_exist_idx\"] after 1s"
);
}
fn _make_table_with_indices(unindexed_rows: usize) -> Table {
Table::new_with_handler("my_table", move |request| {
assert_eq!(request.method(), "POST");
let response_body = match request.url().path() {
"/v1/table/my_table/describe/" => {
let schema = Schema::new(vec![
Field::new(
"vector",
DataType::FixedSizeList(
Arc::new(Field::new("item", DataType::Float32, true)),
8,
),
false,
),
Field::new("my_column", DataType::Utf8, false),
]);
serde_json::from_str::<serde_json::Value>(&describe_response(&schema)).unwrap()
}
"/v1/table/my_table/index/list/" => {
serde_json::json!({
"indexes": [
{
"index_name": "vector_idx",
"index_uuid": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"columns": ["vector"],
"index_status": "done",
},
{
"index_name": "my_idx",
"index_uuid": "34255f64-5717-4562-b3fc-2c963f66afa6",
"columns": ["my_column"],
"index_status": "done",
},
]
})
}
"/v1/table/my_table/index/vector_idx/stats/" => {
serde_json::json!({
"num_indexed_rows": 100000,
"num_unindexed_rows": unindexed_rows,
"index_type": "IVF_PQ",
"distance_type": "l2"
})
}
"/v1/table/my_table/index/my_idx/stats/" => {
serde_json::json!({
"num_indexed_rows": 100000,
"num_unindexed_rows": unindexed_rows,
"index_type": "LABEL_LIST"
})
}
_path => {
serde_json::json!(None::<String>)
}
};
let body = serde_json::to_string(&response_body).unwrap();
let status = if body == "null" { 404 } else { 200 };
http::Response::builder().status(status).body(body).unwrap()
})
}
+254
View File
@@ -0,0 +1,254 @@
// SPDX-License-Identifier: Apache-2.0
// SPDX-FileCopyrightText: Copyright The LanceDB Authors
use super::*;
use crate::index::vector::{
IvfFlatIndexBuilder, IvfHnswFlatIndexBuilder, IvfHnswSqIndexBuilder, IvfRqIndexBuilder,
IvfSqIndexBuilder,
};
use crate::remote::JSON_CONTENT_TYPE;
use crate::remote::client::{ClientConfig, RetryConfig};
use crate::remote::db::DEFAULT_SERVER_VERSION;
use crate::table::{AddDataMode, FieldMetadataUpdate, FtsToken};
use crate::utils::background_cache::clock;
use crate::{
DistanceType, Error, Table,
index::{Index, IndexStatistics, IndexType, vector::IvfPqIndexBuilder},
query::{
AnalyzePlanDistributedMetrics, ColumnOrdering, ExecutableQuery, QueryBase,
QueryExecutionOptions,
},
};
use arrow::{array::AsArray, compute::concat_batches, datatypes::Int32Type};
use arrow_array::Array;
use arrow_array::builder::LargeBinaryBuilder;
use arrow_array::{
BinaryArray, Int32Array, Int64Array, RecordBatch, RecordBatchIterator, StringArray,
StructArray, record_batch,
};
use arrow_schema::{DataType, Field, Schema};
use chrono::{DateTime, Utc};
use futures::{StreamExt, TryFutureExt, TryStreamExt, future::BoxFuture};
use lance::dataset::ROW_ID;
use lance_index::scalar::inverted::{DocumentGranularity, query::MatchQuery};
use lance_index::scalar::{FullTextSearchQuery, InvertedIndexParams};
use reqwest::Body;
use rstest::rstest;
use serde_json::json;
use std::sync::Arc;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::time::Duration;
use std::{collections::HashMap, pin::Pin};
fn refresh_done(job_id: &str) -> String {
json!({
"job_id": job_id,
"job_state": "DONE",
"result": {
"rows_assigned": 12,
"rows_failed": 0,
"rows_remaining": 0,
"source_version": 7,
"published_version": 8,
}
})
.to_string()
}
async fn collect_body(body: Body) -> Vec<u8> {
use http_body::Body;
let mut body = body;
let mut data = Vec::new();
let mut body_pin = Pin::new(&mut body);
futures::stream::poll_fn(|cx| body_pin.as_mut().poll_frame(cx))
.for_each(|frame| {
data.extend_from_slice(frame.unwrap().data_ref().unwrap());
futures::future::ready(())
})
.await;
data
}
fn write_ipc_stream(data: &RecordBatch) -> Vec<u8> {
let mut body = Vec::new();
{
let options = arrow_ipc::writer::IpcWriteOptions::default()
.try_with_compression(Some(arrow_ipc::CompressionType::LZ4_FRAME))
.expect("Failed to create IPC write options");
let mut writer = arrow_ipc::writer::StreamWriter::try_new_with_options(
&mut body,
&data.schema(),
options,
)
.expect("Failed to create writer");
writer.write(data).expect("Failed to write data");
writer.finish().expect("Failed to finish");
}
body
}
fn write_ipc_stream_uncompressed(data: &RecordBatch) -> Vec<u8> {
let mut body = Vec::new();
{
let mut writer = arrow_ipc::writer::StreamWriter::try_new(&mut body, &data.schema())
.expect("Failed to create writer");
writer.write(data).expect("Failed to write data");
writer.finish().expect("Failed to finish");
}
body
}
fn write_ipc_file(data: &RecordBatch) -> Vec<u8> {
let mut body = Vec::new();
{
let mut writer = arrow_ipc::writer::FileWriter::try_new(&mut body, &data.schema())
.expect("Failed to create writer");
writer.write(data).expect("Failed to write data");
writer.finish().expect("Failed to finish");
}
body
}
/// Build a JSON describe response for the given schema.
fn describe_response(schema: &Schema) -> String {
let json_schema = JsonSchema::try_from(schema).unwrap();
serde_json::to_string(&json!({
"version": 1,
"schema": json_schema,
}))
.unwrap()
}
fn nested_index_schema() -> Schema {
let vector_type =
DataType::FixedSizeList(Arc::new(Field::new("item", DataType::Float32, true)), 8);
Schema::new(vec![
Field::new("rowId", DataType::Int32, false),
Field::new("row-id", DataType::Int32, false),
Field::new("userId", DataType::Int32, false),
Field::new(
"metadata",
DataType::Struct(vec![Field::new("user_id", DataType::Int32, false)].into()),
false,
),
Field::new(
"MetaData",
DataType::Struct(vec![Field::new("userId", DataType::Int32, false)].into()),
false,
),
Field::new(
"image",
DataType::Struct(vec![Field::new("embedding", vector_type, false)].into()),
false,
),
Field::new(
"payload",
DataType::Struct(vec![Field::new("text", DataType::Utf8, false)].into()),
false,
),
Field::new(
"docs",
DataType::List(Arc::new(Field::new(
"item",
DataType::Struct(vec![Field::new("content", DataType::Utf8, true)].into()),
true,
))),
true,
),
Field::new(
"meta-data",
DataType::Struct(vec![Field::new("user-id", DataType::Int32, false)].into()),
false,
),
Field::new(
"literal",
DataType::Struct(vec![Field::new("a.b", DataType::Int32, false)].into()),
false,
),
])
}
/// Build a `get_lsm_stats` body for one bucket holding `generations`.
fn stats_body(generations: &[u64], compacting: bool) -> String {
serde_json::json!({
"lsm_stats": {
"buckets": [{
"shard_id": "b0",
"status": "Active",
"writer_epoch": 1,
"manifest_version": 1,
"current_generation": generations.iter().max().copied().unwrap_or(0) + 1,
"replay_after_wal_entry_position": 0,
"wal_entry_position_last_seen": 0,
"generations": generations.iter()
.map(|g| serde_json::json!({ "generation": g, "bytes": 1 }))
.collect::<Vec<_>>(),
"compacting": compacting,
"memtables": [],
}],
}
})
.to_string()
}
fn schema_json() -> &'static str {
r#"{"fields": [{"name": "id", "type": {"type": "int32"}, "nullable": true}]}"#
}
fn simple_describe_response() -> http::Response<String> {
http::Response::builder()
.status(200)
.body(format!(r#"{{"version": 1, "schema": {}}}"#, schema_json()))
.unwrap()
}
// ----- Branch support -----
/// Parse a request's in-memory JSON body. Only valid for JSON-body ops
/// (not Arrow-stream inserts, whose body is a stream).
fn request_body_json(request: &reqwest::Request) -> serde_json::Value {
let bytes = request
.body()
.expect("request has a body")
.as_bytes()
.expect("body is in-memory");
serde_json::from_slice(bytes).expect("body is valid JSON")
}
/// One leg's worth of results, keyed so the two legs share a row.
/// `row_ids` mirrors a server answering a query that asked for `_rowid`.
fn leg(score_column: &str, ids: Vec<&str>, row_ids: bool) -> RecordBatch {
use arrow_array::{Float32Array, UInt64Array};
let mut fields = vec![
Field::new("id", DataType::Utf8, false),
Field::new("text", DataType::Utf8, true),
Field::new(score_column, DataType::Float32, false),
];
let scores = Float32Array::from(vec![1.0_f32; ids.len()]);
let texts = StringArray::from(ids.clone());
let mut columns: Vec<arrow_array::ArrayRef> = vec![
Arc::new(StringArray::from(ids.clone())),
Arc::new(texts),
Arc::new(scores),
];
if row_ids {
fields.push(Field::new(ROW_ID, DataType::UInt64, false));
columns.push(Arc::new(UInt64Array::from_iter_values(
ids.iter().map(|id| id.as_bytes()[0] as u64),
)));
}
RecordBatch::try_new(Arc::new(Schema::new(fields)), columns).unwrap()
}
mod blobs;
mod branches;
mod columns;
mod freshness;
mod hybrid;
mod index;
mod lsm;
mod multipart;
mod query;
mod schema_cache;
mod versions;
mod write;
@@ -0,0 +1,767 @@
// SPDX-License-Identifier: Apache-2.0
// SPDX-FileCopyrightText: Copyright The LanceDB Authors
use super::*;
#[tokio::test]
async fn test_add_insert_fails() {
// Verify that an HTTP error from the insert endpoint is properly
// surfaced with the status code intact. Use 400 (non-retryable).
let batch = record_batch!(("a", Int32, [1, 2, 3])).unwrap();
let describe_body = describe_response(&batch.schema());
let table = Table::new_with_handler("my_table", move |request| match request.url().path() {
"/v1/table/my_table/describe/" => http::Response::builder()
.status(200)
.body(describe_body.clone())
.unwrap(),
"/v1/table/my_table/insert/" => http::Response::builder()
.status(400)
.body("bad request".to_string())
.unwrap(),
path => panic!("Unexpected request path: {}", path),
});
let result = table.add(batch).execute().await;
let err = result.unwrap_err();
match &err {
Error::Http { status_code, .. } => {
assert_eq!(*status_code, Some(reqwest::StatusCode::BAD_REQUEST));
}
other => panic!("Expected Http error, got: {:?}", other),
}
}
#[tokio::test]
async fn test_add_retries_on_retryable_status() {
// Verify that rescannable data retries on retryable status codes (e.g. 502)
// and eventually succeeds.
let batch = record_batch!(("a", Int32, [1, 2, 3])).unwrap();
let describe_body = describe_response(&batch.schema());
let attempt = Arc::new(AtomicUsize::new(0));
let attempt_clone = attempt.clone();
let table = Table::new_with_handler("my_table", move |request| match request.url().path() {
"/v1/table/my_table/describe/" => http::Response::builder()
.status(200)
.body(describe_body.clone())
.unwrap(),
"/v1/table/my_table/insert/" => {
let n = attempt_clone.fetch_add(1, Ordering::SeqCst);
if n < 2 {
http::Response::builder()
.status(502)
.body("bad gateway".to_string())
.unwrap()
} else {
http::Response::builder()
.status(200)
.body(r#"{"version": 3}"#.to_string())
.unwrap()
}
}
path => panic!("Unexpected request path: {}", path),
});
let result = table.add(batch).execute().await.unwrap();
assert_eq!(result.version, 3);
assert_eq!(attempt.load(Ordering::SeqCst), 3);
}
#[tokio::test]
async fn test_query_with_datafusion_filter() {
use datafusion_expr::{col, lit};
let expected_data = RecordBatch::try_new(
Arc::new(Schema::new(vec![Field::new("a", DataType::Int32, false)])),
vec![Arc::new(Int32Array::from(vec![1, 2, 3]))],
)
.unwrap();
let expected_data_ref = expected_data.clone();
let table = Table::new_with_handler("my_table", move |request| {
assert_eq!(request.method(), "POST");
assert_eq!(request.url().path(), "/v1/table/my_table/query/");
let body = request.body().unwrap().as_bytes().unwrap();
let body: serde_json::Value = serde_json::from_slice(body).unwrap();
// The Datafusion expression should be serialized to SQL
let filter = body.get("filter").expect("filter should be present");
let filter_str = filter.as_str().expect("filter should be a string");
// col("x") > lit(10) AND col("status") = lit("active")
assert!(
filter_str.contains("x") && filter_str.contains("10"),
"Filter should contain 'x' and '10', got: {}",
filter_str
);
assert!(
filter_str.contains("status") && filter_str.contains("active"),
"Filter should contain 'status' and 'active', got: {}",
filter_str
);
let response_body = write_ipc_file(&expected_data_ref);
http::Response::builder()
.status(200)
.header(CONTENT_TYPE, ARROW_FILE_CONTENT_TYPE)
.body(response_body)
.unwrap()
});
// Use only_if_expr with a Datafusion expression
let expr = col("x").gt(lit(10)).and(col("status").eq(lit("active")));
let data = table
.query()
.only_if_expr(expr)
.execute()
.await
.unwrap()
.collect::<Vec<_>>()
.await;
assert_eq!(data.len(), 1);
assert_eq!(data[0].as_ref().unwrap(), &expected_data);
}
#[tokio::test]
async fn test_multipart_write_happy_path() {
use std::sync::Mutex;
let create_count = Arc::new(AtomicUsize::new(0));
let insert_count = Arc::new(AtomicUsize::new(0));
let complete_count = Arc::new(AtomicUsize::new(0));
let abort_count = Arc::new(AtomicUsize::new(0));
let upload_ids = Arc::new(Mutex::new(Vec::<String>::new()));
let create_count_c = create_count.clone();
let insert_count_c = insert_count.clone();
let complete_count_c = complete_count.clone();
let abort_count_c = abort_count.clone();
let upload_ids_c = upload_ids.clone();
let table = Table::new_with_handler_version(
"my_table",
semver::Version::new(0, 4, 0),
move |request| {
let path = request.url().path();
let query = request.url().query().unwrap_or("");
if path == "/v1/table/my_table/describe/" {
return simple_describe_response();
}
if path == "/v1/table/my_table/multipart_write/create" {
create_count_c.fetch_add(1, Ordering::SeqCst);
return http::Response::builder()
.status(200)
.body(r#"{"upload_id": "test-upload-123"}"#.to_string())
.unwrap();
}
if path == "/v1/table/my_table/insert/" {
insert_count_c.fetch_add(1, Ordering::SeqCst);
let uid = url::form_urlencoded::parse(query.as_bytes())
.find(|(k, _)| k == "upload_id")
.map(|(_, v)| v.to_string());
upload_ids_c
.lock()
.unwrap()
.push(uid.expect("missing upload_id on insert"));
return http::Response::builder()
.status(200)
.body(r#"{"version": 1}"#.to_string())
.unwrap();
}
if path == "/v1/table/my_table/multipart_write/complete" {
complete_count_c.fetch_add(1, Ordering::SeqCst);
let uid = url::form_urlencoded::parse(query.as_bytes())
.find(|(k, _)| k == "upload_id")
.map(|(_, v)| v.to_string());
upload_ids_c
.lock()
.unwrap()
.push(uid.expect("missing upload_id on complete"));
return http::Response::builder()
.status(200)
.body(r#"{"version": 5}"#.to_string())
.unwrap();
}
if path == "/v1/table/my_table/multipart_write/abort" {
abort_count_c.fetch_add(1, Ordering::SeqCst);
return http::Response::builder()
.status(200)
.body(String::new())
.unwrap();
}
panic!("Unexpected request path: {}", path);
},
);
let batch = record_batch!(("id", Int32, [1, 2, 3])).unwrap();
let result = table
.add(vec![batch])
.write_parallelism(2)
.execute()
.await
.unwrap();
assert_eq!(result.version, 5);
assert_eq!(create_count.load(Ordering::SeqCst), 1);
assert!(
insert_count.load(Ordering::SeqCst) > 1,
"Expected multiple insert calls, got {}",
insert_count.load(Ordering::SeqCst)
);
assert_eq!(complete_count.load(Ordering::SeqCst), 1);
assert_eq!(abort_count.load(Ordering::SeqCst), 0);
let ids = upload_ids.lock().unwrap();
assert!(
ids.iter().all(|id| id == "test-upload-123"),
"All requests should use the same upload_id, got: {:?}",
*ids
);
}
#[tokio::test]
async fn test_multipart_write_progress() {
let callback_count = Arc::new(AtomicUsize::new(0));
let max_active = Arc::new(AtomicUsize::new(0));
let last_total_tasks = Arc::new(AtomicUsize::new(0));
let seen_done = Arc::new(std::sync::Mutex::new(false));
let cb_count = callback_count.clone();
let cb_active = max_active.clone();
let cb_total = last_total_tasks.clone();
let cb_done = seen_done.clone();
let table = Table::new_with_handler_version(
"my_table",
semver::Version::new(0, 4, 0),
move |request| {
let path = request.url().path();
if path == "/v1/table/my_table/describe/" {
return simple_describe_response();
}
if path == "/v1/table/my_table/multipart_write/create" {
return http::Response::builder()
.status(200)
.body(r#"{"upload_id": "prog-upload"}"#.to_string())
.unwrap();
}
if path == "/v1/table/my_table/insert/" {
return http::Response::builder()
.status(200)
.body(r#"{"version": 1}"#.to_string())
.unwrap();
}
if path == "/v1/table/my_table/multipart_write/complete" {
return http::Response::builder()
.status(200)
.body(r#"{"version": 3}"#.to_string())
.unwrap();
}
panic!("Unexpected request path: {}", path);
},
);
let batch = record_batch!(("id", Int32, [1, 2, 3])).unwrap();
table
.add(vec![batch])
.write_parallelism(2)
.progress(move |p| {
cb_count.fetch_add(1, Ordering::SeqCst);
cb_active.fetch_max(p.active_tasks(), Ordering::SeqCst);
cb_total.store(p.total_tasks(), Ordering::SeqCst);
if p.done() {
*cb_done.lock().unwrap() = true;
}
})
.execute()
.await
.unwrap();
assert!(
callback_count.load(Ordering::SeqCst) >= 1,
"expected at least one progress callback"
);
assert!(*seen_done.lock().unwrap(), "must see done=true");
assert_eq!(last_total_tasks.load(Ordering::SeqCst), 2);
assert!(
max_active.load(Ordering::SeqCst) >= 1,
"expected at least one active task"
);
}
#[tokio::test]
async fn test_multipart_write_fallback_old_server() {
let insert_count = Arc::new(AtomicUsize::new(0));
let create_count = Arc::new(AtomicUsize::new(0));
let insert_count_c = insert_count.clone();
let create_count_c = create_count.clone();
// Server version 0.3.0 does not support multipart writes
let table = Table::new_with_handler_version(
"my_table",
semver::Version::new(0, 3, 0),
move |request| {
let path = request.url().path();
if path == "/v1/table/my_table/describe/" {
return simple_describe_response();
}
if path.contains("multipart_write") {
create_count_c.fetch_add(1, Ordering::SeqCst);
panic!("Should not call multipart write endpoints on old server");
}
if path == "/v1/table/my_table/insert/" {
let query = request.url().query().unwrap_or("");
assert!(
!query.contains("upload_id"),
"Should not have upload_id for old server"
);
insert_count_c.fetch_add(1, Ordering::SeqCst);
return http::Response::builder()
.status(200)
.body(r#"{"version": 2}"#.to_string())
.unwrap();
}
panic!("Unexpected request path: {}", path);
},
);
let batch = record_batch!(("id", Int32, [1, 2, 3])).unwrap();
let result = table
.add(vec![batch])
.write_parallelism(2)
.execute()
.await
.unwrap();
assert_eq!(result.version, 2);
assert_eq!(create_count.load(Ordering::SeqCst), 0);
assert_eq!(insert_count.load(Ordering::SeqCst), 1);
}
#[tokio::test]
async fn test_multipart_write_small_data_single_partition() {
let insert_count = Arc::new(AtomicUsize::new(0));
let create_count = Arc::new(AtomicUsize::new(0));
let insert_count_c = insert_count.clone();
let create_count_c = create_count.clone();
let table = Table::new_with_handler_version(
"my_table",
semver::Version::new(0, 4, 0),
move |request| {
let path = request.url().path();
if path == "/v1/table/my_table/describe/" {
return simple_describe_response();
}
if path.contains("multipart_write") {
create_count_c.fetch_add(1, Ordering::SeqCst);
panic!("Should not call multipart write endpoints for small data");
}
if path == "/v1/table/my_table/insert/" {
let query = request.url().query().unwrap_or("");
assert!(
!query.contains("upload_id"),
"Should not have upload_id for small data"
);
insert_count_c.fetch_add(1, Ordering::SeqCst);
return http::Response::builder()
.status(200)
.body(r#"{"version": 2}"#.to_string())
.unwrap();
}
panic!("Unexpected request path: {}", path);
},
);
// Small data: only 3 rows
let batch = record_batch!(("id", Int32, [1, 2, 3])).unwrap();
let result = table.add(vec![batch]).execute().await.unwrap();
assert_eq!(result.version, 2);
assert_eq!(create_count.load(Ordering::SeqCst), 0);
assert_eq!(insert_count.load(Ordering::SeqCst), 1);
}
#[tokio::test]
async fn test_multipart_write_empty_overwrite_uses_single_partition() {
// A multipart write creates its upload session before any partition
// executes. If the input has no batches at all, every partition would
// stage nothing (see `send_multipart_chunked`), so completing the write
// would have nothing to commit and `mode=overwrite` would be silently
// dropped. An explicit `write_parallelism` must not force the multipart
// path for empty input; it should fall back to the single-request path,
// which always sends one schema-only request and carries `mode=overwrite`.
let insert_count = Arc::new(AtomicUsize::new(0));
let multipart_count = Arc::new(AtomicUsize::new(0));
let insert_count_c = insert_count.clone();
let multipart_count_c = multipart_count.clone();
let table = Table::new_with_handler_version(
"my_table",
semver::Version::new(0, 4, 0),
move |request| {
let path = request.url().path();
if path == "/v1/table/my_table/describe/" {
return simple_describe_response();
}
if path.contains("multipart_write") {
multipart_count_c.fetch_add(1, Ordering::SeqCst);
panic!("Should not use multipart write endpoints for empty input");
}
if path == "/v1/table/my_table/insert/" {
let query = request.url().query().unwrap_or("");
assert!(
!query.contains("upload_id"),
"Should not have upload_id for empty input"
);
assert!(
query.contains("mode=overwrite"),
"Should carry mode=overwrite, got query: {}",
query
);
insert_count_c.fetch_add(1, Ordering::SeqCst);
return http::Response::builder()
.status(200)
.body(r#"{"version": 2}"#.to_string())
.unwrap();
}
panic!("Unexpected request path: {}", path);
},
);
let schema = Arc::new(Schema::new(vec![Field::new("id", DataType::Int32, true)]));
let empty_batches: Vec<std::result::Result<RecordBatch, arrow_schema::ArrowError>> = Vec::new();
let data: Box<dyn RecordBatchReader + Send> =
Box::new(RecordBatchIterator::new(empty_batches, schema));
let result = table
.add(data)
.mode(AddDataMode::Overwrite)
.write_parallelism(4)
.execute()
.await
.unwrap();
assert_eq!(result.version, 2);
assert_eq!(multipart_count.load(Ordering::SeqCst), 0);
assert_eq!(insert_count.load(Ordering::SeqCst), 1);
}
#[tokio::test]
async fn test_multipart_write_abort_on_insert_failure() {
let create_count = Arc::new(AtomicUsize::new(0));
let insert_count = Arc::new(AtomicUsize::new(0));
let complete_count = Arc::new(AtomicUsize::new(0));
let abort_count = Arc::new(AtomicUsize::new(0));
let create_count_c = create_count.clone();
let insert_count_c = insert_count.clone();
let complete_count_c = complete_count.clone();
let abort_count_c = abort_count.clone();
let table = Table::new_with_handler_version(
"my_table",
semver::Version::new(0, 4, 0),
move |request| {
let path = request.url().path();
if path == "/v1/table/my_table/describe/" {
return simple_describe_response();
}
if path == "/v1/table/my_table/multipart_write/create" {
create_count_c.fetch_add(1, Ordering::SeqCst);
return http::Response::builder()
.status(200)
.body(r#"{"upload_id": "test-upload-456"}"#.to_string())
.unwrap();
}
if path == "/v1/table/my_table/insert/" {
let count = insert_count_c.fetch_add(1, Ordering::SeqCst);
// Fail on the first insert with non-retryable status
if count == 0 {
return http::Response::builder()
.status(400)
.body("Bad Request".to_string())
.unwrap();
}
return http::Response::builder()
.status(200)
.body(r#"{"version": 1}"#.to_string())
.unwrap();
}
if path == "/v1/table/my_table/multipart_write/complete" {
complete_count_c.fetch_add(1, Ordering::SeqCst);
return http::Response::builder()
.status(200)
.body(r#"{"version": 5}"#.to_string())
.unwrap();
}
if path == "/v1/table/my_table/multipart_write/abort" {
abort_count_c.fetch_add(1, Ordering::SeqCst);
return http::Response::builder()
.status(200)
.body(String::new())
.unwrap();
}
panic!("Unexpected request path: {}", path);
},
);
let batch = record_batch!(("id", Int32, [1, 2, 3])).unwrap();
let result = table.add(vec![batch]).write_parallelism(2).execute().await;
assert!(result.is_err());
assert_eq!(create_count.load(Ordering::SeqCst), 1);
assert_eq!(complete_count.load(Ordering::SeqCst), 0);
assert_eq!(abort_count.load(Ordering::SeqCst), 1);
}
#[tokio::test]
async fn test_multipart_write_abort_on_complete_failure() {
let abort_count = Arc::new(AtomicUsize::new(0));
let abort_count_c = abort_count.clone();
let table = Table::new_with_handler_version(
"my_table",
semver::Version::new(0, 4, 0),
move |request| {
let path = request.url().path();
if path == "/v1/table/my_table/describe/" {
return simple_describe_response();
}
if path == "/v1/table/my_table/multipart_write/create" {
return http::Response::builder()
.status(200)
.body(r#"{"upload_id": "test-upload-789"}"#.to_string())
.unwrap();
}
if path == "/v1/table/my_table/insert/" {
return http::Response::builder()
.status(200)
.body(r#"{"version": 1}"#.to_string())
.unwrap();
}
if path == "/v1/table/my_table/multipart_write/complete" {
return http::Response::builder()
.status(400)
.body("Bad Request".to_string())
.unwrap();
}
if path == "/v1/table/my_table/multipart_write/abort" {
abort_count_c.fetch_add(1, Ordering::SeqCst);
return http::Response::builder()
.status(200)
.body(String::new())
.unwrap();
}
panic!("Unexpected request path: {}", path);
},
);
let batch = record_batch!(("id", Int32, [1, 2, 3])).unwrap();
let result = table.add(vec![batch]).write_parallelism(2).execute().await;
assert!(result.is_err());
assert_eq!(abort_count.load(Ordering::SeqCst), 1);
}
fn retry_config_no_backoff() -> ClientConfig {
ClientConfig {
retry_config: RetryConfig {
retries: Some(3),
connect_retries: Some(3),
read_retries: Some(3),
backoff_factor: Some(0.0),
backoff_jitter: Some(0.0),
statuses: Some(vec![502, 503]),
},
..Default::default()
}
}
#[tokio::test]
async fn test_multipart_write_retry_on_partition_failure() {
// All inserts for the first upload session return 503 (retryable).
// After exhausting internal retries, the outer loop retries with a
// new session and succeeds.
let create_count = Arc::new(AtomicUsize::new(0));
let complete_count = Arc::new(AtomicUsize::new(0));
let abort_count = Arc::new(AtomicUsize::new(0));
let create_count_c = create_count.clone();
let complete_count_c = complete_count.clone();
let abort_count_c = abort_count.clone();
let table = Table::new_with_handler_version_and_config(
"my_table",
semver::Version::new(0, 4, 0),
move |request| {
let path = request.url().path();
let query = request.url().query().unwrap_or("");
if path == "/v1/table/my_table/describe/" {
return simple_describe_response();
}
if path == "/v1/table/my_table/multipart_write/create" {
let n = create_count_c.fetch_add(1, Ordering::SeqCst);
let body = format!(r#"{{"upload_id": "upload-{}"}}"#, n + 1);
return http::Response::builder().status(200).body(body).unwrap();
}
if path == "/v1/table/my_table/insert/" {
// Fail all inserts for the first session
if query.contains("upload_id=upload-1") {
return http::Response::builder()
.status(503)
.body("Service Unavailable".to_string())
.unwrap();
}
return http::Response::builder()
.status(200)
.body(r#"{"version": 1}"#.to_string())
.unwrap();
}
if path == "/v1/table/my_table/multipart_write/complete" {
complete_count_c.fetch_add(1, Ordering::SeqCst);
return http::Response::builder()
.status(200)
.body(r#"{"version": 7}"#.to_string())
.unwrap();
}
if path == "/v1/table/my_table/multipart_write/abort" {
abort_count_c.fetch_add(1, Ordering::SeqCst);
return http::Response::builder()
.status(200)
.body(String::new())
.unwrap();
}
panic!("Unexpected request path: {}", path);
},
retry_config_no_backoff(),
);
let batch = record_batch!(("id", Int32, [1, 2, 3])).unwrap();
let result = table
.add(vec![batch])
.write_parallelism(2)
.execute()
.await
.unwrap();
assert_eq!(result.version, 7);
assert_eq!(create_count.load(Ordering::SeqCst), 2);
assert_eq!(abort_count.load(Ordering::SeqCst), 1);
assert_eq!(complete_count.load(Ordering::SeqCst), 1);
}
#[tokio::test]
async fn test_multipart_write_retry_on_complete_failure() {
// Complete returns 503 for the first session, succeeds for the second.
let create_count = Arc::new(AtomicUsize::new(0));
let abort_count = Arc::new(AtomicUsize::new(0));
let create_count_c = create_count.clone();
let abort_count_c = abort_count.clone();
let table = Table::new_with_handler_version_and_config(
"my_table",
semver::Version::new(0, 4, 0),
move |request| {
let path = request.url().path();
let query = request.url().query().unwrap_or("");
if path == "/v1/table/my_table/describe/" {
return simple_describe_response();
}
if path == "/v1/table/my_table/multipart_write/create" {
let n = create_count_c.fetch_add(1, Ordering::SeqCst);
let body = format!(r#"{{"upload_id": "upload-{}"}}"#, n + 1);
return http::Response::builder().status(200).body(body).unwrap();
}
if path == "/v1/table/my_table/insert/" {
return http::Response::builder()
.status(200)
.body(r#"{"version": 1}"#.to_string())
.unwrap();
}
if path == "/v1/table/my_table/multipart_write/complete" {
// Fail complete for first session
if query.contains("upload_id=upload-1") {
return http::Response::builder()
.status(503)
.body("Service Unavailable".to_string())
.unwrap();
}
return http::Response::builder()
.status(200)
.body(r#"{"version": 9}"#.to_string())
.unwrap();
}
if path == "/v1/table/my_table/multipart_write/abort" {
abort_count_c.fetch_add(1, Ordering::SeqCst);
return http::Response::builder()
.status(200)
.body(String::new())
.unwrap();
}
panic!("Unexpected request path: {}", path);
},
retry_config_no_backoff(),
);
let batch = record_batch!(("id", Int32, [1, 2, 3])).unwrap();
let result = table
.add(vec![batch])
.write_parallelism(2)
.execute()
.await
.unwrap();
assert_eq!(result.version, 9);
assert_eq!(create_count.load(Ordering::SeqCst), 2);
assert_eq!(abort_count.load(Ordering::SeqCst), 1);
}
@@ -0,0 +1,806 @@
// SPDX-License-Identifier: Apache-2.0
// SPDX-FileCopyrightText: Copyright The LanceDB Authors
use super::*;
#[rstest]
#[case(DEFAULT_SERVER_VERSION.clone())]
#[case(semver::Version::new(0, 2, 0))]
#[tokio::test]
async fn test_query_vector_non_finite(#[case] version: semver::Version) {
let table =
Table::new_with_handler_version("my_table", version, |_| -> http::Response<String> {
panic!("non-finite vectors must be rejected before sending a request")
});
for value in [f32::NAN, f32::INFINITY, f32::NEG_INFINITY] {
// Requests built without the query builder must also return an
// error, including a non-finite vector later in a batch.
for batched in [false, true] {
let mut request = table
.query()
.nearest_to(&[0.1, 0.2])
.unwrap()
.into_request();
if !batched {
request.query_vector.clear();
}
request
.query_vector
.push(Arc::new(arrow_array::Float32Array::from(vec![0.1, value])));
let result = table
.base_table()
.query(
&AnyQuery::VectorQuery(request),
QueryExecutionOptions::default(),
)
.await;
let Err(err) = result else {
panic!("non-finite query vector unexpectedly succeeded")
};
assert!(matches!(err, Error::InvalidInput { .. }));
assert!(err.to_string().contains("only finite values"));
}
}
}
#[tokio::test]
async fn test_query_vector_default_values() {
let expected_data = RecordBatch::try_new(
Arc::new(Schema::new(vec![Field::new("a", DataType::Int32, false)])),
vec![Arc::new(Int32Array::from(vec![1, 2, 3]))],
)
.unwrap();
let expected_data_ref = expected_data.clone();
let table = Table::new_with_handler("my_table", move |request| {
assert_eq!(request.method(), "POST");
assert_eq!(request.url().path(), "/v1/table/my_table/query/");
assert_eq!(
request.headers().get("Content-Type").unwrap(),
JSON_CONTENT_TYPE
);
let body = request.body().unwrap().as_bytes().unwrap();
let body: serde_json::Value = serde_json::from_slice(body).unwrap();
let mut expected_body = serde_json::json!({
"prefilter": true,
"nprobes": 20,
"minimum_nprobes": 20,
"maximum_nprobes": 20,
"lower_bound": Option::<f32>::None,
"upper_bound": Option::<f32>::None,
"k": 10,
"ef": Option::<usize>::None,
"refine_factor": null,
"version": null,
});
// Pass vector separately to make sure it matches f32 precision.
expected_body["vector"] = vec![0.1f32, 0.2, 0.3].into();
assert_eq!(body, expected_body);
let response_body = write_ipc_file(&expected_data_ref);
http::Response::builder()
.status(200)
.header(CONTENT_TYPE, ARROW_FILE_CONTENT_TYPE)
.body(response_body)
.unwrap()
});
let data = table
.query()
.nearest_to(vec![0.1, 0.2, 0.3])
.unwrap()
.execute()
.await;
let data = data.unwrap().collect::<Vec<_>>().await;
assert_eq!(data.len(), 1);
assert_eq!(data[0].as_ref().unwrap(), &expected_data);
}
#[tokio::test]
async fn test_query_vector_approx_mode_sent_when_set() {
let expected_data = RecordBatch::try_new(
Arc::new(Schema::new(vec![Field::new("a", DataType::Int32, false)])),
vec![Arc::new(Int32Array::from(vec![1, 2, 3]))],
)
.unwrap();
let expected_data_ref = expected_data.clone();
let table = Table::new_with_handler("my_table", move |request| {
assert_eq!(request.method(), "POST");
assert_eq!(request.url().path(), "/v1/table/my_table/query/");
assert_eq!(
request.headers().get("Content-Type").unwrap(),
JSON_CONTENT_TYPE
);
let body = request.body().unwrap().as_bytes().unwrap();
let body: serde_json::Value = serde_json::from_slice(body).unwrap();
let mut expected_body = serde_json::json!({
"prefilter": true,
"nprobes": 20,
"minimum_nprobes": 20,
"maximum_nprobes": 20,
"approx_mode": "accurate",
"lower_bound": Option::<f32>::None,
"upper_bound": Option::<f32>::None,
"k": 10,
"ef": Option::<usize>::None,
"refine_factor": null,
"version": null,
});
expected_body["vector"] = vec![0.1f32, 0.2, 0.3].into();
assert_eq!(body, expected_body);
let response_body = write_ipc_file(&expected_data_ref);
http::Response::builder()
.status(200)
.header(CONTENT_TYPE, ARROW_FILE_CONTENT_TYPE)
.body(response_body)
.unwrap()
});
let data = table
.query()
.nearest_to(vec![0.1, 0.2, 0.3])
.unwrap()
.approx_mode(crate::ApproxMode::Accurate)
.execute()
.await;
let data = data.unwrap().collect::<Vec<_>>().await;
assert_eq!(data.len(), 1);
assert_eq!(data[0].as_ref().unwrap(), &expected_data);
}
#[tokio::test]
async fn test_query_fts_default_values() {
let expected_data = RecordBatch::try_new(
Arc::new(Schema::new(vec![Field::new("a", DataType::Int32, false)])),
vec![Arc::new(Int32Array::from(vec![1, 2, 3]))],
)
.unwrap();
let expected_data_ref = expected_data.clone();
let table = Table::new_with_handler("my_table", move |request| {
assert_eq!(request.method(), "POST");
assert_eq!(request.url().path(), "/v1/table/my_table/query/");
assert_eq!(
request.headers().get("Content-Type").unwrap(),
JSON_CONTENT_TYPE
);
let body = request.body().unwrap().as_bytes().unwrap();
let body: serde_json::Value = serde_json::from_slice(body).unwrap();
let expected_body = serde_json::json!({
"full_text_query": {
"columns": [],
"query": "test",
},
"prefilter": true,
"version": null,
"k": 10,
"vector": [],
});
assert_eq!(body, expected_body);
let response_body = write_ipc_file(&expected_data_ref);
http::Response::builder()
.status(200)
.header(CONTENT_TYPE, ARROW_FILE_CONTENT_TYPE)
.body(response_body)
.unwrap()
});
let data = table
.query()
.full_text_search(FullTextSearchQuery::new("test".to_owned()))
.execute()
.await;
let data = data.unwrap().collect::<Vec<_>>().await;
assert_eq!(data.len(), 1);
assert_eq!(data[0].as_ref().unwrap(), &expected_data);
}
#[tokio::test]
async fn test_query_vector_nprobes_zero() {
let table = Table::new_with_handler::<&str>("my_table", |_| {
panic!("invalid nprobes must be rejected before sending a request")
});
let result = table
.query()
.nearest_to(vec![0.1, 0.2, 0.3])
.unwrap()
.nprobes(0);
assert!(matches!(
result,
Err(Error::InvalidInput { message }) if message == "nprobes must be greater than 0"
));
}
#[tokio::test]
async fn test_query_vector_all_params() {
let table = Table::new_with_handler("my_table", |request| {
assert_eq!(request.method(), "POST");
assert_eq!(request.url().path(), "/v1/table/my_table/query/");
assert_eq!(
request.headers().get("Content-Type").unwrap(),
JSON_CONTENT_TYPE
);
let body = request.body().unwrap().as_bytes().unwrap();
let body: serde_json::Value = serde_json::from_slice(body).unwrap();
let mut expected_body = serde_json::json!({
"vector_column": "my_vector",
"prefilter": false,
"k": 42,
"offset": 10,
"distance_type": "cosine",
"bypass_vector_index": true,
"columns": ["a", "b"],
"order_by": [
{
"column_name": "score",
"ascending": false,
"nulls_first": true,
},
{
"column_name": "id",
"ascending": true,
"nulls_first": false,
}
],
"nprobes": 12,
"minimum_nprobes": 12,
"maximum_nprobes": 12,
"lower_bound": Option::<f32>::None,
"upper_bound": Option::<f32>::None,
"ef": Option::<usize>::None,
"refine_factor": 2,
"version": null,
});
// Pass vector separately to make sure it matches f32 precision.
expected_body["vector"] = vec![0.1f32, 0.2, 0.3].into();
assert_eq!(body, expected_body);
let data = RecordBatch::try_new(
Arc::new(Schema::new(vec![Field::new("a", DataType::Int32, false)])),
vec![Arc::new(Int32Array::from(vec![1, 2, 3]))],
)
.unwrap();
let response_body = write_ipc_file(&data);
http::Response::builder()
.status(200)
.header(CONTENT_TYPE, ARROW_FILE_CONTENT_TYPE)
.body(response_body)
.unwrap()
});
let _ = table
.query()
.limit(42)
.offset(10)
.select(Select::columns(&["a", "b"]))
.order_by(Some(vec![
ColumnOrdering::desc_nulls_first("score".to_string()),
ColumnOrdering::asc_nulls_last("id".to_string()),
]))
.nearest_to(vec![0.1, 0.2, 0.3])
.unwrap()
.column("my_vector")
.postfilter()
.distance_type(crate::DistanceType::Cosine)
.nprobes(12)
.unwrap()
.refine_factor(2)
.bypass_vector_index()
.execute()
.await
.unwrap();
}
#[tokio::test]
async fn test_query_vector_nested_field_path() {
let expected_data = RecordBatch::try_new(
Arc::new(Schema::new(vec![Field::new("a", DataType::Int32, false)])),
vec![Arc::new(Int32Array::from(vec![1, 2, 3]))],
)
.unwrap();
let expected_data_ref = expected_data.clone();
let table = Table::new_with_handler("my_table", move |request| {
assert_eq!(request.method(), "POST");
assert_eq!(request.url().path(), "/v1/table/my_table/query/");
assert_eq!(
request.headers().get("Content-Type").unwrap(),
JSON_CONTENT_TYPE
);
let body = request.body().unwrap().as_bytes().unwrap();
let body: serde_json::Value = serde_json::from_slice(body).unwrap();
let mut expected_body = serde_json::json!({
"vector_column": "image.embedding",
"prefilter": true,
"k": 10,
"nprobes": 20,
"minimum_nprobes": 20,
"maximum_nprobes": 20,
"lower_bound": Option::<f32>::None,
"upper_bound": Option::<f32>::None,
"ef": Option::<usize>::None,
"refine_factor": Option::<u32>::None,
"version": null,
});
expected_body["vector"] = vec![0.1f32, 0.2, 0.3].into();
assert_eq!(body, expected_body);
let response_body = write_ipc_file(&expected_data_ref);
http::Response::builder()
.status(200)
.header(CONTENT_TYPE, ARROW_FILE_CONTENT_TYPE)
.body(response_body)
.unwrap()
});
let _ = table
.query()
.nearest_to(vec![0.1, 0.2, 0.3])
.unwrap()
.column("image.embedding")
.execute()
.await
.unwrap();
}
#[tokio::test]
async fn test_query_fts() {
let table = Table::new_with_handler("my_table", |request| {
assert_eq!(request.method(), "POST");
assert_eq!(request.url().path(), "/v1/table/my_table/query/");
assert_eq!(
request.headers().get("Content-Type").unwrap(),
JSON_CONTENT_TYPE
);
let body = request.body().unwrap().as_bytes().unwrap();
let body: serde_json::Value = serde_json::from_slice(body).unwrap();
let expected_body = serde_json::json!({
"full_text_query": {
"columns": ["a", "b"],
"query": "hello world",
},
"k": 10,
"vector": [],
"with_row_id": true,
"prefilter": true,
"version": null
});
let expected_body_2 = serde_json::json!({
"full_text_query": {
"columns": ["b","a"],
"query": "hello world",
},
"k": 10,
"vector": [],
"with_row_id": true,
"prefilter": true,
"version": null
});
assert!(body == expected_body || body == expected_body_2);
let data = RecordBatch::try_new(
Arc::new(Schema::new(vec![Field::new("a", DataType::Int32, false)])),
vec![Arc::new(Int32Array::from(vec![1, 2, 3]))],
)
.unwrap();
let response_body = write_ipc_file(&data);
http::Response::builder()
.status(200)
.header(CONTENT_TYPE, ARROW_FILE_CONTENT_TYPE)
.body(response_body)
.unwrap()
});
let _ = table
.query()
.full_text_search(
FullTextSearchQuery::new("hello world".into())
.with_columns(&["a".into(), "b".into()])
.unwrap(),
)
.with_row_id()
.limit(10)
.execute()
.await
.unwrap();
}
#[tokio::test]
async fn test_analyze_plan_distributed_metrics_query_param() {
let table = Table::new_with_handler("my_table", |request| {
assert_eq!(request.method(), "POST");
assert_eq!(request.url().path(), "/v1/table/my_table/analyze_plan/");
assert_eq!(
request
.url()
.query_pairs()
.find(|(k, _)| k == "distributed_metrics"),
Some(("distributed_metrics".into(), "per_worker".into()))
);
let body = request.body().unwrap().as_bytes().unwrap();
let body: serde_json::Value = serde_json::from_slice(body).unwrap();
assert_eq!(body["k"], serde_json::json!(1));
http::Response::builder()
.status(200)
.body(r#""analyzed plan""#)
.unwrap()
});
let result = table
.query()
.limit(1)
.analyze_plan_with_options(QueryExecutionOptions {
analyze_plan_distributed_metrics: AnalyzePlanDistributedMetrics::PerWorker,
..Default::default()
})
.await
.unwrap();
assert_eq!(result, "analyzed plan");
}
#[tokio::test]
async fn test_take_offsets_explain_plan_does_not_execute_query() {
let table = Table::new_with_handler("my_table", |request| {
assert_eq!(request.method(), "POST");
assert_eq!(request.url().path(), "/v1/table/my_table/explain_plan/");
http::Response::builder()
.status(200)
.body(r#""RemoteLookupExec""#)
.unwrap()
});
let explained = table
.take_offsets(vec![0, 1, 0, 2])
.select(crate::query::Select::columns(&["id"]))
.limit(3)
.explain_plan(false)
.await
.unwrap();
assert!(explained.contains("GlobalLimitExec"));
assert!(explained.contains("TakeRestoreExec"));
assert!(!explained.contains("CoalescePartitionsExec"));
assert!(explained.contains("RemoteLookupExec"));
}
#[tokio::test]
async fn test_converted_take_request_restores_remote_occurrences() {
let table = Table::new_with_handler("my_table", |request| {
assert_eq!(request.method(), "POST");
assert_eq!(request.url().path(), "/v1/table/my_table/query/");
let body: serde_json::Value =
serde_json::from_slice(request.body().unwrap().as_bytes().unwrap()).unwrap();
assert_eq!(body["columns"], json!(["id", "_rowoffset"]));
let data = RecordBatch::try_new(
Arc::new(Schema::new(vec![
Field::new("id", DataType::Int32, false),
Field::new("_rowoffset", DataType::UInt64, false),
])),
vec![
Arc::new(Int32Array::from(vec![5])),
Arc::new(arrow_array::UInt64Array::from(vec![5])),
],
)
.unwrap();
http::Response::builder()
.status(200)
.header(CONTENT_TYPE, ARROW_FILE_CONTENT_TYPE)
.body(write_ipc_file(&data))
.unwrap()
});
let request = table
.take_offsets(vec![5, 5])
.select(crate::query::Select::columns(&["id"]))
.into_request();
let batches = table
.base_table()
.query(&AnyQuery::Query(request), QueryExecutionOptions::default())
.await
.unwrap()
.try_collect::<Vec<_>>()
.await
.unwrap();
assert_eq!(batches.iter().map(RecordBatch::num_rows).sum::<usize>(), 2);
assert!(
batches
.iter()
.all(|batch| batch.schema().fields().len() == 1)
);
}
#[tokio::test]
async fn test_take_offsets_analyze_plan_delegates_to_remote() {
let table = Table::new_with_handler("my_table", |request| {
assert_eq!(request.method(), "POST");
assert_eq!(request.url().path(), "/v1/table/my_table/analyze_plan/");
assert_eq!(
request
.url()
.query_pairs()
.find(|(key, _)| key == "distributed_metrics"),
Some(("distributed_metrics".into(), "per_worker".into()))
);
http::Response::builder()
.status(200)
.body(r#""Remote analyzed plan: worker metrics""#)
.unwrap()
});
let analyzed = table
.take_offsets(vec![0, 1, 0, 2])
.select(crate::query::Select::columns(&["id"]))
.limit(3)
.analyze_plan_with_options(QueryExecutionOptions {
analyze_plan_distributed_metrics: AnalyzePlanDistributedMetrics::PerWorker,
..Default::default()
})
.await
.unwrap();
assert_eq!(analyzed, "Remote analyzed plan: worker metrics");
}
#[tokio::test]
async fn test_query_structured_fts() {
let table =
Table::new_with_handler_version("my_table", semver::Version::new(0, 6, 0), |request| {
assert_eq!(request.method(), "POST");
assert_eq!(request.url().path(), "/v1/table/my_table/query/");
assert_eq!(
request.headers().get("Content-Type").unwrap(),
JSON_CONTENT_TYPE
);
let body = request.body().unwrap().as_bytes().unwrap();
let body: serde_json::Value = serde_json::from_slice(body).unwrap();
let expected_body = serde_json::json!({
"full_text_query": {
"query": {
"match": {
"terms": "hello world",
"column": "payload.text",
"boost": 1.0,
"fuzziness": 0,
"max_expansions": 50,
"operator": "Or",
"prefix_length": 0,
"document_granularity": "list_element",
},
}
},
"k": 10,
"vector": [],
"with_row_id": true,
"prefilter": true,
"version": null
});
assert_eq!(body, expected_body);
let data = RecordBatch::try_new(
Arc::new(Schema::new(vec![Field::new("a", DataType::Int32, false)])),
vec![Arc::new(Int32Array::from(vec![1, 2, 3]))],
)
.unwrap();
let response_body = write_ipc_file(&data);
http::Response::builder()
.status(200)
.header(CONTENT_TYPE, ARROW_FILE_CONTENT_TYPE)
.body(response_body)
.unwrap()
});
let _ = table
.query()
.full_text_search(FullTextSearchQuery::new_query(
MatchQuery::new("hello world".to_owned())
.with_column(Some("payload.text".to_owned()))
.with_document_granularity(DocumentGranularity::ListElement)
.into(),
))
.with_row_id()
.limit(10)
.execute()
.await
.unwrap();
}
#[tokio::test]
async fn test_query_combined_fields_uses_structured_fts() {
use lance_index::scalar::inverted::query::CombinedFieldsQuery;
let table =
Table::new_with_handler_version("my_table", semver::Version::new(0, 3, 0), |request| {
let body = request.body().unwrap().as_bytes().unwrap();
let body: serde_json::Value = serde_json::from_slice(body).unwrap();
assert_eq!(
body["full_text_query"]["query"],
serde_json::json!({
"combined_fields": {
"query": "hello world",
"columns": ["title", "text"],
"boost": [1.0, 1.0],
"operator": "Or"
}
})
);
let data = RecordBatch::try_new(
Arc::new(Schema::new(vec![Field::new("a", DataType::Int32, false)])),
vec![Arc::new(Int32Array::from(vec![1]))],
)
.unwrap();
http::Response::builder()
.status(200)
.header(CONTENT_TYPE, ARROW_FILE_CONTENT_TYPE)
.body(write_ipc_file(&data))
.unwrap()
});
table
.query()
.full_text_search(FullTextSearchQuery::new_query(
CombinedFieldsQuery::try_new("hello world".into(), vec!["title".into(), "text".into()])
.unwrap()
.into(),
))
.execute()
.await
.unwrap();
}
#[tokio::test]
async fn test_query_row_document_granularity_uses_structured_fts() {
let table =
Table::new_with_handler_version("my_table", semver::Version::new(0, 3, 0), |request| {
let body = request.body().unwrap().as_bytes().unwrap();
let body: serde_json::Value = serde_json::from_slice(body).unwrap();
assert_eq!(
body["full_text_query"]["query"]["match"]["document_granularity"],
"row"
);
let data = RecordBatch::try_new(
Arc::new(Schema::new(vec![Field::new("a", DataType::Int32, false)])),
vec![Arc::new(Int32Array::from(vec![1]))],
)
.unwrap();
http::Response::builder()
.status(200)
.header(CONTENT_TYPE, ARROW_FILE_CONTENT_TYPE)
.body(write_ipc_file(&data))
.unwrap()
});
table
.query()
.full_text_search(FullTextSearchQuery::new_query(
MatchQuery::new("hello world".to_owned())
.with_column(Some("payload.text".to_owned()))
.with_document_granularity(DocumentGranularity::Row)
.into(),
))
.execute()
.await
.unwrap();
}
#[rstest]
#[case(DEFAULT_SERVER_VERSION.clone())]
#[case(semver::Version::new(0, 3, 0))]
#[case(semver::Version::new(0, 5, 0))]
#[tokio::test]
async fn test_query_document_granularity_requires_server_support(#[case] version: semver::Version) {
let table =
Table::new_with_handler_version("my_table", version, |_| -> http::Response<String> {
panic!("unsupported remote query must fail before sending a request")
});
let result = table
.query()
.full_text_search(FullTextSearchQuery::new_query(
MatchQuery::new("hello world".to_owned())
.with_column(Some("payload.text".to_owned()))
.with_document_granularity(DocumentGranularity::ListElement)
.into(),
))
.execute()
.await;
let Err(err) = result else {
panic!("legacy remote query unexpectedly succeeded")
};
assert!(
err.to_string()
.contains("document granularity requires remote server version 0.6.0 or later")
);
}
#[rstest]
#[case(DEFAULT_SERVER_VERSION.clone())]
#[case(semver::Version::new(0, 2, 0))]
#[tokio::test]
async fn test_batch_queries(#[case] version: semver::Version) {
let table = Table::new_with_handler_version("my_table", version.clone(), move |request| {
assert_eq!(request.method(), "POST");
assert_eq!(request.url().path(), "/v1/table/my_table/query/");
assert_eq!(
request.headers().get("Content-Type").unwrap(),
JSON_CONTENT_TYPE
);
let body: serde_json::Value =
serde_json::from_slice(request.body().unwrap().as_bytes().unwrap()).unwrap();
let query_vectors = body["vector"].as_array().unwrap();
let version = ServerVersion(version.clone());
let data = if version.support_multivector() {
assert_eq!(query_vectors.len(), 2);
assert_eq!(query_vectors[0].as_array().unwrap().len(), 3);
assert_eq!(query_vectors[1].as_array().unwrap().len(), 3);
RecordBatch::try_new(
Arc::new(Schema::new(vec![
Field::new("a", DataType::Int32, false),
Field::new("query_index", DataType::Int32, false),
])),
vec![
Arc::new(Int32Array::from(vec![1, 2, 3, 4, 5, 6])),
Arc::new(Int32Array::from(vec![0, 0, 0, 1, 1, 1])),
],
)
.unwrap()
} else {
// it's single flat vector, so here the length is dim
assert_eq!(query_vectors.len(), 3);
RecordBatch::try_new(
Arc::new(Schema::new(vec![Field::new("a", DataType::Int32, false)])),
vec![Arc::new(Int32Array::from(vec![1, 2, 3]))],
)
.unwrap()
};
let response_body = write_ipc_file(&data);
http::Response::builder()
.status(200)
.header(CONTENT_TYPE, ARROW_FILE_CONTENT_TYPE)
.body(response_body)
.unwrap()
});
let query = table
.query()
.nearest_to(vec![0.1, 0.2, 0.3])
.unwrap()
.add_query_vector(vec![0.4, 0.5, 0.6])
.unwrap();
let results = query
.execute()
.await
.unwrap()
.try_collect::<Vec<_>>()
.await
.unwrap();
let results = concat_batches(&results[0].schema(), &results).unwrap();
let query_index = results["query_index"].as_primitive::<Int32Type>();
// We don't guarantee order.
assert!(query_index.values().contains(&0));
assert!(query_index.values().contains(&1));
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,369 @@
// SPDX-License-Identifier: Apache-2.0
// SPDX-FileCopyrightText: Copyright The LanceDB Authors
use super::*;
#[tokio::test]
async fn test_list_versions() {
let table = Table::new_with_handler("my_table", |request| {
assert_eq!(request.method(), "POST");
assert_eq!(request.url().path(), "/v1/table/my_table/version/list/");
let version1 = lance::dataset::Version {
version: 1,
timestamp: "2024-01-01T00:00:00Z".parse().unwrap(),
metadata: Default::default(),
};
let version2 = lance::dataset::Version {
version: 2,
timestamp: "2024-02-01T00:00:00Z".parse().unwrap(),
metadata: Default::default(),
};
let response_body = serde_json::json!({
"versions": [
version1,
version2,
]
});
let response_body = serde_json::to_string(&response_body).unwrap();
http::Response::builder()
.status(200)
.body(response_body)
.unwrap()
});
let versions = table.list_versions().await.unwrap();
assert_eq!(versions.len(), 2);
assert_eq!(versions[0].version, 1);
assert_eq!(
versions[0].timestamp,
"2024-01-01T00:00:00Z".parse::<DateTime<Utc>>().unwrap()
);
assert_eq!(versions[1].version, 2);
assert_eq!(
versions[1].timestamp,
"2024-02-01T00:00:00Z".parse::<DateTime<Utc>>().unwrap()
);
// assert_eq!(versions, expected);
}
/// Namespace-backed servers report `timestamp_millis` instead of
/// `timestamp`, and may omit `metadata` entirely.
#[tokio::test]
async fn test_list_versions_timestamp_millis() {
let table = Table::new_with_handler("my_table", |request| {
assert_eq!(request.method(), "POST");
assert_eq!(request.url().path(), "/v1/table/my_table/version/list/");
let response_body = serde_json::json!({
"versions": [
{
"version": 1,
"manifest_path": "path/to/_versions/1.manifest",
"timestamp_millis": 1704067200000i64,
},
{
"version": 2,
"manifest_path": "path/to/_versions/2.manifest",
"timestamp_millis": 1706745600000i64,
"metadata": {"key": "value"},
},
]
});
let response_body = serde_json::to_string(&response_body).unwrap();
http::Response::builder()
.status(200)
.body(response_body)
.unwrap()
});
let versions = table.list_versions().await.unwrap();
assert_eq!(versions.len(), 2);
assert_eq!(versions[0].version, 1);
assert_eq!(
versions[0].timestamp,
"2024-01-01T00:00:00Z".parse::<DateTime<Utc>>().unwrap()
);
assert!(versions[0].metadata.is_empty());
assert_eq!(versions[1].version, 2);
assert_eq!(
versions[1].timestamp,
"2024-02-01T00:00:00Z".parse::<DateTime<Utc>>().unwrap()
);
assert_eq!(
versions[1].metadata.get("key").map(String::as_str),
Some("value")
);
}
#[tokio::test]
async fn test_index_stats() {
let table = Table::new_with_handler("my_table", |request| {
assert_eq!(request.method(), "POST");
assert_eq!(
request.url().path(),
"/v1/table/my_table/index/my_index/stats/"
);
let response_body = serde_json::json!({
"num_indexed_rows": 100000,
"num_unindexed_rows": 0,
"index_type": "IVF_PQ",
"distance_type": "l2"
});
let response_body = serde_json::to_string(&response_body).unwrap();
http::Response::builder()
.status(200)
.body(response_body)
.unwrap()
});
let indices = table.index_stats("my_index").await.unwrap().unwrap();
let expected = IndexStatistics {
num_indexed_rows: 100000,
num_unindexed_rows: 0,
index_type: IndexType::IvfPq,
distance_type: Some(DistanceType::L2),
num_indices: None,
};
assert_eq!(indices, expected);
let table = Table::new_with_handler("my_table", |request| {
assert_eq!(request.method(), "POST");
assert_eq!(
request.url().path(),
"/v1/table/my_table/index/my_index/stats/"
);
http::Response::builder().status(404).body("").unwrap()
});
let indices = table.index_stats("my_index").await.unwrap();
assert!(indices.is_none());
}
#[tokio::test]
async fn test_passes_version() {
let table = Table::new_with_handler("my_table", |request| {
let body = request.body().unwrap().as_bytes().unwrap();
let body: serde_json::Value = serde_json::from_slice(body).unwrap();
let version = body
.as_object()
.unwrap()
.get("version")
.unwrap()
.as_u64()
.unwrap();
assert_eq!(version, 42);
let response_body = match request.url().path() {
"/v1/table/my_table/describe/" => {
serde_json::json!({
"version": 42,
"schema": { "fields": [] }
})
}
"/v1/table/my_table/index/list/" => {
serde_json::json!({
"indexes": []
})
}
"/v1/table/my_table/index/my_idx/stats/" => {
serde_json::json!({
"num_indexed_rows": 100000,
"num_unindexed_rows": 0,
"index_type": "IVF_PQ",
"distance_type": "l2"
})
}
"/v1/table/my_table/count_rows/" => {
serde_json::json!(1000)
}
"/v1/table/my_table/query/" => {
let expected_data = RecordBatch::try_new(
Arc::new(Schema::new(vec![Field::new("a", DataType::Int32, false)])),
vec![Arc::new(Int32Array::from(vec![1, 2, 3]))],
)
.unwrap();
let expected_data_ref = expected_data.clone();
let response_body = write_ipc_file(&expected_data_ref);
return http::Response::builder()
.status(200)
.header(CONTENT_TYPE, ARROW_FILE_CONTENT_TYPE)
.body(response_body)
.unwrap();
}
path => panic!("Unexpected path: {}", path),
};
http::Response::builder()
.status(200)
.body(
serde_json::to_string(&response_body)
.unwrap()
.as_bytes()
.to_vec(),
)
.unwrap()
});
table.checkout(42).await.unwrap();
// ensure that version is passed to the /describe endpoint
let version = table.version().await.unwrap();
assert_eq!(version, 42);
// ensure it's passed to other read API calls
table.list_indices().await.unwrap();
table.index_stats("my_idx").await.unwrap();
table.count_rows(None).await.unwrap();
table
.query()
.nearest_to(vec![0.1, 0.2, 0.3])
.unwrap()
.execute()
.await
.unwrap();
}
#[tokio::test]
async fn test_restore_requires_checkout() {
let request_count = Arc::new(AtomicUsize::new(0));
let request_count_clone = request_count.clone();
let table = Table::new_with_handler("my_table", move |request| {
request_count_clone.fetch_add(1, Ordering::SeqCst);
assert_eq!(request_body_json(&request)["version"], 42);
let body = match request.url().path() {
"/v1/table/my_table/describe/" => r#"{"version":42,"schema":{"fields":[]}}"#,
"/v1/table/my_table/restore/" => r#"{"version":43}"#,
path => panic!("unexpected request path: {path}"),
};
http::Response::builder().status(200).body(body).unwrap()
});
let err = table.restore().await.unwrap_err();
assert!(matches!(err, Error::InvalidInput { message }
if message == "you must run checkout before running restore"));
assert_eq!(request_count.load(Ordering::SeqCst), 0);
table.checkout(42).await.unwrap();
table.checkout_latest().await.unwrap();
let err = table.restore().await.unwrap_err();
assert!(matches!(err, Error::InvalidInput { message }
if message == "you must run checkout before running restore"));
assert_eq!(request_count.load(Ordering::SeqCst), 1);
table.checkout(42).await.unwrap();
table.restore().await.unwrap();
assert_eq!(request_count.load(Ordering::SeqCst), 3);
let err = table.restore().await.unwrap_err();
assert!(matches!(err, Error::InvalidInput { message }
if message == "you must run checkout before running restore"));
assert_eq!(request_count.load(Ordering::SeqCst), 3);
}
#[tokio::test]
async fn test_fails_if_checkout_version_doesnt_exist() {
let table = Table::new_with_handler("my_table", |request| {
let body = request.body().unwrap().as_bytes().unwrap();
let body: serde_json::Value = serde_json::from_slice(body).unwrap();
let version = body
.as_object()
.unwrap()
.get("version")
.unwrap()
.as_u64()
.unwrap();
if version != 42 {
return http::Response::builder()
.status(404)
.body(format!("Table my_table (version: {}) not found", version))
.unwrap();
}
let response_body = match request.url().path() {
"/v1/table/my_table/describe/" => {
serde_json::json!({
"version": 42,
"schema": { "fields": [] }
})
}
_ => panic!("Unexpected path"),
};
http::Response::builder()
.status(200)
.body(serde_json::to_string(&response_body).unwrap())
.unwrap()
});
let res = table.checkout(43).await;
println!("{:?}", res);
assert!(
matches!(res, Err(Error::TableNotFound { name, .. }) if name == "my_table (version: 43)")
);
}
#[tokio::test]
async fn test_timetravel_immutable() {
let table = Table::new_with_handler::<String>("my_table", |request| {
let response_body = match request.url().path() {
"/v1/table/my_table/describe/" => {
serde_json::json!({
"version": 42,
"schema": { "fields": [] }
})
}
_ => panic!("Should not have made a request: {:?}", request),
};
http::Response::builder()
.status(200)
.body(serde_json::to_string(&response_body).unwrap())
.unwrap()
});
table.checkout(42).await.unwrap();
// Ensure that all mutable operations fail.
let res = table
.update()
.column("a", "a + 1")
.column("b", "b - 1")
.only_if("b > 10")
.execute()
.await;
assert!(matches!(res, Err(Error::NotSupported { .. })));
let batch = RecordBatch::try_new(
Arc::new(Schema::new(vec![Field::new("a", DataType::Int32, false)])),
vec![Arc::new(Int32Array::from(vec![1, 2, 3]))],
)
.unwrap();
let data: Box<dyn RecordBatchReader + Send> = Box::new(RecordBatchIterator::new(
[Ok(batch.clone())],
batch.schema(),
));
let res = table.merge_insert(&["some_col"]).execute(data).await;
assert!(matches!(res, Err(Error::NotSupported { .. })));
let res = table.delete("id in (1, 2, 3)").await;
assert!(matches!(res, Err(Error::NotSupported { .. })));
let data = RecordBatch::try_new(
Arc::new(Schema::new(vec![Field::new("a", DataType::Int32, false)])),
vec![Arc::new(Int32Array::from(vec![1, 2, 3]))],
)
.unwrap();
let res = table.add(data.clone()).execute().await;
assert!(matches!(res, Err(Error::NotSupported { .. })));
let res = table
.create_index(&["a"], Index::IvfPq(Default::default()))
.execute()
.await;
assert!(matches!(res, Err(Error::NotSupported { .. })));
}
@@ -0,0 +1,994 @@
// SPDX-License-Identifier: Apache-2.0
// SPDX-FileCopyrightText: Copyright The LanceDB Authors
use super::*;
#[tokio::test]
async fn test_not_found() {
let table = Table::new_with_handler("my_table", |_| {
http::Response::builder()
.status(404)
.body("table my_table not found")
.unwrap()
});
let batch = RecordBatch::try_new(
Arc::new(Schema::new(vec![Field::new("a", DataType::Int32, false)])),
vec![Arc::new(Int32Array::from(vec![1, 2, 3]))],
)
.unwrap();
let example_data_for_add = || batch.clone();
let example_data_for_merge = || -> Box<dyn RecordBatchReader + Send> {
Box::new(RecordBatchIterator::new(
[Ok(batch.clone())],
batch.schema(),
))
};
// These table operations should translate 404 to TableNotFound.
let results: Vec<BoxFuture<'_, Result<()>>> = vec![
Box::pin(table.version().map_ok(|_| ())),
Box::pin(table.schema().map_ok(|_| ())),
Box::pin(table.count_rows(None).map_ok(|_| ())),
Box::pin(table.update().column("a", "a + 1").execute().map_ok(|_| ())),
Box::pin(table.add(example_data_for_add()).execute().map_ok(|_| ())),
Box::pin(
table
.merge_insert(&["test"])
.execute(example_data_for_merge())
.map_ok(|_| ()),
),
Box::pin(table.delete("false").map_ok(|_| ())),
Box::pin(
table
.add_columns()
.transform(NewColumnTransform::SqlExpressions(vec![(
"x".into(),
"y".into(),
)]))
.execute()
.map_ok(|_| ()),
),
Box::pin(async {
let alterations = vec![ColumnAlteration::new("x".into()).rename("y".into())];
table.alter_columns(&alterations).await.map(|_| ())
}),
Box::pin(table.drop_columns(&["a"]).map_ok(|_| ())),
// TODO: other endpoints.
];
for result in results {
let result = result.await;
assert!(result.is_err());
assert!(
matches!(&result, &Err(Error::TableNotFound { ref name, .. }) if name == "my_table")
);
let full_error_report = snafu::Report::from_error(result.unwrap_err()).to_string();
assert!(full_error_report.contains("table my_table not found"));
}
}
#[tokio::test]
async fn test_version() {
let table = Table::new_with_handler("my_table", |request| {
assert_eq!(request.method(), "POST");
assert_eq!(request.url().path(), "/v1/table/my_table/describe/");
http::Response::builder()
.status(200)
.body(r#"{"version": 42, "schema": { "fields": [] }}"#)
.unwrap()
});
let version = table.version().await.unwrap();
assert_eq!(version, 42);
}
#[tokio::test]
async fn test_schema() {
let table = Table::new_with_handler("my_table", |request| {
assert_eq!(request.method(), "POST");
assert_eq!(request.url().path(), "/v1/table/my_table/describe/");
http::Response::builder()
.status(200)
.body(
r#"{"version": 42, "schema": {"fields": [
{"name": "a", "type": { "type": "int32" }, "nullable": false},
{"name": "b", "type": { "type": "string" }, "nullable": true}
], "metadata": {"key": "value"}}}"#,
)
.unwrap()
});
let expected = Arc::new(
Schema::new(vec![
Field::new("a", DataType::Int32, false),
Field::new("b", DataType::Utf8, true),
])
.with_metadata([("key".into(), "value".into())].into()),
);
let schema = table.schema().await.unwrap();
assert_eq!(schema, expected);
}
#[tokio::test]
async fn test_count_rows() {
let table = Table::new_with_handler("my_table", |request| {
assert_eq!(request.method(), "POST");
assert_eq!(request.url().path(), "/v1/table/my_table/count_rows/");
assert_eq!(
request.headers().get("Content-Type").unwrap(),
JSON_CONTENT_TYPE
);
assert_eq!(
request.body().unwrap().as_bytes().unwrap(),
br#"{"version":null}"#
);
http::Response::builder().status(200).body("42").unwrap()
});
let count = table.count_rows(None).await.unwrap();
assert_eq!(count, 42);
let table = Table::new_with_handler("my_table", |request| {
assert_eq!(request.method(), "POST");
assert_eq!(request.url().path(), "/v1/table/my_table/count_rows/");
assert_eq!(
request.headers().get("Content-Type").unwrap(),
JSON_CONTENT_TYPE
);
assert_eq!(
request.body().unwrap().as_bytes().unwrap(),
br#"{"predicate":"`A` > 10","version":null}"#
);
http::Response::builder().status(200).body("42").unwrap()
});
let count = table
.base_table()
.count_rows(Some(Filter::Sql(r#""A" > 10"#.into())))
.await
.unwrap();
assert_eq!(count, 42);
}
#[rstest]
#[case("", 0)]
#[case("{}", 0)]
#[case(r#"{"request_id": "test-request-id"}"#, 0)]
#[case(r#"{"version": 43}"#, 43)]
#[tokio::test]
async fn test_add_append(#[case] response_body: &str, #[case] expected_version: u64) {
let data = RecordBatch::try_new(
Arc::new(Schema::new(vec![Field::new("a", DataType::Int32, false)])),
vec![Arc::new(Int32Array::from(vec![1, 2, 3]))],
)
.unwrap();
// Clone response_body to give it 'static lifetime for the closure
let response_body = response_body.to_string();
let describe_body = describe_response(&data.schema());
let (sender, receiver) = std::sync::mpsc::channel();
let table =
Table::new_with_handler("my_table", move |mut request| match request.url().path() {
"/v1/table/my_table/describe/" => http::Response::builder()
.status(200)
.body(describe_body.clone())
.unwrap(),
"/v1/table/my_table/insert/" => {
assert_eq!(request.method(), "POST");
assert!(
request
.url()
.query_pairs()
.filter(|(k, _)| k == "mode")
.all(|(_, v)| v == "append")
);
assert_eq!(
request.headers().get("Content-Type").unwrap(),
ARROW_STREAM_CONTENT_TYPE
);
let mut body_out = reqwest::Body::from(Vec::new());
std::mem::swap(request.body_mut().as_mut().unwrap(), &mut body_out);
sender.send(body_out).unwrap();
http::Response::builder()
.status(200)
.body(response_body.clone())
.unwrap()
}
path => panic!("Unexpected request path: {}", path),
});
let result = table.add(data.clone()).execute().await.unwrap();
// Check version matches expected value
assert_eq!(result.version, expected_version);
let body = receiver.recv().unwrap();
let body = collect_body(body).await;
let expected_body = write_ipc_stream(&data);
assert_eq!(&body, &expected_body);
}
#[tokio::test]
async fn add_rejects_external_blob_flag_before_any_request() {
let table = Table::new_with_handler::<String>("my_table", |request| {
panic!("Unexpected request: {}", request.url().path())
});
let data = RecordBatch::try_new(
Arc::new(Schema::new(vec![Field::new("a", DataType::Int32, false)])),
vec![Arc::new(Int32Array::from(vec![1]))],
)
.unwrap();
let err = table
.add(data)
.allow_external_blob_outside_bases(true)
.execute()
.await
.unwrap_err();
assert!(matches!(err, Error::NotSupported { .. }), "got {err:?}");
assert!(err.to_string().contains("local tables"));
}
#[tokio::test]
async fn add_string_blob_becomes_uri_struct_without_the_local_flag() {
let table_schema = Schema::new(vec![
Field::new("id", DataType::Int64, false),
crate::blob("image", true),
]);
let describe_body = describe_response(&table_schema);
let input = RecordBatch::try_new(
Arc::new(Schema::new(vec![
Field::new("id", DataType::Int64, false),
Field::new("image", DataType::Utf8, true),
])),
vec![
Arc::new(Int64Array::from(vec![1])),
Arc::new(StringArray::from(vec![Some("s3://bucket/key")])),
],
)
.unwrap();
let (sender, receiver) = std::sync::mpsc::channel();
let table =
Table::new_with_handler("my_table", move |mut request| match request.url().path() {
"/v1/table/my_table/describe/" => http::Response::builder()
.status(200)
.body(describe_body.clone())
.unwrap(),
"/v1/table/my_table/insert/" => {
let mut body_out = reqwest::Body::from(Vec::new());
std::mem::swap(request.body_mut().as_mut().unwrap(), &mut body_out);
sender.send(body_out).unwrap();
http::Response::builder()
.status(200)
.body(r#"{"version": 2}"#.to_string())
.unwrap()
}
path => panic!("Unexpected path: {path}"),
});
table.add(input).execute().await.unwrap();
let body = collect_body(receiver.recv().unwrap()).await;
let mut reader =
arrow_ipc::reader::StreamReader::try_new(std::io::Cursor::new(body), None).unwrap();
let batch = reader.next().unwrap().unwrap();
let image = batch
.column_by_name("image")
.unwrap()
.as_any()
.downcast_ref::<StructArray>()
.expect("remote add should send the coerced blob struct");
let uri: &StringArray = image
.column_by_name("uri")
.unwrap()
.as_any()
.downcast_ref()
.unwrap();
assert_eq!(uri.value(0), "s3://bucket/key");
assert!(image.column_by_name("data").unwrap().is_null(0));
}
#[rstest]
#[case(true)]
#[case(false)]
#[tokio::test]
async fn test_add_overwrite(#[case] old_server: bool) {
let data = RecordBatch::try_new(
Arc::new(Schema::new(vec![Field::new("a", DataType::Int32, false)])),
vec![Arc::new(Int32Array::from(vec![1, 2, 3]))],
)
.unwrap();
let describe_body = describe_response(&data.schema());
let (sender, receiver) = std::sync::mpsc::channel();
let table =
Table::new_with_handler("my_table", move |mut request| match request.url().path() {
"/v1/table/my_table/describe/" => http::Response::builder()
.status(200)
.body(describe_body.clone())
.unwrap(),
"/v1/table/my_table/insert/" => {
assert_eq!(request.method(), "POST");
assert_eq!(
request
.url()
.query_pairs()
.find(|(k, _)| k == "mode")
.map(|kv| kv.1)
.as_deref(),
Some("overwrite"),
"Expected mode=overwrite"
);
assert_eq!(
request.headers().get("Content-Type").unwrap(),
ARROW_STREAM_CONTENT_TYPE
);
let mut body_out = reqwest::Body::from(Vec::new());
std::mem::swap(request.body_mut().as_mut().unwrap(), &mut body_out);
sender.send(body_out).unwrap();
if old_server {
http::Response::builder()
.status(200)
.body("".to_string())
.unwrap()
} else {
http::Response::builder()
.status(200)
.body(r#"{"version": 43}"#.to_string())
.unwrap()
}
}
path => panic!("Unexpected request path: {}", path),
});
let result = table
.add(data.clone())
.mode(AddDataMode::Overwrite)
.execute()
.await
.unwrap();
assert_eq!(result.version, if old_server { 0 } else { 43 });
let body = receiver.recv().unwrap();
let body = collect_body(body).await;
let expected_body = write_ipc_stream(&data);
assert_eq!(&body, &expected_body);
}
#[tokio::test]
async fn test_add_preprocessing() {
use crate::table::NaNVectorBehavior;
use arrow_array::{FixedSizeListArray, Float32Array, Int64Array};
// The table schema: {id: Int64, vec: FixedSizeList<Float32>[3]}
let table_schema = Schema::new(vec![
Field::new("id", DataType::Int64, false),
Field::new(
"vec",
DataType::FixedSizeList(Arc::new(Field::new("item", DataType::Float32, true)), 3),
false,
),
]);
let json_schema = JsonSchema::try_from(&table_schema).unwrap();
let describe_body = serde_json::to_string(&json!({
"version": 1,
"schema": json_schema,
}))
.unwrap();
// ---- Part 1: NaN vectors should be rejected by default ----
let nan_data = RecordBatch::try_new(
Arc::new(table_schema.clone()),
vec![
Arc::new(Int64Array::from(vec![1])),
Arc::new(
FixedSizeListArray::try_new(
Arc::new(Field::new("item", DataType::Float32, true)),
3,
Arc::new(Float32Array::from(vec![1.0, f32::NAN, 3.0])),
None,
)
.unwrap(),
),
],
)
.unwrap();
let describe_body_clone = describe_body.clone();
let table = Table::new_with_handler("my_table", move |request| match request.url().path() {
"/v1/table/my_table/describe/" => http::Response::builder()
.status(200)
.body(describe_body_clone.clone())
.unwrap(),
"/v1/table/my_table/insert/" => http::Response::builder()
.status(200)
.body(r#"{"version": 2}"#.to_string())
.unwrap(),
path => panic!("Unexpected path: {path}"),
});
let result = table.add(nan_data).execute().await;
assert!(result.is_err(), "NaN vectors should be rejected by default");
assert!(
result.unwrap_err().to_string().contains("NaN"),
"error should mention NaN"
);
// ---- Part 2: With Keep, should handle casting and missing columns ----
// Input: {id: Int32 (needs cast to Int64), vec: FixedSizeList<Float32>[3] with NaN}
// Table expects Int64 for id; NaN should be kept.
let input_schema = Schema::new(vec![
Field::new("id", DataType::Int32, false),
Field::new(
"vec",
DataType::FixedSizeList(Arc::new(Field::new("item", DataType::Float32, true)), 3),
false,
),
]);
let cast_data = RecordBatch::try_new(
Arc::new(input_schema),
vec![
Arc::new(Int32Array::from(vec![42])),
Arc::new(
FixedSizeListArray::try_new(
Arc::new(Field::new("item", DataType::Float32, true)),
3,
Arc::new(Float32Array::from(vec![1.0, f32::NAN, 3.0])),
None,
)
.unwrap(),
),
],
)
.unwrap();
let (sender, receiver) = std::sync::mpsc::channel();
let table =
Table::new_with_handler("my_table", move |mut request| match request.url().path() {
"/v1/table/my_table/describe/" => http::Response::builder()
.status(200)
.body(describe_body.clone())
.unwrap(),
"/v1/table/my_table/insert/" => {
let mut body_out = reqwest::Body::from(Vec::new());
std::mem::swap(request.body_mut().as_mut().unwrap(), &mut body_out);
sender.send(body_out).unwrap();
http::Response::builder()
.status(200)
.body(r#"{"version": 2}"#.to_string())
.unwrap()
}
path => panic!("Unexpected path: {path}"),
});
table
.add(cast_data)
.on_nan_vectors(NaNVectorBehavior::Keep)
.execute()
.await
.unwrap();
// Verify the data sent to the server was cast to the table schema.
let body = receiver.recv().unwrap();
let body = collect_body(body).await;
let cursor = std::io::Cursor::new(body);
let mut reader = arrow_ipc::reader::StreamReader::try_new(cursor, None).unwrap();
let batch = reader.next().unwrap().unwrap();
assert_eq!(batch.schema().field(0).data_type(), &DataType::Int64);
let ids: &Int64Array = batch.column(0).as_any().downcast_ref().unwrap();
assert_eq!(ids.value(0), 42);
}
#[rstest]
#[case::old_server("", 0, 0)]
#[case::rows_updated(r#"{"rows_updated": 5, "version": 43}"#, 5, 43)]
#[case::updated_rows(r#"{"updated_rows": 5, "version": 43}"#, 5, 43)]
#[case::zero_updated_rows(r#"{"updated_rows": 0, "version": 43}"#, 0, 43)]
#[case::missing_row_count(r#"{"version": 43}"#, 0, 43)]
#[tokio::test]
async fn test_update(
#[case] response_body: &'static str,
#[case] expected_rows_updated: u64,
#[case] expected_version: u64,
#[values(true, false)] filtered: bool,
) {
let table = Table::new_with_handler("my_table", move |request| {
if request.url().path() == "/v1/table/my_table/update/" {
assert_eq!(request.method(), "POST");
assert_eq!(
request.headers().get("Content-Type").unwrap(),
JSON_CONTENT_TYPE
);
if let Some(body) = request.body().unwrap().as_bytes() {
let body = std::str::from_utf8(body).unwrap();
let value: serde_json::Value = serde_json::from_str(body).unwrap();
let updates = value.get("updates").unwrap().as_array().unwrap();
assert!(updates.len() == 2);
let col_name = updates[0][0].as_str().unwrap();
let expression = updates[0][1].as_str().unwrap();
assert_eq!(col_name, "a");
assert_eq!(expression, "a + 1");
let col_name = updates[1][0].as_str().unwrap();
let expression = updates[1][1].as_str().unwrap();
assert_eq!(col_name, "b");
assert_eq!(expression, "b - 1");
assert_eq!(
value.get("predicate").unwrap(),
&serde_json::json!(if filtered { Some("`B` > 10") } else { None })
);
}
http::Response::builder()
.status(200)
.body(response_body)
.unwrap()
} else {
panic!("Unexpected request path: {}", request.url().path());
}
});
let mut update = table.update().column("a", "a + 1").column("b", "b - 1");
if filtered {
update = update.only_if(r#""B" > 10"#);
}
let result = table.base_table().update(update).await.unwrap();
assert_eq!(result.version, expected_version);
assert_eq!(result.rows_updated, expected_rows_updated);
}
#[tokio::test]
async fn test_alter_columns_rejects_missing_changes_before_request() {
let table = Table::new_with_handler::<String>("my_table", |request| {
panic!("Unexpected request: {}", request.url().path())
});
for alterations in [
vec![ColumnAlteration::new("id".into())],
vec![
ColumnAlteration::new("id".into()).rename("new_id".into()),
ColumnAlteration::new("id".into()),
],
] {
let err = table.alter_columns(&alterations).await.unwrap_err();
assert!(matches!(err, Error::InvalidInput { .. }), "got {err:?}");
assert!(err.to_string().contains("path 'id'"));
}
}
#[rstest]
#[case(true)]
#[case(false)]
#[tokio::test]
async fn test_alter_columns(#[case] old_server: bool) {
let table = Table::new_with_handler("my_table", move |request| {
if request.url().path() == "/v1/table/my_table/alter_columns/" {
assert_eq!(request.method(), "POST");
assert_eq!(
request.headers().get("Content-Type").unwrap(),
JSON_CONTENT_TYPE
);
let body = request.body().unwrap().as_bytes().unwrap();
let body = std::str::from_utf8(body).unwrap();
let value: serde_json::Value = serde_json::from_str(body).unwrap();
let alterations = value.get("alterations").unwrap().as_array().unwrap();
assert!(alterations.len() == 2);
let path = alterations[0]["path"].as_str().unwrap();
let data_type = alterations[0]["data_type"]["type"].as_str().unwrap();
assert_eq!(path, "b.c");
assert_eq!(data_type, "int32");
let path = alterations[1]["path"].as_str().unwrap();
let nullable = alterations[1]["nullable"].as_bool().unwrap();
let rename = alterations[1]["rename"].as_str().unwrap();
assert_eq!(path, "x");
assert!(nullable);
assert_eq!(rename, "y");
if old_server {
http::Response::builder()
.status(200)
.body("{}".to_string())
.unwrap()
} else {
http::Response::builder()
.status(200)
.body(r#"{"version": 43}"#.to_string())
.unwrap()
}
} else {
panic!("Unexpected request path: {}", request.url().path());
}
});
let result = table
.alter_columns(&[
ColumnAlteration::new("b.c".into()).cast_to(DataType::Int32),
ColumnAlteration::new("x".into())
.rename("y".into())
.set_nullable(true),
])
.await
.unwrap();
assert_eq!(result.version, if old_server { 0 } else { 43 });
}
#[rstest]
#[case(true)]
#[case(false)]
#[tokio::test]
async fn test_merge_insert(#[case] old_server: bool) {
let batch = RecordBatch::try_new(
Arc::new(Schema::new(vec![Field::new("a", DataType::Int32, false)])),
vec![Arc::new(Int32Array::from(vec![1, 2, 3]))],
)
.unwrap();
let data: Box<dyn RecordBatchReader + Send> = Box::new(RecordBatchIterator::new(
[Ok(batch.clone())],
batch.schema(),
));
let table = Table::new_with_handler("my_table", move |request| {
if request.url().path() == "/v1/table/my_table/merge_insert/" {
assert_eq!(request.method(), "POST");
let params = request.url().query_pairs().collect::<HashMap<_, _>>();
assert_eq!(params["on"], "some_col");
assert_eq!(params["when_matched_update_all"], "true");
assert_eq!(params["when_not_matched_insert_all"], "false");
assert_eq!(params["when_not_matched_by_source_delete"], "false");
assert_eq!(params["when_matched_update_all_filt"], "target.`A` > 0");
assert!(!params.contains_key("when_not_matched_by_source_delete_filt"));
assert!(!params.contains_key("use_index"));
if old_server {
http::Response::builder().status(200).body("{}").unwrap()
} else {
http::Response::builder()
.status(200)
.body(r#"{"version": 43, "num_deleted_rows": 0, "num_inserted_rows": 3, "num_updated_rows": 0}"#)
.unwrap()
}
} else {
panic!("Unexpected request path: {}", request.url().path());
}
});
let mut merge = table.merge_insert(&["some_col"]);
merge.when_matched_update_all(Some(r#"target."A" > 0"#.into()));
let result = table.base_table().merge_insert(merge, data).await.unwrap();
assert_eq!(result.version, if old_server { 0 } else { 43 });
if !old_server {
assert_eq!(result.num_deleted_rows, 0);
assert_eq!(result.num_inserted_rows, 3);
assert_eq!(result.num_updated_rows, 0);
}
}
#[rstest]
#[case::no_batches_insert(false, false)]
#[case::empty_batch_insert(true, false)]
#[case::no_batches_delete(false, true)]
#[case::empty_batch_delete(true, true)]
#[tokio::test]
async fn test_merge_insert_empty_source(#[case] has_batch: bool, #[case] delete_unmatched: bool) {
let mut fields = vec![Field::new("id", DataType::Int64, false)];
if !delete_unmatched {
fields.extend([
Field::new("k", DataType::Int64, false),
Field::new(
"vector",
DataType::FixedSizeList(Arc::new(Field::new("item", DataType::Float32, true)), 2),
true,
),
Field::new("s", DataType::Utf8, true),
]);
}
let schema = Arc::new(Schema::new_with_metadata(
fields,
HashMap::from([("source".to_string(), "empty".to_string())]),
));
let batches = if has_batch {
vec![Ok(RecordBatch::new_empty(schema.clone()))]
} else {
vec![]
};
let data: Box<dyn RecordBatchReader + Send> =
Box::new(RecordBatchIterator::new(batches, schema.clone()));
let attempts = Arc::new(AtomicUsize::new(0));
let attempts_ref = attempts.clone();
let num_deleted_rows = if delete_unmatched { 3 } else { 0 };
let table = Table::new_with_handler("my_table", move |request| {
assert_eq!(request.method(), "POST");
assert_eq!(request.url().path(), "/v1/table/my_table/merge_insert/");
assert_eq!(request.headers()[CONTENT_TYPE], ARROW_STREAM_CONTENT_TYPE);
let params = request.url().query_pairs().collect::<HashMap<_, _>>();
assert_eq!(params["on"], "id");
assert_eq!(
params["when_not_matched_insert_all"],
(!delete_unmatched).to_string()
);
assert_eq!(
params["when_not_matched_by_source_delete"],
delete_unmatched.to_string()
);
let body = request.body().unwrap().as_bytes().unwrap();
let reader = StreamReader::try_new(Cursor::new(body), None).unwrap();
assert_eq!(reader.schema(), schema);
for batch in reader {
assert_eq!(batch.unwrap().num_rows(), 0);
}
// The empty source must retain its schema when replayed after a conflict.
if attempts_ref.fetch_add(1, Ordering::SeqCst) == 0 {
http::Response::builder()
.status(409)
.body(String::new())
.unwrap()
} else {
http::Response::builder()
.status(200)
.body(
json!({
"version": 43,
"num_deleted_rows": num_deleted_rows,
"num_inserted_rows": 0,
"num_updated_rows": 0,
})
.to_string(),
)
.unwrap()
}
});
let mut merge = table.merge_insert(&["id"]);
if delete_unmatched {
merge.when_not_matched_by_source_delete(None);
} else {
merge.when_not_matched_insert_all();
}
let result = merge.execute(data).await.unwrap();
assert_eq!(attempts.load(Ordering::SeqCst), 2);
assert_eq!(result.version, 43);
assert_eq!(result.num_deleted_rows, num_deleted_rows);
assert_eq!(result.num_inserted_rows, 0);
assert_eq!(result.num_updated_rows, 0);
}
#[tokio::test]
async fn test_merge_insert_composite_key() {
let batch = RecordBatch::try_new(
Arc::new(Schema::new(vec![Field::new("a", DataType::Int32, false)])),
vec![Arc::new(Int32Array::from(vec![1, 2, 3]))],
)
.unwrap();
let data: Box<dyn RecordBatchReader + Send> = Box::new(RecordBatchIterator::new(
[Ok(batch.clone())],
batch.schema(),
));
let table = Table::new_with_handler("my_table", move |request| {
assert_eq!(request.url().path(), "/v1/table/my_table/merge_insert/");
// One repeated `on` per column, in the order the caller gave them.
let on = request
.url()
.query_pairs()
.filter(|(key, _)| key == "on")
.map(|(_, value)| value.into_owned())
.collect::<Vec<_>>();
assert_eq!(on, vec!["shard_key".to_string(), "id".to_string()]);
let params = request.url().query_pairs().collect::<HashMap<_, _>>();
assert_eq!(params["when_matched_update_all"], "true");
assert_eq!(params["when_not_matched_insert_all"], "true");
http::Response::builder()
.status(200)
.body(r#"{"version": 43, "num_deleted_rows": 0, "num_inserted_rows": 3, "num_updated_rows": 0}"#)
.unwrap()
});
let mut merge = table.merge_insert(&["shard_key", "id"]);
merge.when_matched_update_all(None);
merge.when_not_matched_insert_all();
let result = table.base_table().merge_insert(merge, data).await.unwrap();
assert_eq!(result.num_inserted_rows, 3);
}
#[tokio::test]
async fn test_merge_insert_rejects_repeated_on_column() {
let batch = RecordBatch::try_new(
Arc::new(Schema::new(vec![Field::new("a", DataType::Int32, false)])),
vec![Arc::new(Int32Array::from(vec![1]))],
)
.unwrap();
let data: Box<dyn RecordBatchReader + Send> = Box::new(RecordBatchIterator::new(
[Ok(batch.clone())],
batch.schema(),
));
let table = Table::new_with_handler::<&str>("my_table", |request| {
panic!("Unexpected request: {}", request.url());
});
let merge = table.merge_insert(&["id", "id"]);
let err = table
.base_table()
.merge_insert(merge, data)
.await
.unwrap_err();
assert!(
matches!(&err, Error::InvalidInput { message } if message.contains("'id' is repeated")),
"unexpected error: {err}"
);
}
#[tokio::test]
async fn test_merge_insert_retries_on_409() {
let batch = RecordBatch::try_new(
Arc::new(Schema::new(vec![Field::new("a", DataType::Int32, false)])),
vec![Arc::new(Int32Array::from(vec![1, 2, 3]))],
)
.unwrap();
let data: Box<dyn RecordBatchReader + Send> = Box::new(RecordBatchIterator::new(
[Ok(batch.clone())],
batch.schema(),
));
// Default parameters
let table = Table::new_with_handler("my_table", |request| {
assert_eq!(request.method(), "POST");
assert_eq!(request.url().path(), "/v1/table/my_table/merge_insert/");
let params = request.url().query_pairs().collect::<HashMap<_, _>>();
assert_eq!(params["on"], "some_col");
assert_eq!(params["when_matched_update_all"], "false");
assert_eq!(params["when_not_matched_insert_all"], "false");
assert_eq!(params["when_not_matched_by_source_delete"], "false");
assert!(!params.contains_key("when_matched_update_all_filt"));
assert!(!params.contains_key("when_not_matched_by_source_delete_filt"));
http::Response::builder().status(409).body("").unwrap()
});
let e = table
.merge_insert(&["some_col"])
.execute(data)
.await
.unwrap_err();
assert!(e.to_string().contains("Hit retry limit"));
}
#[rstest]
#[case(true)]
#[case(false)]
#[tokio::test]
async fn test_delete(#[case] old_server: bool) {
let table = Table::new_with_handler("my_table", move |request| {
if request.url().path() == "/v1/table/my_table/delete/" {
assert_eq!(request.method(), "POST");
assert_eq!(
request.headers().get("Content-Type").unwrap(),
JSON_CONTENT_TYPE
);
let body = request.body().unwrap().as_bytes().unwrap();
let body: serde_json::Value = serde_json::from_slice(body).unwrap();
let predicate = body.get("predicate").unwrap().as_str().unwrap();
assert_eq!(predicate, "`ID` in (1, 2, 3)");
if old_server {
http::Response::builder()
.status(200)
.body("{}".to_string())
.unwrap()
} else {
http::Response::builder()
.status(200)
.body(r#"{"version": 43}"#.to_string())
.unwrap()
}
} else {
panic!("Unexpected request path: {}", request.url().path());
}
});
let result = table
.base_table()
.delete(Predicate::String(r#""ID" in (1, 2, 3)"#))
.await
.unwrap();
assert_eq!(result.version, if old_server { 0 } else { 43 });
}
#[tokio::test]
async fn test_delete_expr() {
use datafusion_expr::{col, lit};
let table = Table::new_with_handler("my_table", move |request| {
if request.url().path() == "/v1/table/my_table/delete/" {
assert_eq!(request.method(), "POST");
let body = request.body().unwrap().as_bytes().unwrap();
let body: serde_json::Value = serde_json::from_slice(body).unwrap();
assert!(body.get("predicate").unwrap().is_string());
http::Response::builder()
.status(200)
.body(r#"{"num_deleted_rows": 4, "version": 2}"#)
.unwrap()
} else {
panic!("Unexpected request path: {}", request.url().path());
}
});
let expr = col("id").gt(lit(5));
let result = table.delete(&expr).await.unwrap();
assert_eq!(result.num_deleted_rows, 4);
assert_eq!(result.version, 2);
}
#[rstest]
#[case(true)]
#[case(false)]
#[tokio::test]
async fn test_drop_columns(#[case] old_server: bool) {
let table = Table::new_with_handler("my_table", move |request| {
if request.url().path() == "/v1/table/my_table/drop_columns/" {
assert_eq!(request.method(), "POST");
assert_eq!(
request.headers().get("Content-Type").unwrap(),
JSON_CONTENT_TYPE
);
let body = request.body().unwrap().as_bytes().unwrap();
let body = std::str::from_utf8(body).unwrap();
let value: serde_json::Value = serde_json::from_str(body).unwrap();
let columns = value.get("columns").unwrap().as_array().unwrap();
assert!(columns.len() == 2);
let col1 = columns[0].as_str().unwrap();
let col2 = columns[1].as_str().unwrap();
assert_eq!(col1, "a");
assert_eq!(col2, "b");
if old_server {
http::Response::builder().status(200).body("{}").unwrap()
} else {
http::Response::builder()
.status(200)
.body(r#"{"version": 43}"#)
.unwrap()
}
} else {
panic!("Unexpected request path: {}", request.url().path());
}
});
let result = table.drop_columns(&["a", "b"]).await.unwrap();
assert_eq!(result.version, if old_server { 0 } else { 43 });
}
+1 -915
View File
@@ -979,918 +979,4 @@ pub fn token_cache_for_config(config: &OAuthConfig) -> Result<Option<Arc<TokenCa
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::{TcpListener, TcpStream};
use crate::remote::HeaderProvider;
use crate::remote::oauth::OAuthHeaderProvider;
use oauth2::basic::BasicTokenType;
use oauth2::{AccessToken, RefreshToken};
use serial_test::serial;
/// Temp directory that satisfies the cache hardening checks. CI runners
/// can create temp directories with group/other bits set, which the
/// private-directory validation correctly rejects.
fn cache_tempdir() -> tempfile::TempDir {
let dir = tempfile::tempdir().unwrap();
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(dir.path(), std::fs::Permissions::from_mode(0o700)).unwrap();
}
dir
}
fn device_config(cache_dir: &Path) -> OAuthConfig {
OAuthConfig {
issuer_url: "https://issuer.example.com".to_string(),
client_id: "client-id".to_string(),
client_secret: None,
client_auth_method: None,
scopes: vec!["openid".to_string()],
flow: OAuthFlow::DeviceCode,
refresh_buffer_secs: None,
resource: None,
audience: None,
token_cache: Some(TokenCacheOptions::new().cache_dir(cache_dir)),
}
}
/// Stateful mock IdP covering discovery, device authorization, device
/// polling, client credentials, and refresh with strict rotation: a
/// refresh token that is not the currently issued one is rejected with
/// `invalid_grant`, which is exactly what real providers do on rotation.
struct MockIdp {
issuer_url: String,
requests: Arc<std::sync::Mutex<Vec<String>>>,
device_authorizations: Arc<AtomicUsize>,
refresh_attempts: Arc<AtomicUsize>,
invalid_grant_rejections: Arc<AtomicUsize>,
access_tokens_issued: Arc<AtomicUsize>,
current_refresh: Arc<std::sync::Mutex<Option<String>>>,
fail_refreshes: Arc<AtomicBool>,
issue_refresh_tokens: Arc<AtomicBool>,
}
impl MockIdp {
async fn start() -> Self {
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
let issuer_url = format!("http://{addr}");
let server = Self {
issuer_url: issuer_url.clone(),
requests: Arc::new(std::sync::Mutex::new(Vec::new())),
device_authorizations: Arc::new(AtomicUsize::new(0)),
refresh_attempts: Arc::new(AtomicUsize::new(0)),
invalid_grant_rejections: Arc::new(AtomicUsize::new(0)),
access_tokens_issued: Arc::new(AtomicUsize::new(0)),
current_refresh: Arc::new(std::sync::Mutex::new(None)),
fail_refreshes: Arc::new(AtomicBool::new(false)),
issue_refresh_tokens: Arc::new(AtomicBool::new(true)),
};
let requests = Arc::clone(&server.requests);
let device_authorizations = Arc::clone(&server.device_authorizations);
let refresh_attempts = Arc::clone(&server.refresh_attempts);
let invalid_grant_rejections = Arc::clone(&server.invalid_grant_rejections);
let access_tokens_issued = Arc::clone(&server.access_tokens_issued);
let current_refresh = Arc::clone(&server.current_refresh);
let fail_refreshes = Arc::clone(&server.fail_refreshes);
let issue_refresh_tokens = Arc::clone(&server.issue_refresh_tokens);
tokio::spawn(async move {
loop {
let Ok((mut stream, _)) = listener.accept().await else {
return;
};
let requests = Arc::clone(&requests);
let device_authorizations = Arc::clone(&device_authorizations);
let refresh_attempts = Arc::clone(&refresh_attempts);
let invalid_grant_rejections = Arc::clone(&invalid_grant_rejections);
let access_tokens_issued = Arc::clone(&access_tokens_issued);
let current_refresh = Arc::clone(&current_refresh);
let fail_refreshes = Arc::clone(&fail_refreshes);
let issue_refresh_tokens = Arc::clone(&issue_refresh_tokens);
tokio::spawn(async move {
let (request_line, body) = read_http_request(&mut stream).await;
if request_line.starts_with("POST ") {
requests.lock().unwrap().push(body.clone());
}
if request_line.starts_with("GET /.well-known/openid-configuration ") {
let discovery = format!(
r#"{{"token_endpoint":"http://{addr}/token","device_authorization_endpoint":"http://{addr}/device"}}"#
);
write_json_response(&mut stream, "200 OK", &discovery).await;
} else if request_line.starts_with("POST /device ") {
device_authorizations.fetch_add(1, Ordering::SeqCst);
let device = format!(
r#"{{"device_code":"device-code","user_code":"ABCD-EFGH","verification_uri":"http://{addr}/verify","expires_in":60,"interval":1}}"#
);
write_json_response(&mut stream, "200 OK", &device).await;
} else if request_line.starts_with("POST /token ") {
if body.contains("grant_type=refresh_token") {
refresh_attempts.fetch_add(1, Ordering::SeqCst);
if fail_refreshes.load(Ordering::SeqCst) {
write_json_response(
&mut stream,
"503 Service Unavailable",
r#"{"error":"temporarily_unavailable"}"#,
)
.await;
return;
}
let expected = current_refresh.lock().unwrap().clone();
let matched = body
.split('&')
.find_map(|pair| pair.strip_prefix("refresh_token="))
.map(|token| token.to_string())
.zip(expected)
.is_some_and(|(offered, expected)| offered == expected);
if !matched {
invalid_grant_rejections.fetch_add(1, Ordering::SeqCst);
write_json_response(
&mut stream,
"400 Bad Request",
r#"{"error":"invalid_grant"}"#,
)
.await;
return;
}
issue_access_token(
&mut stream,
&access_tokens_issued,
&current_refresh,
issue_refresh_tokens.load(Ordering::SeqCst),
)
.await;
} else {
// Device polling or client credentials: issue
// a token and, for interactive grants, a fresh
// refresh token with strict rotation.
let grant_device =
body.contains("grant_type=urn%3Aietf%3Aparams%3Aoauth%3Agrant-type%3Adevice_code");
if grant_device {
issue_access_token(
&mut stream,
&access_tokens_issued,
&current_refresh,
issue_refresh_tokens.load(Ordering::SeqCst),
)
.await;
} else {
let token = format!(
r#"{{"access_token":"access-{}","expires_in":3600}}"#,
access_tokens_issued.fetch_add(1, Ordering::SeqCst) + 1
);
write_json_response(&mut stream, "200 OK", &token).await;
}
}
} else {
write_json_response(&mut stream, "404 Not Found", "{}").await;
}
});
}
});
server
}
fn config(&self, cache_dir: &Path) -> OAuthConfig {
let mut config = device_config(cache_dir);
config.issuer_url = self.issuer_url.clone();
config
}
}
async fn issue_access_token(
stream: &mut TcpStream,
access_tokens_issued: &AtomicUsize,
current_refresh: &std::sync::Mutex<Option<String>>,
with_refresh: bool,
) {
let number = access_tokens_issued.fetch_add(1, Ordering::SeqCst) + 1;
let token = if with_refresh {
*current_refresh.lock().unwrap() = Some(format!("refresh-{number}"));
format!(
r#"{{"access_token":"access-{number}","refresh_token":"refresh-{number}","expires_in":3600}}"#
)
} else {
format!(r#"{{"access_token":"access-{number}","expires_in":3600}}"#)
};
write_json_response(stream, "200 OK", &token).await;
}
async fn read_http_request(stream: &mut TcpStream) -> (String, String) {
let mut buffer = Vec::new();
let mut header_end = None;
while header_end.is_none() {
let mut chunk = [0; 1024];
let read = stream.read(&mut chunk).await.unwrap();
assert_ne!(read, 0, "connection closed before request headers");
buffer.extend_from_slice(&chunk[..read]);
header_end = find_subsequence(&buffer, b"\r\n\r\n").map(|pos| pos + 4);
}
let header_end = header_end.unwrap();
let headers = String::from_utf8_lossy(&buffer[..header_end]).to_string();
let request_line = headers.lines().next().unwrap_or_default().to_string();
let content_length = headers
.lines()
.find_map(|line| {
let (name, value) = line.split_once(':')?;
name.eq_ignore_ascii_case("content-length")
.then(|| value.trim().parse::<usize>().ok())
.flatten()
})
.unwrap_or(0);
while buffer.len() < header_end + content_length {
let mut chunk = [0; 1024];
let read = stream.read(&mut chunk).await.unwrap();
assert_ne!(read, 0, "connection closed before request body");
buffer.extend_from_slice(&chunk[..read]);
}
let body =
String::from_utf8_lossy(&buffer[header_end..header_end + content_length]).to_string();
(request_line, body)
}
fn find_subsequence(haystack: &[u8], needle: &[u8]) -> Option<usize> {
haystack
.windows(needle.len())
.position(|window| window == needle)
}
async fn write_json_response(stream: &mut TcpStream, status: &str, body: &str) {
let response = format!(
"HTTP/1.1 {status}\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{body}",
body.len()
);
stream.write_all(response.as_bytes()).await.unwrap();
}
fn suppress_browser() {
// Point the OAuth browser helper at a no-op so device-flow tests never
// open a real browser window.
unsafe { std::env::set_var("LANCEDB_OAUTH_BROWSER", "/usr/bin/true") };
}
#[tokio::test]
#[serial]
async fn test_provider_reuses_cached_session_across_instances() {
suppress_browser();
let dir = cache_tempdir();
let idp = MockIdp::start().await;
let first = OAuthHeaderProvider::new(idp.config(dir.path())).unwrap();
let headers = first.get_headers().await.unwrap();
assert_eq!(headers.get("authorization").unwrap(), "Bearer access-1");
// A second, independent provider (simulating a second process) must
// refresh silently instead of starting another device flow.
let second = OAuthHeaderProvider::new(idp.config(dir.path())).unwrap();
let headers = second.get_headers().await.unwrap();
assert_eq!(headers.get("authorization").unwrap(), "Bearer access-2");
assert_eq!(idp.device_authorizations.load(Ordering::SeqCst), 1);
assert_eq!(idp.refresh_attempts.load(Ordering::SeqCst), 1);
let cache = crate::remote::token_cache::TokenCache::new(
&idp.config(dir.path()),
&TokenCacheOptions::new().cache_dir(dir.path()),
)
.unwrap();
let record = cache.load().await.unwrap().unwrap();
assert_eq!(record.refresh_token, "refresh-2");
}
#[tokio::test]
#[serial]
async fn test_concurrent_providers_serialize_rotation() {
suppress_browser();
let dir = cache_tempdir();
let idp = MockIdp::start().await;
let priming = OAuthHeaderProvider::new(idp.config(dir.path())).unwrap();
priming.get_headers().await.unwrap();
assert_eq!(idp.device_authorizations.load(Ordering::SeqCst), 1);
let provider_a = OAuthHeaderProvider::new(idp.config(dir.path())).unwrap();
let provider_b = OAuthHeaderProvider::new(idp.config(dir.path())).unwrap();
let (headers_a, headers_b) =
tokio::join!(provider_a.get_headers(), provider_b.get_headers());
let token_a = headers_a.unwrap().remove("authorization").unwrap();
let token_b = headers_b.unwrap().remove("authorization").unwrap();
assert!(
{
let mut tokens = [token_a, token_b];
tokens.sort();
tokens
} == ["Bearer access-2".to_string(), "Bearer access-3".to_string()],
"each provider must observe its own refreshed token"
);
// Rotation raced would produce an invalid_grant and a second device
// flow; the lock prevents both.
assert_eq!(idp.invalid_grant_rejections.load(Ordering::SeqCst), 0);
assert_eq!(idp.device_authorizations.load(Ordering::SeqCst), 1);
assert_eq!(idp.refresh_attempts.load(Ordering::SeqCst), 2);
let cache = crate::remote::token_cache::TokenCache::new(
&idp.config(dir.path()),
&TokenCacheOptions::new().cache_dir(dir.path()),
)
.unwrap();
let record = cache.load().await.unwrap().unwrap();
assert_eq!(record.refresh_token, "refresh-3");
}
#[tokio::test]
#[serial]
async fn test_transient_refresh_failure_retains_record() {
suppress_browser();
let dir = cache_tempdir();
let idp = MockIdp::start().await;
let priming = OAuthHeaderProvider::new(idp.config(dir.path())).unwrap();
priming.get_headers().await.unwrap();
idp.fail_refreshes.store(true, Ordering::SeqCst);
let second = OAuthHeaderProvider::new(idp.config(dir.path())).unwrap();
let error = second.get_headers().await.unwrap_err();
assert!(error.to_string().contains("503"));
let session = OAuthSession::new(idp.config(dir.path())).unwrap();
let status = session.status().await.unwrap();
assert!(
status.refreshable,
"transient failures must keep the record"
);
}
#[tokio::test]
#[serial]
async fn test_invalid_grant_deletes_record_and_reauthenticates() {
suppress_browser();
let dir = cache_tempdir();
let idp = MockIdp::start().await;
let priming = OAuthHeaderProvider::new(idp.config(dir.path())).unwrap();
priming.get_headers().await.unwrap();
// Simulate a revoked refresh token by replacing the record with one
// the provider never issued.
let cache = crate::remote::token_cache::TokenCache::new(
&idp.config(dir.path()),
&TokenCacheOptions::new().cache_dir(dir.path()),
)
.unwrap();
let mut record = cache.load().await.unwrap().unwrap();
record.refresh_token = "revoked-refresh".to_string();
cache.store(&record).await.unwrap();
let second = OAuthHeaderProvider::new(idp.config(dir.path())).unwrap();
let headers = second.get_headers().await.unwrap();
assert_eq!(headers.get("authorization").unwrap(), "Bearer access-2");
assert_eq!(idp.invalid_grant_rejections.load(Ordering::SeqCst), 1);
assert_eq!(idp.device_authorizations.load(Ordering::SeqCst), 2);
let record = cache.load().await.unwrap().unwrap();
assert_eq!(record.refresh_token, "refresh-2");
}
#[tokio::test]
#[serial]
async fn test_session_login_status_logout_lifecycle() {
suppress_browser();
let dir = cache_tempdir();
let idp = MockIdp::start().await;
let session = OAuthSession::new(idp.config(dir.path())).unwrap();
let status = session.status().await.unwrap();
assert!(!status.refreshable);
let status = session.login().await.unwrap();
assert!(status.refreshable);
assert_eq!(status.issuer_url, idp.issuer_url);
assert_eq!(status.client_id, "client-id");
assert_eq!(status.scopes, vec!["openid".to_string()]);
assert_eq!(status.flow, "device_code");
assert!(status.obtained_at.is_some());
// An independent session manager sees the same cached login.
let other = OAuthSession::new(idp.config(dir.path())).unwrap();
assert!(other.status().await.unwrap().refreshable);
assert!(session.logout().await.unwrap().removed);
assert!(!session.logout().await.unwrap().removed);
assert!(!other.status().await.unwrap().refreshable);
assert_eq!(idp.device_authorizations.load(Ordering::SeqCst), 1);
}
#[tokio::test]
#[serial]
async fn test_login_without_refresh_token_clears_prior_record() {
suppress_browser();
let dir = cache_tempdir();
let idp = MockIdp::start().await;
let session = OAuthSession::new(idp.config(dir.path())).unwrap();
session.login().await.unwrap();
assert!(session.status().await.unwrap().refreshable);
// A provider that stops issuing refresh tokens (for example a login
// without offline_access) must not leave the earlier account behind.
idp.issue_refresh_tokens.store(false, Ordering::SeqCst);
let status = session.login().await.unwrap();
assert!(!status.refreshable);
assert!(!session.status().await.unwrap().refreshable);
}
#[tokio::test]
async fn test_client_credentials_with_cache_stays_memory_only() {
let dir = cache_tempdir();
let idp = MockIdp::start().await;
let mut config = idp.config(dir.path());
config.flow = OAuthFlow::ClientCredentials;
config.client_secret = Some("secret".to_string());
let provider = OAuthHeaderProvider::new(config).unwrap();
let headers = provider.get_headers().await.unwrap();
assert_eq!(headers.get("authorization").unwrap(), "Bearer access-1");
assert_eq!(dir.path().read_dir().unwrap().count(), 0);
}
#[test]
fn test_managed_identity_with_cache_is_rejected() {
let dir = cache_tempdir();
let mut config = device_config(dir.path());
config.flow = OAuthFlow::AzureManagedIdentity { client_id: None };
let err = OAuthHeaderProvider::new(config).unwrap_err();
assert!(
matches!(err, Error::InvalidInput { message } if message.contains("AzureManagedIdentity"))
);
}
#[tokio::test]
#[serial]
async fn test_provider_debug_and_status_reveal_no_secrets() {
suppress_browser();
let dir = cache_tempdir();
let idp = MockIdp::start().await;
let provider = OAuthHeaderProvider::new(idp.config(dir.path())).unwrap();
let headers = provider.get_headers().await.unwrap();
assert_eq!(headers.get("authorization").unwrap(), "Bearer access-1");
let debug = format!("{provider:?}");
assert!(!debug.contains("access-1"));
assert!(!debug.contains("refresh-1"));
let session = OAuthSession::new(idp.config(dir.path())).unwrap();
let status = session.status().await.unwrap();
assert!(!format!("{status:?}").contains("refresh-"));
}
#[tokio::test]
async fn test_targeted_cache_refresh_and_logout_isolation() {
let dir = cache_tempdir();
let idp = MockIdp::start().await;
let mut config = idp.config(dir.path());
let options = config.token_cache.clone().unwrap();
let untargeted_key = CacheKey::new(&config).unwrap().file_stem;
assert_eq!(
untargeted_key,
hex_sha256(
format!(
"v1\n{}\nclient-id\nopenid\ndevice_code\npublic",
idp.issuer_url
)
.as_bytes()
)
);
let mut sessions = Vec::new();
let mut keys = std::collections::HashSet::new();
for (resource, audience) in [
(None, None),
(Some("urn:one"), None),
(None, Some("audience & ü")),
(Some("urn:one"), Some("audience & ü")),
(Some("urn:two"), Some("audience & ü")),
(Some("urn:one"), Some("other")),
] {
config.resource = resource.map(str::to_owned);
config.audience = audience.map(str::to_owned);
let cache = TokenCache::new(&config, &options).unwrap();
assert!(keys.insert(cache.key.file_stem.clone()));
let record = cache
.record_from_response(&TokenResponse {
access_token: AccessToken::new("unused".into()),
refresh_token: Some(RefreshToken::new("seed-refresh".into())),
expires_in: Some(3600),
token_type: None,
})
.unwrap();
cache.store(&record).await.unwrap();
*idp.current_refresh.lock().unwrap() = Some("seed-refresh".into());
let provider = OAuthHeaderProvider::new(config.clone()).unwrap();
provider.get_headers().await.unwrap();
let request = idp.requests.lock().unwrap().last().unwrap().clone();
let params: std::collections::HashMap<_, _> =
url::form_urlencoded::parse(request.as_bytes()).collect();
assert_eq!(params.get("resource").map(|s| s.as_ref()), resource);
assert_eq!(params.get("audience").map(|s| s.as_ref()), audience);
assert_eq!(params.get("grant_type").unwrap(), "refresh_token");
let session = OAuthSession::new(config.clone()).unwrap();
let status = session.status().await.unwrap();
assert!(status.refreshable);
assert_eq!(status.resource, config.resource);
assert_eq!(status.audience, config.audience);
sessions.push(session);
}
assert!(sessions.pop().unwrap().logout().await.unwrap().removed);
for session in sessions {
assert!(session.status().await.unwrap().refreshable);
}
assert_eq!(idp.device_authorizations.load(Ordering::SeqCst), 0);
}
#[tokio::test]
async fn test_legacy_cache_record_without_target_fields() {
let dir = cache_tempdir();
let config = device_config(dir.path());
let cache = TokenCache::new(&config, config.token_cache.as_ref().unwrap()).unwrap();
let legacy = br#"{"version":1,"issuer_url":"https://issuer.example.com","client_id":"client-id","scopes":["openid"],"flow":"device_code","client_auth":"public","refresh_token":"legacy","obtained_at":1}"#;
write_record(dir.path(), &cache.record_path(), legacy).unwrap();
let session = OAuthSession::new(config).unwrap();
let status = session.status().await.unwrap();
assert!(status.refreshable);
assert_eq!(status.resource, None);
assert_eq!(status.audience, None);
assert!(session.logout().await.unwrap().removed);
}
#[test]
fn test_cache_key_canonicalizes_scopes_and_issuer() {
let mut config = device_config(Path::new("/tmp/cache"));
config.issuer_url = "https://issuer.example.com/".to_string();
config.scopes = vec!["b".to_string(), " a ".to_string(), "a".to_string()];
let key = CacheKey::new(&config).unwrap();
assert_eq!(key.issuer_url, "https://issuer.example.com");
assert_eq!(key.scopes, vec!["a".to_string(), "b".to_string()]);
config.issuer_url = "https://issuer.example.com".to_string();
let canonical = CacheKey::new(&config).unwrap();
assert_eq!(canonical.file_stem, key.file_stem);
}
#[test]
fn test_cache_key_separates_identity_dimensions() {
let base = device_config(Path::new("/tmp/cache"));
let base_key = CacheKey::new(&base).unwrap();
let mut other = base.clone();
other.client_id = "other-client".to_string();
assert_ne!(CacheKey::new(&other).unwrap().file_stem, base_key.file_stem);
let mut other = base.clone();
other.issuer_url = "https://other.example.com".to_string();
assert_ne!(CacheKey::new(&other).unwrap().file_stem, base_key.file_stem);
let mut other = base.clone();
other.scopes = vec!["profile".to_string()];
assert_ne!(CacheKey::new(&other).unwrap().file_stem, base_key.file_stem);
let mut other = base.clone();
other.flow = OAuthFlow::AuthorizationCode(Default::default());
assert_ne!(CacheKey::new(&other).unwrap().file_stem, base_key.file_stem);
let mut other = base.clone();
other.client_secret = Some("secret".to_string());
assert_ne!(CacheKey::new(&other).unwrap().file_stem, base_key.file_stem);
}
#[test]
fn test_cache_key_contains_no_secret_material() {
let mut config = device_config(Path::new("/tmp/cache"));
config.client_secret = Some("super-secret-value".to_string());
let key = CacheKey::new(&config).unwrap();
assert!(!key.file_stem.contains("super-secret-value"));
assert_eq!(key.file_stem.len(), 64);
}
#[test]
fn test_flow_key_rejects_non_persistent_flows() {
let mut config = device_config(Path::new("/tmp/cache"));
config.flow = OAuthFlow::ClientCredentials;
let err = CacheKey::new(&config).unwrap_err();
assert!(
matches!(err, Error::InvalidInput { message } if message.contains("not supported"))
);
let mut config = device_config(Path::new("/tmp/cache"));
config.flow = OAuthFlow::AzureManagedIdentity { client_id: None };
assert!(CacheKey::new(&config).is_err());
}
#[test]
fn test_token_cache_options_defaults() {
let options = TokenCacheOptions::new();
assert!(options.cache_dir.is_none());
assert!(options.lock_timeout_secs.is_none());
assert_eq!(options.lock_timeout(), Duration::from_secs(30));
let options = options.cache_dir("/tmp/x").lock_timeout_secs(5);
assert_eq!(options.cache_dir.as_deref(), Some(Path::new("/tmp/x")));
assert_eq!(options.lock_timeout(), Duration::from_secs(5));
}
#[test]
fn test_token_cache_options_rejects_empty_dir() {
let options = TokenCacheOptions::new().cache_dir("");
assert!(matches!(
options.resolved_dir(),
Err(Error::InvalidInput { message }) if message.contains("must not be empty")
));
}
#[tokio::test]
async fn test_session_lifecycle_without_cache_entry() {
let dir = cache_tempdir();
let session = OAuthSession::new(device_config(dir.path())).unwrap();
let status = session.status().await.unwrap();
assert!(!status.refreshable);
assert_eq!(status.issuer_url, "https://issuer.example.com");
assert_eq!(status.client_id, "client-id");
assert_eq!(status.scopes, vec!["openid".to_string()]);
assert_eq!(status.flow, "device_code");
assert_eq!(status.obtained_at, None);
let logout = session.logout().await.unwrap();
assert!(!logout.removed);
}
#[tokio::test]
async fn test_record_round_trip_and_redaction() {
let dir = cache_tempdir();
let cache = TokenCache::new(
&device_config(dir.path()),
&TokenCacheOptions::new().cache_dir(dir.path()),
)
.unwrap();
let response = TokenResponse {
access_token: AccessToken::new("access-token".to_string()),
refresh_token: Some(RefreshToken::new("refresh-token".to_string())),
expires_in: Some(3600),
token_type: Some(BasicTokenType::Bearer),
};
let record = cache.record_from_response(&response).unwrap();
let debug = format!("{record:?}");
assert!(!debug.contains("refresh-token"));
assert!(debug.contains("<redacted>"));
// No access-token material is persisted.
let json = serde_json::to_string(&record).unwrap();
assert!(!json.contains("access-token"));
cache.store(&record).await.unwrap();
let loaded = cache.load().await.unwrap().unwrap();
assert_eq!(loaded.refresh_token, "refresh-token");
assert_eq!(loaded.version, CACHE_RECORD_VERSION);
// The on-disk file must not be group/other readable and must not be a symlink target.
let path = cache.record_path();
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let mode = std::fs::metadata(&path).unwrap().permissions().mode();
assert_eq!(mode & 0o077, 0);
}
assert!(std::fs::symlink_metadata(&path).unwrap().is_file());
assert!(cache.delete().await.unwrap());
assert!(cache.load().await.unwrap().is_none());
assert!(!cache.delete().await.unwrap());
}
#[cfg(unix)]
#[tokio::test]
async fn test_record_rejects_symlink() {
let dir = cache_tempdir();
let cache = TokenCache::new(
&device_config(dir.path()),
&TokenCacheOptions::new().cache_dir(dir.path()),
)
.unwrap();
let record = cache
.record_from_response(&TokenResponse {
access_token: AccessToken::new("a".to_string()),
refresh_token: Some(RefreshToken::new("r".to_string())),
expires_in: None,
token_type: None,
})
.unwrap();
cache.store(&record).await.unwrap();
let path = cache.record_path();
let target = dir.path().join("evil.json");
std::fs::write(&target, "{}").unwrap();
std::fs::remove_file(&path).unwrap();
std::os::unix::fs::symlink(&target, &path).unwrap();
let err = cache.load().await.unwrap_err();
assert!(
matches!(err, Error::InvalidInput { message } if message.contains("not a regular file"))
);
}
#[cfg(unix)]
#[tokio::test]
async fn test_record_rejects_world_readable_file() {
let dir = cache_tempdir();
let cache = TokenCache::new(
&device_config(dir.path()),
&TokenCacheOptions::new().cache_dir(dir.path()),
)
.unwrap();
let path = cache.record_path();
std::fs::write(&path, "{}").unwrap();
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o644)).unwrap();
let err = cache.load().await.unwrap_err();
assert!(
matches!(err, Error::InvalidInput { message } if message.contains("group or other"))
);
}
/// Write a record file that passes the permission hardening checks.
fn write_record_file(path: &Path, contents: &str) {
std::fs::write(path, contents).unwrap();
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600)).unwrap();
}
}
#[tokio::test]
async fn test_record_rejects_unknown_version_and_corruption() {
let dir = cache_tempdir();
let cache = TokenCache::new(
&device_config(dir.path()),
&TokenCacheOptions::new().cache_dir(dir.path()),
)
.unwrap();
let path = cache.record_path();
// A complete, well-formed record with an unknown schema version.
let record = cache
.record_from_response(&TokenResponse {
access_token: AccessToken::new("a".to_string()),
refresh_token: Some(RefreshToken::new("r".to_string())),
expires_in: None,
token_type: None,
})
.unwrap();
let mut json = serde_json::to_value(&record).unwrap();
json["version"] = serde_json::json!(99);
write_record_file(&path, &json.to_string());
let err = cache.load().await.unwrap_err();
assert!(
matches!(err, Error::Runtime { message } if message.contains("unsupported version"))
);
write_record_file(&path, r#"{"version":1,"issuer_url":"x""#);
let err = cache.load().await.unwrap_err();
assert!(matches!(err, Error::Runtime { message } if message.contains("corrupt")));
write_record_file(&path, "");
assert!(
cache
.load()
.await
.unwrap_err()
.to_string()
.contains("corrupt")
);
}
#[cfg(unix)]
#[tokio::test]
async fn test_cache_dir_rejects_open_permissions() {
let dir = cache_tempdir();
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(dir.path(), std::fs::Permissions::from_mode(0o755)).unwrap();
let err = TokenCache::new(
&device_config(dir.path()),
&TokenCacheOptions::new().cache_dir(dir.path()),
)
.unwrap_err();
assert!(matches!(err, Error::InvalidInput { message } if message.contains("chmod 700")));
}
#[tokio::test]
async fn test_lock_serializes_and_releases() {
let dir = cache_tempdir();
let cache = TokenCache::new(
&device_config(dir.path()),
&TokenCacheOptions::new().cache_dir(dir.path()),
)
.unwrap();
let guard = cache.acquire_lock().await.unwrap();
let contender = {
let dir = dir.path().to_path_buf();
let cache2 = TokenCache::new(
&device_config(&dir),
&TokenCacheOptions::new()
.cache_dir(&dir)
.lock_timeout_secs(1),
)
.unwrap();
tokio::time::timeout(Duration::from_millis(300), cache2.acquire_lock()).await
};
assert!(contender.is_err(), "second acquire must block while held");
drop(guard);
let cache3 = TokenCache::new(
&device_config(dir.path()),
&TokenCacheOptions::new().cache_dir(dir.path()),
)
.unwrap();
tokio::time::timeout(Duration::from_secs(5), cache3.acquire_lock())
.await
.expect("lock re-acquirable after release")
.unwrap();
}
#[test]
fn test_token_cache_for_config_gating() {
let dir = cache_tempdir();
assert!(
token_cache_for_config(&device_config(dir.path()))
.unwrap()
.is_some()
);
let mut config = device_config(dir.path());
config.token_cache = None;
assert!(token_cache_for_config(&config).unwrap().is_none());
let mut config = device_config(dir.path());
config.flow = OAuthFlow::ClientCredentials;
assert!(token_cache_for_config(&config).unwrap().is_none());
let mut config = device_config(dir.path());
config.flow = OAuthFlow::AzureManagedIdentity { client_id: None };
let err = token_cache_for_config(&config).unwrap_err();
assert!(
matches!(err, Error::InvalidInput { message } if message.contains("AzureManagedIdentity"))
);
}
#[test]
fn test_oauth_session_requires_cache_options() {
let mut config = device_config(Path::new("/tmp/cache"));
config.token_cache = None;
let err = OAuthSession::new(config).unwrap_err();
assert!(matches!(err, Error::InvalidInput { message } if message.contains("token_cache")));
}
#[tokio::test]
async fn test_store_skips_responses_without_refresh_token() {
let dir = cache_tempdir();
let cache = TokenCache::new(
&device_config(dir.path()),
&TokenCacheOptions::new().cache_dir(dir.path()),
)
.unwrap();
let response = TokenResponse {
access_token: AccessToken::new("access-token".to_string()),
refresh_token: None,
expires_in: Some(3600),
token_type: None,
};
assert!(cache.record_from_response(&response).is_none());
cache.store_if_refreshable(&response).await.unwrap();
assert!(cache.load().await.unwrap().is_none());
}
#[test]
fn test_session_status_debug_has_no_secrets() {
let status = SessionStatus {
refreshable: true,
issuer_url: "https://issuer.example.com".to_string(),
client_id: "client-id".to_string(),
scopes: vec!["openid".to_string()],
resource: None,
audience: None,
flow: "device_code".to_string(),
obtained_at: Some(100),
};
let debug = format!("{status:?}");
assert!(!debug.contains("refresh-token"));
}
}
mod tests;
@@ -0,0 +1,910 @@
// SPDX-License-Identifier: Apache-2.0
// SPDX-FileCopyrightText: Copyright The LanceDB Authors
use super::*;
use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::{TcpListener, TcpStream};
use crate::remote::HeaderProvider;
use crate::remote::oauth::OAuthHeaderProvider;
use oauth2::basic::BasicTokenType;
use oauth2::{AccessToken, RefreshToken};
use serial_test::serial;
/// Temp directory that satisfies the cache hardening checks. CI runners
/// can create temp directories with group/other bits set, which the
/// private-directory validation correctly rejects.
fn cache_tempdir() -> tempfile::TempDir {
let dir = tempfile::tempdir().unwrap();
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(dir.path(), std::fs::Permissions::from_mode(0o700)).unwrap();
}
dir
}
fn device_config(cache_dir: &Path) -> OAuthConfig {
OAuthConfig {
issuer_url: "https://issuer.example.com".to_string(),
client_id: "client-id".to_string(),
client_secret: None,
client_auth_method: None,
scopes: vec!["openid".to_string()],
flow: OAuthFlow::DeviceCode,
refresh_buffer_secs: None,
resource: None,
audience: None,
token_cache: Some(TokenCacheOptions::new().cache_dir(cache_dir)),
}
}
/// Stateful mock IdP covering discovery, device authorization, device
/// polling, client credentials, and refresh with strict rotation: a
/// refresh token that is not the currently issued one is rejected with
/// `invalid_grant`, which is exactly what real providers do on rotation.
struct MockIdp {
issuer_url: String,
requests: Arc<std::sync::Mutex<Vec<String>>>,
device_authorizations: Arc<AtomicUsize>,
refresh_attempts: Arc<AtomicUsize>,
invalid_grant_rejections: Arc<AtomicUsize>,
access_tokens_issued: Arc<AtomicUsize>,
current_refresh: Arc<std::sync::Mutex<Option<String>>>,
fail_refreshes: Arc<AtomicBool>,
issue_refresh_tokens: Arc<AtomicBool>,
}
impl MockIdp {
async fn start() -> Self {
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
let issuer_url = format!("http://{addr}");
let server = Self {
issuer_url: issuer_url.clone(),
requests: Arc::new(std::sync::Mutex::new(Vec::new())),
device_authorizations: Arc::new(AtomicUsize::new(0)),
refresh_attempts: Arc::new(AtomicUsize::new(0)),
invalid_grant_rejections: Arc::new(AtomicUsize::new(0)),
access_tokens_issued: Arc::new(AtomicUsize::new(0)),
current_refresh: Arc::new(std::sync::Mutex::new(None)),
fail_refreshes: Arc::new(AtomicBool::new(false)),
issue_refresh_tokens: Arc::new(AtomicBool::new(true)),
};
let requests = Arc::clone(&server.requests);
let device_authorizations = Arc::clone(&server.device_authorizations);
let refresh_attempts = Arc::clone(&server.refresh_attempts);
let invalid_grant_rejections = Arc::clone(&server.invalid_grant_rejections);
let access_tokens_issued = Arc::clone(&server.access_tokens_issued);
let current_refresh = Arc::clone(&server.current_refresh);
let fail_refreshes = Arc::clone(&server.fail_refreshes);
let issue_refresh_tokens = Arc::clone(&server.issue_refresh_tokens);
tokio::spawn(async move {
loop {
let Ok((mut stream, _)) = listener.accept().await else {
return;
};
let requests = Arc::clone(&requests);
let device_authorizations = Arc::clone(&device_authorizations);
let refresh_attempts = Arc::clone(&refresh_attempts);
let invalid_grant_rejections = Arc::clone(&invalid_grant_rejections);
let access_tokens_issued = Arc::clone(&access_tokens_issued);
let current_refresh = Arc::clone(&current_refresh);
let fail_refreshes = Arc::clone(&fail_refreshes);
let issue_refresh_tokens = Arc::clone(&issue_refresh_tokens);
tokio::spawn(async move {
let (request_line, body) = read_http_request(&mut stream).await;
if request_line.starts_with("POST ") {
requests.lock().unwrap().push(body.clone());
}
if request_line.starts_with("GET /.well-known/openid-configuration ") {
let discovery = format!(
r#"{{"token_endpoint":"http://{addr}/token","device_authorization_endpoint":"http://{addr}/device"}}"#
);
write_json_response(&mut stream, "200 OK", &discovery).await;
} else if request_line.starts_with("POST /device ") {
device_authorizations.fetch_add(1, Ordering::SeqCst);
let device = format!(
r#"{{"device_code":"device-code","user_code":"ABCD-EFGH","verification_uri":"http://{addr}/verify","expires_in":60,"interval":1}}"#
);
write_json_response(&mut stream, "200 OK", &device).await;
} else if request_line.starts_with("POST /token ") {
if body.contains("grant_type=refresh_token") {
refresh_attempts.fetch_add(1, Ordering::SeqCst);
if fail_refreshes.load(Ordering::SeqCst) {
write_json_response(
&mut stream,
"503 Service Unavailable",
r#"{"error":"temporarily_unavailable"}"#,
)
.await;
return;
}
let expected = current_refresh.lock().unwrap().clone();
let matched = body
.split('&')
.find_map(|pair| pair.strip_prefix("refresh_token="))
.map(|token| token.to_string())
.zip(expected)
.is_some_and(|(offered, expected)| offered == expected);
if !matched {
invalid_grant_rejections.fetch_add(1, Ordering::SeqCst);
write_json_response(
&mut stream,
"400 Bad Request",
r#"{"error":"invalid_grant"}"#,
)
.await;
return;
}
issue_access_token(
&mut stream,
&access_tokens_issued,
&current_refresh,
issue_refresh_tokens.load(Ordering::SeqCst),
)
.await;
} else {
// Device polling or client credentials: issue
// a token and, for interactive grants, a fresh
// refresh token with strict rotation.
let grant_device = body.contains(
"grant_type=urn%3Aietf%3Aparams%3Aoauth%3Agrant-type%3Adevice_code",
);
if grant_device {
issue_access_token(
&mut stream,
&access_tokens_issued,
&current_refresh,
issue_refresh_tokens.load(Ordering::SeqCst),
)
.await;
} else {
let token = format!(
r#"{{"access_token":"access-{}","expires_in":3600}}"#,
access_tokens_issued.fetch_add(1, Ordering::SeqCst) + 1
);
write_json_response(&mut stream, "200 OK", &token).await;
}
}
} else {
write_json_response(&mut stream, "404 Not Found", "{}").await;
}
});
}
});
server
}
fn config(&self, cache_dir: &Path) -> OAuthConfig {
let mut config = device_config(cache_dir);
config.issuer_url = self.issuer_url.clone();
config
}
}
async fn issue_access_token(
stream: &mut TcpStream,
access_tokens_issued: &AtomicUsize,
current_refresh: &std::sync::Mutex<Option<String>>,
with_refresh: bool,
) {
let number = access_tokens_issued.fetch_add(1, Ordering::SeqCst) + 1;
let token = if with_refresh {
*current_refresh.lock().unwrap() = Some(format!("refresh-{number}"));
format!(
r#"{{"access_token":"access-{number}","refresh_token":"refresh-{number}","expires_in":3600}}"#
)
} else {
format!(r#"{{"access_token":"access-{number}","expires_in":3600}}"#)
};
write_json_response(stream, "200 OK", &token).await;
}
async fn read_http_request(stream: &mut TcpStream) -> (String, String) {
let mut buffer = Vec::new();
let mut header_end = None;
while header_end.is_none() {
let mut chunk = [0; 1024];
let read = stream.read(&mut chunk).await.unwrap();
assert_ne!(read, 0, "connection closed before request headers");
buffer.extend_from_slice(&chunk[..read]);
header_end = find_subsequence(&buffer, b"\r\n\r\n").map(|pos| pos + 4);
}
let header_end = header_end.unwrap();
let headers = String::from_utf8_lossy(&buffer[..header_end]).to_string();
let request_line = headers.lines().next().unwrap_or_default().to_string();
let content_length = headers
.lines()
.find_map(|line| {
let (name, value) = line.split_once(':')?;
name.eq_ignore_ascii_case("content-length")
.then(|| value.trim().parse::<usize>().ok())
.flatten()
})
.unwrap_or(0);
while buffer.len() < header_end + content_length {
let mut chunk = [0; 1024];
let read = stream.read(&mut chunk).await.unwrap();
assert_ne!(read, 0, "connection closed before request body");
buffer.extend_from_slice(&chunk[..read]);
}
let body =
String::from_utf8_lossy(&buffer[header_end..header_end + content_length]).to_string();
(request_line, body)
}
fn find_subsequence(haystack: &[u8], needle: &[u8]) -> Option<usize> {
haystack
.windows(needle.len())
.position(|window| window == needle)
}
async fn write_json_response(stream: &mut TcpStream, status: &str, body: &str) {
let response = format!(
"HTTP/1.1 {status}\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{body}",
body.len()
);
stream.write_all(response.as_bytes()).await.unwrap();
}
fn suppress_browser() {
// Point the OAuth browser helper at a no-op so device-flow tests never
// open a real browser window.
unsafe { std::env::set_var("LANCEDB_OAUTH_BROWSER", "/usr/bin/true") };
}
#[tokio::test]
#[serial]
async fn test_provider_reuses_cached_session_across_instances() {
suppress_browser();
let dir = cache_tempdir();
let idp = MockIdp::start().await;
let first = OAuthHeaderProvider::new(idp.config(dir.path())).unwrap();
let headers = first.get_headers().await.unwrap();
assert_eq!(headers.get("authorization").unwrap(), "Bearer access-1");
// A second, independent provider (simulating a second process) must
// refresh silently instead of starting another device flow.
let second = OAuthHeaderProvider::new(idp.config(dir.path())).unwrap();
let headers = second.get_headers().await.unwrap();
assert_eq!(headers.get("authorization").unwrap(), "Bearer access-2");
assert_eq!(idp.device_authorizations.load(Ordering::SeqCst), 1);
assert_eq!(idp.refresh_attempts.load(Ordering::SeqCst), 1);
let cache = crate::remote::token_cache::TokenCache::new(
&idp.config(dir.path()),
&TokenCacheOptions::new().cache_dir(dir.path()),
)
.unwrap();
let record = cache.load().await.unwrap().unwrap();
assert_eq!(record.refresh_token, "refresh-2");
}
#[tokio::test]
#[serial]
async fn test_concurrent_providers_serialize_rotation() {
suppress_browser();
let dir = cache_tempdir();
let idp = MockIdp::start().await;
let priming = OAuthHeaderProvider::new(idp.config(dir.path())).unwrap();
priming.get_headers().await.unwrap();
assert_eq!(idp.device_authorizations.load(Ordering::SeqCst), 1);
let provider_a = OAuthHeaderProvider::new(idp.config(dir.path())).unwrap();
let provider_b = OAuthHeaderProvider::new(idp.config(dir.path())).unwrap();
let (headers_a, headers_b) = tokio::join!(provider_a.get_headers(), provider_b.get_headers());
let token_a = headers_a.unwrap().remove("authorization").unwrap();
let token_b = headers_b.unwrap().remove("authorization").unwrap();
assert!(
{
let mut tokens = [token_a, token_b];
tokens.sort();
tokens
} == ["Bearer access-2".to_string(), "Bearer access-3".to_string()],
"each provider must observe its own refreshed token"
);
// Rotation raced would produce an invalid_grant and a second device
// flow; the lock prevents both.
assert_eq!(idp.invalid_grant_rejections.load(Ordering::SeqCst), 0);
assert_eq!(idp.device_authorizations.load(Ordering::SeqCst), 1);
assert_eq!(idp.refresh_attempts.load(Ordering::SeqCst), 2);
let cache = crate::remote::token_cache::TokenCache::new(
&idp.config(dir.path()),
&TokenCacheOptions::new().cache_dir(dir.path()),
)
.unwrap();
let record = cache.load().await.unwrap().unwrap();
assert_eq!(record.refresh_token, "refresh-3");
}
#[tokio::test]
#[serial]
async fn test_transient_refresh_failure_retains_record() {
suppress_browser();
let dir = cache_tempdir();
let idp = MockIdp::start().await;
let priming = OAuthHeaderProvider::new(idp.config(dir.path())).unwrap();
priming.get_headers().await.unwrap();
idp.fail_refreshes.store(true, Ordering::SeqCst);
let second = OAuthHeaderProvider::new(idp.config(dir.path())).unwrap();
let error = second.get_headers().await.unwrap_err();
assert!(error.to_string().contains("503"));
let session = OAuthSession::new(idp.config(dir.path())).unwrap();
let status = session.status().await.unwrap();
assert!(
status.refreshable,
"transient failures must keep the record"
);
}
#[tokio::test]
#[serial]
async fn test_invalid_grant_deletes_record_and_reauthenticates() {
suppress_browser();
let dir = cache_tempdir();
let idp = MockIdp::start().await;
let priming = OAuthHeaderProvider::new(idp.config(dir.path())).unwrap();
priming.get_headers().await.unwrap();
// Simulate a revoked refresh token by replacing the record with one
// the provider never issued.
let cache = crate::remote::token_cache::TokenCache::new(
&idp.config(dir.path()),
&TokenCacheOptions::new().cache_dir(dir.path()),
)
.unwrap();
let mut record = cache.load().await.unwrap().unwrap();
record.refresh_token = "revoked-refresh".to_string();
cache.store(&record).await.unwrap();
let second = OAuthHeaderProvider::new(idp.config(dir.path())).unwrap();
let headers = second.get_headers().await.unwrap();
assert_eq!(headers.get("authorization").unwrap(), "Bearer access-2");
assert_eq!(idp.invalid_grant_rejections.load(Ordering::SeqCst), 1);
assert_eq!(idp.device_authorizations.load(Ordering::SeqCst), 2);
let record = cache.load().await.unwrap().unwrap();
assert_eq!(record.refresh_token, "refresh-2");
}
#[tokio::test]
#[serial]
async fn test_session_login_status_logout_lifecycle() {
suppress_browser();
let dir = cache_tempdir();
let idp = MockIdp::start().await;
let session = OAuthSession::new(idp.config(dir.path())).unwrap();
let status = session.status().await.unwrap();
assert!(!status.refreshable);
let status = session.login().await.unwrap();
assert!(status.refreshable);
assert_eq!(status.issuer_url, idp.issuer_url);
assert_eq!(status.client_id, "client-id");
assert_eq!(status.scopes, vec!["openid".to_string()]);
assert_eq!(status.flow, "device_code");
assert!(status.obtained_at.is_some());
// An independent session manager sees the same cached login.
let other = OAuthSession::new(idp.config(dir.path())).unwrap();
assert!(other.status().await.unwrap().refreshable);
assert!(session.logout().await.unwrap().removed);
assert!(!session.logout().await.unwrap().removed);
assert!(!other.status().await.unwrap().refreshable);
assert_eq!(idp.device_authorizations.load(Ordering::SeqCst), 1);
}
#[tokio::test]
#[serial]
async fn test_login_without_refresh_token_clears_prior_record() {
suppress_browser();
let dir = cache_tempdir();
let idp = MockIdp::start().await;
let session = OAuthSession::new(idp.config(dir.path())).unwrap();
session.login().await.unwrap();
assert!(session.status().await.unwrap().refreshable);
// A provider that stops issuing refresh tokens (for example a login
// without offline_access) must not leave the earlier account behind.
idp.issue_refresh_tokens.store(false, Ordering::SeqCst);
let status = session.login().await.unwrap();
assert!(!status.refreshable);
assert!(!session.status().await.unwrap().refreshable);
}
#[tokio::test]
async fn test_client_credentials_with_cache_stays_memory_only() {
let dir = cache_tempdir();
let idp = MockIdp::start().await;
let mut config = idp.config(dir.path());
config.flow = OAuthFlow::ClientCredentials;
config.client_secret = Some("secret".to_string());
let provider = OAuthHeaderProvider::new(config).unwrap();
let headers = provider.get_headers().await.unwrap();
assert_eq!(headers.get("authorization").unwrap(), "Bearer access-1");
assert_eq!(dir.path().read_dir().unwrap().count(), 0);
}
#[test]
fn test_managed_identity_with_cache_is_rejected() {
let dir = cache_tempdir();
let mut config = device_config(dir.path());
config.flow = OAuthFlow::AzureManagedIdentity { client_id: None };
let err = OAuthHeaderProvider::new(config).unwrap_err();
assert!(
matches!(err, Error::InvalidInput { message } if message.contains("AzureManagedIdentity"))
);
}
#[tokio::test]
#[serial]
async fn test_provider_debug_and_status_reveal_no_secrets() {
suppress_browser();
let dir = cache_tempdir();
let idp = MockIdp::start().await;
let provider = OAuthHeaderProvider::new(idp.config(dir.path())).unwrap();
let headers = provider.get_headers().await.unwrap();
assert_eq!(headers.get("authorization").unwrap(), "Bearer access-1");
let debug = format!("{provider:?}");
assert!(!debug.contains("access-1"));
assert!(!debug.contains("refresh-1"));
let session = OAuthSession::new(idp.config(dir.path())).unwrap();
let status = session.status().await.unwrap();
assert!(!format!("{status:?}").contains("refresh-"));
}
#[tokio::test]
async fn test_targeted_cache_refresh_and_logout_isolation() {
let dir = cache_tempdir();
let idp = MockIdp::start().await;
let mut config = idp.config(dir.path());
let options = config.token_cache.clone().unwrap();
let untargeted_key = CacheKey::new(&config).unwrap().file_stem;
assert_eq!(
untargeted_key,
hex_sha256(
format!(
"v1\n{}\nclient-id\nopenid\ndevice_code\npublic",
idp.issuer_url
)
.as_bytes()
)
);
let mut sessions = Vec::new();
let mut keys = std::collections::HashSet::new();
for (resource, audience) in [
(None, None),
(Some("urn:one"), None),
(None, Some("audience & ü")),
(Some("urn:one"), Some("audience & ü")),
(Some("urn:two"), Some("audience & ü")),
(Some("urn:one"), Some("other")),
] {
config.resource = resource.map(str::to_owned);
config.audience = audience.map(str::to_owned);
let cache = TokenCache::new(&config, &options).unwrap();
assert!(keys.insert(cache.key.file_stem.clone()));
let record = cache
.record_from_response(&TokenResponse {
access_token: AccessToken::new("unused".into()),
refresh_token: Some(RefreshToken::new("seed-refresh".into())),
expires_in: Some(3600),
token_type: None,
})
.unwrap();
cache.store(&record).await.unwrap();
*idp.current_refresh.lock().unwrap() = Some("seed-refresh".into());
let provider = OAuthHeaderProvider::new(config.clone()).unwrap();
provider.get_headers().await.unwrap();
let request = idp.requests.lock().unwrap().last().unwrap().clone();
let params: std::collections::HashMap<_, _> =
url::form_urlencoded::parse(request.as_bytes()).collect();
assert_eq!(params.get("resource").map(|s| s.as_ref()), resource);
assert_eq!(params.get("audience").map(|s| s.as_ref()), audience);
assert_eq!(params.get("grant_type").unwrap(), "refresh_token");
let session = OAuthSession::new(config.clone()).unwrap();
let status = session.status().await.unwrap();
assert!(status.refreshable);
assert_eq!(status.resource, config.resource);
assert_eq!(status.audience, config.audience);
sessions.push(session);
}
assert!(sessions.pop().unwrap().logout().await.unwrap().removed);
for session in sessions {
assert!(session.status().await.unwrap().refreshable);
}
assert_eq!(idp.device_authorizations.load(Ordering::SeqCst), 0);
}
#[tokio::test]
async fn test_legacy_cache_record_without_target_fields() {
let dir = cache_tempdir();
let config = device_config(dir.path());
let cache = TokenCache::new(&config, config.token_cache.as_ref().unwrap()).unwrap();
let legacy = br#"{"version":1,"issuer_url":"https://issuer.example.com","client_id":"client-id","scopes":["openid"],"flow":"device_code","client_auth":"public","refresh_token":"legacy","obtained_at":1}"#;
write_record(dir.path(), &cache.record_path(), legacy).unwrap();
let session = OAuthSession::new(config).unwrap();
let status = session.status().await.unwrap();
assert!(status.refreshable);
assert_eq!(status.resource, None);
assert_eq!(status.audience, None);
assert!(session.logout().await.unwrap().removed);
}
#[test]
fn test_cache_key_canonicalizes_scopes_and_issuer() {
let mut config = device_config(Path::new("/tmp/cache"));
config.issuer_url = "https://issuer.example.com/".to_string();
config.scopes = vec!["b".to_string(), " a ".to_string(), "a".to_string()];
let key = CacheKey::new(&config).unwrap();
assert_eq!(key.issuer_url, "https://issuer.example.com");
assert_eq!(key.scopes, vec!["a".to_string(), "b".to_string()]);
config.issuer_url = "https://issuer.example.com".to_string();
let canonical = CacheKey::new(&config).unwrap();
assert_eq!(canonical.file_stem, key.file_stem);
}
#[test]
fn test_cache_key_separates_identity_dimensions() {
let base = device_config(Path::new("/tmp/cache"));
let base_key = CacheKey::new(&base).unwrap();
let mut other = base.clone();
other.client_id = "other-client".to_string();
assert_ne!(CacheKey::new(&other).unwrap().file_stem, base_key.file_stem);
let mut other = base.clone();
other.issuer_url = "https://other.example.com".to_string();
assert_ne!(CacheKey::new(&other).unwrap().file_stem, base_key.file_stem);
let mut other = base.clone();
other.scopes = vec!["profile".to_string()];
assert_ne!(CacheKey::new(&other).unwrap().file_stem, base_key.file_stem);
let mut other = base.clone();
other.flow = OAuthFlow::AuthorizationCode(Default::default());
assert_ne!(CacheKey::new(&other).unwrap().file_stem, base_key.file_stem);
let mut other = base.clone();
other.client_secret = Some("secret".to_string());
assert_ne!(CacheKey::new(&other).unwrap().file_stem, base_key.file_stem);
}
#[test]
fn test_cache_key_contains_no_secret_material() {
let mut config = device_config(Path::new("/tmp/cache"));
config.client_secret = Some("super-secret-value".to_string());
let key = CacheKey::new(&config).unwrap();
assert!(!key.file_stem.contains("super-secret-value"));
assert_eq!(key.file_stem.len(), 64);
}
#[test]
fn test_flow_key_rejects_non_persistent_flows() {
let mut config = device_config(Path::new("/tmp/cache"));
config.flow = OAuthFlow::ClientCredentials;
let err = CacheKey::new(&config).unwrap_err();
assert!(matches!(err, Error::InvalidInput { message } if message.contains("not supported")));
let mut config = device_config(Path::new("/tmp/cache"));
config.flow = OAuthFlow::AzureManagedIdentity { client_id: None };
assert!(CacheKey::new(&config).is_err());
}
#[test]
fn test_token_cache_options_defaults() {
let options = TokenCacheOptions::new();
assert!(options.cache_dir.is_none());
assert!(options.lock_timeout_secs.is_none());
assert_eq!(options.lock_timeout(), Duration::from_secs(30));
let options = options.cache_dir("/tmp/x").lock_timeout_secs(5);
assert_eq!(options.cache_dir.as_deref(), Some(Path::new("/tmp/x")));
assert_eq!(options.lock_timeout(), Duration::from_secs(5));
}
#[test]
fn test_token_cache_options_rejects_empty_dir() {
let options = TokenCacheOptions::new().cache_dir("");
assert!(matches!(
options.resolved_dir(),
Err(Error::InvalidInput { message }) if message.contains("must not be empty")
));
}
#[tokio::test]
async fn test_session_lifecycle_without_cache_entry() {
let dir = cache_tempdir();
let session = OAuthSession::new(device_config(dir.path())).unwrap();
let status = session.status().await.unwrap();
assert!(!status.refreshable);
assert_eq!(status.issuer_url, "https://issuer.example.com");
assert_eq!(status.client_id, "client-id");
assert_eq!(status.scopes, vec!["openid".to_string()]);
assert_eq!(status.flow, "device_code");
assert_eq!(status.obtained_at, None);
let logout = session.logout().await.unwrap();
assert!(!logout.removed);
}
#[tokio::test]
async fn test_record_round_trip_and_redaction() {
let dir = cache_tempdir();
let cache = TokenCache::new(
&device_config(dir.path()),
&TokenCacheOptions::new().cache_dir(dir.path()),
)
.unwrap();
let response = TokenResponse {
access_token: AccessToken::new("access-token".to_string()),
refresh_token: Some(RefreshToken::new("refresh-token".to_string())),
expires_in: Some(3600),
token_type: Some(BasicTokenType::Bearer),
};
let record = cache.record_from_response(&response).unwrap();
let debug = format!("{record:?}");
assert!(!debug.contains("refresh-token"));
assert!(debug.contains("<redacted>"));
// No access-token material is persisted.
let json = serde_json::to_string(&record).unwrap();
assert!(!json.contains("access-token"));
cache.store(&record).await.unwrap();
let loaded = cache.load().await.unwrap().unwrap();
assert_eq!(loaded.refresh_token, "refresh-token");
assert_eq!(loaded.version, CACHE_RECORD_VERSION);
// The on-disk file must not be group/other readable and must not be a symlink target.
let path = cache.record_path();
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let mode = std::fs::metadata(&path).unwrap().permissions().mode();
assert_eq!(mode & 0o077, 0);
}
assert!(std::fs::symlink_metadata(&path).unwrap().is_file());
assert!(cache.delete().await.unwrap());
assert!(cache.load().await.unwrap().is_none());
assert!(!cache.delete().await.unwrap());
}
#[cfg(unix)]
#[tokio::test]
async fn test_record_rejects_symlink() {
let dir = cache_tempdir();
let cache = TokenCache::new(
&device_config(dir.path()),
&TokenCacheOptions::new().cache_dir(dir.path()),
)
.unwrap();
let record = cache
.record_from_response(&TokenResponse {
access_token: AccessToken::new("a".to_string()),
refresh_token: Some(RefreshToken::new("r".to_string())),
expires_in: None,
token_type: None,
})
.unwrap();
cache.store(&record).await.unwrap();
let path = cache.record_path();
let target = dir.path().join("evil.json");
std::fs::write(&target, "{}").unwrap();
std::fs::remove_file(&path).unwrap();
std::os::unix::fs::symlink(&target, &path).unwrap();
let err = cache.load().await.unwrap_err();
assert!(
matches!(err, Error::InvalidInput { message } if message.contains("not a regular file"))
);
}
#[cfg(unix)]
#[tokio::test]
async fn test_record_rejects_world_readable_file() {
let dir = cache_tempdir();
let cache = TokenCache::new(
&device_config(dir.path()),
&TokenCacheOptions::new().cache_dir(dir.path()),
)
.unwrap();
let path = cache.record_path();
std::fs::write(&path, "{}").unwrap();
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o644)).unwrap();
let err = cache.load().await.unwrap_err();
assert!(matches!(err, Error::InvalidInput { message } if message.contains("group or other")));
}
/// Write a record file that passes the permission hardening checks.
fn write_record_file(path: &Path, contents: &str) {
std::fs::write(path, contents).unwrap();
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600)).unwrap();
}
}
#[tokio::test]
async fn test_record_rejects_unknown_version_and_corruption() {
let dir = cache_tempdir();
let cache = TokenCache::new(
&device_config(dir.path()),
&TokenCacheOptions::new().cache_dir(dir.path()),
)
.unwrap();
let path = cache.record_path();
// A complete, well-formed record with an unknown schema version.
let record = cache
.record_from_response(&TokenResponse {
access_token: AccessToken::new("a".to_string()),
refresh_token: Some(RefreshToken::new("r".to_string())),
expires_in: None,
token_type: None,
})
.unwrap();
let mut json = serde_json::to_value(&record).unwrap();
json["version"] = serde_json::json!(99);
write_record_file(&path, &json.to_string());
let err = cache.load().await.unwrap_err();
assert!(matches!(err, Error::Runtime { message } if message.contains("unsupported version")));
write_record_file(&path, r#"{"version":1,"issuer_url":"x""#);
let err = cache.load().await.unwrap_err();
assert!(matches!(err, Error::Runtime { message } if message.contains("corrupt")));
write_record_file(&path, "");
assert!(
cache
.load()
.await
.unwrap_err()
.to_string()
.contains("corrupt")
);
}
#[cfg(unix)]
#[tokio::test]
async fn test_cache_dir_rejects_open_permissions() {
let dir = cache_tempdir();
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(dir.path(), std::fs::Permissions::from_mode(0o755)).unwrap();
let err = TokenCache::new(
&device_config(dir.path()),
&TokenCacheOptions::new().cache_dir(dir.path()),
)
.unwrap_err();
assert!(matches!(err, Error::InvalidInput { message } if message.contains("chmod 700")));
}
#[tokio::test]
async fn test_lock_serializes_and_releases() {
let dir = cache_tempdir();
let cache = TokenCache::new(
&device_config(dir.path()),
&TokenCacheOptions::new().cache_dir(dir.path()),
)
.unwrap();
let guard = cache.acquire_lock().await.unwrap();
let contender = {
let dir = dir.path().to_path_buf();
let cache2 = TokenCache::new(
&device_config(&dir),
&TokenCacheOptions::new()
.cache_dir(&dir)
.lock_timeout_secs(1),
)
.unwrap();
tokio::time::timeout(Duration::from_millis(300), cache2.acquire_lock()).await
};
assert!(contender.is_err(), "second acquire must block while held");
drop(guard);
let cache3 = TokenCache::new(
&device_config(dir.path()),
&TokenCacheOptions::new().cache_dir(dir.path()),
)
.unwrap();
tokio::time::timeout(Duration::from_secs(5), cache3.acquire_lock())
.await
.expect("lock re-acquirable after release")
.unwrap();
}
#[test]
fn test_token_cache_for_config_gating() {
let dir = cache_tempdir();
assert!(
token_cache_for_config(&device_config(dir.path()))
.unwrap()
.is_some()
);
let mut config = device_config(dir.path());
config.token_cache = None;
assert!(token_cache_for_config(&config).unwrap().is_none());
let mut config = device_config(dir.path());
config.flow = OAuthFlow::ClientCredentials;
assert!(token_cache_for_config(&config).unwrap().is_none());
let mut config = device_config(dir.path());
config.flow = OAuthFlow::AzureManagedIdentity { client_id: None };
let err = token_cache_for_config(&config).unwrap_err();
assert!(
matches!(err, Error::InvalidInput { message } if message.contains("AzureManagedIdentity"))
);
}
#[test]
fn test_oauth_session_requires_cache_options() {
let mut config = device_config(Path::new("/tmp/cache"));
config.token_cache = None;
let err = OAuthSession::new(config).unwrap_err();
assert!(matches!(err, Error::InvalidInput { message } if message.contains("token_cache")));
}
#[tokio::test]
async fn test_store_skips_responses_without_refresh_token() {
let dir = cache_tempdir();
let cache = TokenCache::new(
&device_config(dir.path()),
&TokenCacheOptions::new().cache_dir(dir.path()),
)
.unwrap();
let response = TokenResponse {
access_token: AccessToken::new("access-token".to_string()),
refresh_token: None,
expires_in: Some(3600),
token_type: None,
};
assert!(cache.record_from_response(&response).is_none());
cache.store_if_refreshable(&response).await.unwrap();
assert!(cache.load().await.unwrap().is_none());
}
#[test]
fn test_session_status_debug_has_no_secrets() {
let status = SessionStatus {
refreshable: true,
issuer_url: "https://issuer.example.com".to_string(),
client_id: "client-id".to_string(),
scopes: vec!["openid".to_string()],
resource: None,
audience: None,
flow: "device_code".to_string(),
obtained_at: Some(100),
};
let debug = format!("{status:?}");
assert!(!debug.contains("refresh-token"));
}