laminar_connectors/lakehouse/delta_io/
catalog.rs1#[cfg(feature = "delta-lake-glue")]
4use super::info;
5use super::{ConnectorError, HashMap};
6#[cfg(feature = "delta-lake-glue")]
7use laminar_core::storage_location::StorageProvider;
8
9#[cfg(feature = "delta-lake-glue")]
10fn glue_init_error_category(error: &deltalake_catalog_glue::GlueError) -> &'static str {
11 match error {
12 deltalake_catalog_glue::GlueError::MissingMetadata { .. } => "missing-metadata",
13 deltalake_catalog_glue::GlueError::AWSError { source } => aws_glue_error_category(source),
14 }
15}
16
17#[cfg(feature = "delta-lake-glue")]
18fn aws_glue_error_category(error: &aws_sdk_glue::Error) -> &'static str {
19 use aws_sdk_glue::Error;
20
21 match error {
22 Error::AccessDeniedException(_)
23 | Error::KmsKeyNotAccessibleFault(_)
24 | Error::PermissionTypeMismatchException(_) => "authorization",
25 Error::EntityNotFoundException(_)
26 | Error::IntegrationNotFoundFault(_)
27 | Error::ResourceNotFoundException(_)
28 | Error::TargetResourceNotFound(_) => "not-found",
29 Error::ConcurrentRunsExceededException(_)
30 | Error::IntegrationQuotaExceededFault(_)
31 | Error::ResourceNumberLimitExceededException(_)
32 | Error::ThrottlingException(_) => "throttled",
33 Error::OperationTimeoutException(_) => "service-timeout",
34 Error::FederationSourceRetryableException(_)
35 | Error::InternalServerException(_)
36 | Error::InternalServiceException(_)
37 | Error::ResourceNotReadyException(_) => "service-unavailable",
38 Error::InvalidInputException(_) | Error::ValidationException(_) => "invalid-request",
39 Error::AlreadyExistsException(_)
40 | Error::ConcurrentModificationException(_)
41 | Error::ConflictException(_)
42 | Error::VersionMismatchException(_) => "catalog-conflict",
43 _ => aws_glue_pre_service_error_category(error).unwrap_or("unclassified-service-error"),
44 }
45}
46
47#[cfg(feature = "delta-lake-glue")]
48fn aws_glue_pre_service_error_category(error: &aws_sdk_glue::Error) -> Option<&'static str> {
49 type GetTableSdkError =
50 aws_sdk_glue::error::SdkError<aws_sdk_glue::operation::get_table::GetTableError>;
51
52 let mut source = std::error::Error::source(error);
53 while let Some(current) = source {
54 if current
55 .downcast_ref::<aws_sdk_glue::error::BuildError>()
56 .is_some()
57 {
58 return Some("client-input");
59 }
60 if let Some(sdk_error) = current.downcast_ref::<GetTableSdkError>() {
61 return match sdk_error {
62 GetTableSdkError::ConstructionFailure(_) => Some("client-input"),
63 GetTableSdkError::TimeoutError(_) => Some("client-timeout"),
64 GetTableSdkError::DispatchFailure(_) => Some("transport"),
65 GetTableSdkError::ResponseError(_) => Some("invalid-response"),
66 GetTableSdkError::ServiceError(_) => None,
67 _ => Some("unclassified-sdk-error"),
68 };
69 }
70 source = current.source();
71 }
72
73 None
74}
75
76#[cfg(feature = "delta-lake-glue")]
77fn glue_lookup_error_category(error: &deltalake::data_catalog::DataCatalogError) -> &'static str {
78 match error {
79 deltalake::data_catalog::DataCatalogError::Generic { source, .. } => source
80 .downcast_ref::<deltalake_catalog_glue::GlueError>()
81 .map_or("catalog-provider", glue_init_error_category),
82 deltalake::data_catalog::DataCatalogError::InvalidDataCatalog { .. } => "invalid-catalog",
83 deltalake::data_catalog::DataCatalogError::UnknownConfigKey { .. } => {
84 "invalid-catalog-configuration"
85 }
86 deltalake::data_catalog::DataCatalogError::RequestError { .. } => "catalog-request",
87 }
88}
89
90#[derive(Debug, Clone, Copy, PartialEq, Eq)]
92pub enum CatalogAccess {
93 Read,
95 Write,
97}
98
99#[derive(Clone)]
101pub struct ResolvedCatalogTable {
102 pub location: String,
104 pub storage_options: HashMap<String, String>,
106 pub credentials_expire_at: Option<std::time::SystemTime>,
108}
109
110impl std::fmt::Debug for ResolvedCatalogTable {
111 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
112 f.debug_struct("ResolvedCatalogTable")
113 .field("location", &self.location)
114 .field(
115 "storage_options",
116 &crate::storage::SecretMasker::display_map(&self.storage_options),
117 )
118 .field("credentials_expire_at", &self.credentials_expire_at)
119 .finish()
120 }
121}
122
123const CREDENTIAL_REFRESH_MARGIN: std::time::Duration = std::time::Duration::from_secs(10 * 60);
125
126#[must_use]
128pub fn credentials_refresh_at(
129 expire_at: Option<std::time::SystemTime>,
130) -> Option<std::time::SystemTime> {
131 expire_at.map(|at| {
132 at.checked_sub(CREDENTIAL_REFRESH_MARGIN)
133 .unwrap_or(std::time::UNIX_EPOCH)
134 })
135}
136
137#[must_use]
139pub fn credentials_need_refresh(expire_at: Option<std::time::SystemTime>) -> bool {
140 credentials_refresh_at(expire_at).is_some_and(|at| std::time::SystemTime::now() >= at)
141}
142
143#[cfg(feature = "delta-lake")]
154#[allow(clippy::implicit_hasher, clippy::unused_async)]
155pub async fn resolve_catalog_options(
156 catalog: &super::super::delta_config::DeltaCatalogType,
157 #[allow(unused_variables)] catalog_database: Option<&str>,
158 #[allow(unused_variables)] catalog_name: Option<&str>,
159 _catalog_schema: Option<&str>,
160 table_path: &str,
161 base_storage_options: &HashMap<String, String>,
162 #[allow(unused_variables)] access: CatalogAccess,
163) -> Result<ResolvedCatalogTable, ConnectorError> {
164 use super::super::delta_config::DeltaCatalogType;
165
166 match catalog {
167 DeltaCatalogType::None => Ok(ResolvedCatalogTable {
168 location: table_path.to_string(),
169 storage_options: base_storage_options.clone(),
170 credentials_expire_at: None,
171 }),
172 #[cfg(feature = "delta-lake-glue")]
173 DeltaCatalogType::Glue => {
174 use deltalake::DataCatalog;
175 let database = catalog_database.ok_or_else(|| {
176 ConnectorError::ConfigurationError(
177 "Glue catalog requires 'catalog.database'".into(),
178 )
179 })?;
180 let glue = deltalake_catalog_glue::GlueDataCatalog::from_env()
181 .await
182 .map_err(|error| {
183 ConnectorError::ConnectionFailed(
184 format!(
185 "failed to initialize Glue catalog using the downstream AWS credential chain ({}); verify AWS region and credential-chain setup",
186 glue_init_error_category(&error)
187 ),
188 )
189 })?;
190 let resolved = glue
191 .get_table_storage_location(catalog_name.map(String::from), database, table_path)
192 .await
193 .map_err(|error| {
194 ConnectorError::ConnectionFailed(
195 format!(
196 "Glue catalog lookup failed ({}); verify catalog identifiers, AWS region, credentials, and glue:GetTable permission",
197 glue_lookup_error_category(&error)
198 ),
199 )
200 })?;
201 info!(
202 glue_database = database,
203 storage_provider =
204 StorageProvider::detect_uri(&resolved).map_or("unknown", StorageProvider::name),
205 "resolved table path via Glue catalog"
206 );
207 Ok(ResolvedCatalogTable {
208 location: resolved,
209 storage_options: base_storage_options.clone(),
210 credentials_expire_at: None,
211 })
212 }
213 #[cfg(not(feature = "delta-lake-glue"))]
214 DeltaCatalogType::Glue => Err(ConnectorError::ConfigurationError(
215 "Glue catalog requires the 'delta-lake-glue' feature. \
216 Build with: cargo build --features delta-lake-glue"
217 .into(),
218 )),
219 #[cfg(feature = "delta-lake-unity")]
220 DeltaCatalogType::Unity(unity) => {
221 let resolved =
222 super::super::unity_catalog::resolve_table(unity, table_path, access).await?;
223 let mut storage_options = base_storage_options.clone();
224 storage_options.extend(resolved.vended_options);
225 Ok(ResolvedCatalogTable {
226 location: resolved.storage_location,
227 storage_options,
228 credentials_expire_at: resolved.expires_at,
229 })
230 }
231 #[cfg(not(feature = "delta-lake-unity"))]
232 DeltaCatalogType::Unity(_) => Err(ConnectorError::ConfigurationError(
233 "Unity catalog requires the 'delta-lake-unity' feature. \
234 Build with: cargo build --features delta-lake-unity"
235 .into(),
236 )),
237 }
238}
239
240#[cfg(all(test, feature = "delta-lake-glue"))]
241mod tests {
242 use super::*;
243
244 #[test]
245 fn glue_categories_are_actionable_without_provider_details() {
246 let error: deltalake::data_catalog::DataCatalogError =
247 deltalake_catalog_glue::GlueError::MissingMetadata {
248 metadata: "secret-table-location".into(),
249 }
250 .into();
251 assert_eq!(glue_lookup_error_category(&error), "missing-metadata");
252 assert!(!glue_lookup_error_category(&error).contains("secret"));
253
254 let error = deltalake_catalog_glue::GlueError::AWSError {
255 source: aws_sdk_glue::Error::AccessDeniedException(
256 aws_sdk_glue::types::error::AccessDeniedException::builder()
257 .message("private provider detail")
258 .build(),
259 ),
260 };
261 assert_eq!(glue_init_error_category(&error), "authorization");
262 assert!(!glue_init_error_category(&error).contains("private"));
263
264 let unhandled = aws_sdk_glue::types::TableInput::builder()
265 .build()
266 .expect_err("missing table name must produce a client-side build error");
267 let error = deltalake_catalog_glue::GlueError::AWSError {
268 source: unhandled.into(),
269 };
270 assert_eq!(glue_init_error_category(&error), "client-input");
271
272 let timeout: aws_sdk_glue::error::SdkError<
273 aws_sdk_glue::operation::get_table::GetTableError,
274 > = aws_sdk_glue::error::SdkError::timeout_error(std::io::Error::other(
275 "private transport detail",
276 ));
277 let error = deltalake_catalog_glue::GlueError::AWSError {
278 source: timeout.into(),
279 };
280 assert_eq!(glue_init_error_category(&error), "client-timeout");
281
282 let no_code_service_error = aws_sdk_glue::operation::get_table::GetTableError::unhandled(
283 std::io::Error::other("private provider response"),
284 );
285 let error = deltalake_catalog_glue::GlueError::AWSError {
286 source: no_code_service_error.into(),
287 };
288 assert_eq!(
289 glue_init_error_category(&error),
290 "unclassified-service-error"
291 );
292 }
293}
294
295