1use itertools::Itertools;
2
3use unitycatalog_common::models::ObjectLabel;
4use unitycatalog_common::models::model_versions::v1::*;
5use unitycatalog_common::models::registered_models::v1::RegisteredModel;
6use unitycatalog_common::models::{ResourceIdent, ResourceName};
7
8use super::{RegisteredModelHandler, RequestContext, SecuredAction};
9pub use crate::codegen::model_versions::ModelVersionHandler;
10use crate::policy::{Permission, Policy, process_resources};
11use crate::store::ResourceStore;
12use crate::{Error, Result};
13use unitycatalog_common::models::registered_models::v1::GetRegisteredModelRequest;
14
15fn split_model_full_name(full_name: &str) -> Result<(String, String, String)> {
17 let name = ResourceName::from_naive_str_split(full_name);
18 let [catalog, schema, model] = name.as_ref() else {
19 return Err(Error::invalid_argument(
20 "Invalid model name - expected <catalog_name>.<schema_name>.<model_name>",
21 ));
22 };
23 Ok((catalog.clone(), schema.clone(), model.clone()))
24}
25
26fn model_version_ident(catalog: &str, schema: &str, model: &str, version: i64) -> ResourceIdent {
29 ResourceIdent::model_version(ResourceName::new([
30 catalog.to_string(),
31 schema.to_string(),
32 model.to_string(),
33 version.to_string(),
34 ]))
35}
36
37#[async_trait::async_trait]
38impl<T: ResourceStore + Policy<RequestContext> + RegisteredModelHandler<RequestContext>>
39 ModelVersionHandler<RequestContext> for T
40{
41 #[tracing::instrument(skip(self, context), fields(resource_name))]
42 async fn list_model_versions(
43 &self,
44 request: ListModelVersionsRequest,
45 context: RequestContext,
46 ) -> Result<ListModelVersionsResponse> {
47 tracing::Span::current().record("resource_name", &request.full_name);
48 self.check_required(&request, &context).await?;
49 let (catalog, schema, model) = split_model_full_name(&request.full_name)?;
50 let (mut resources, next_page_token) = self
51 .list(
52 &ObjectLabel::ModelVersion,
53 Some(&ResourceName::new([&catalog, &schema, &model])),
54 request.max_results.map(|v| v as usize),
55 request.page_token,
56 )
57 .await?;
58 process_resources(self, &context, &Permission::Read, &mut resources).await?;
59 Ok(ListModelVersionsResponse {
60 model_versions: resources.into_iter().map(|r| r.try_into()).try_collect()?,
61 next_page_token,
62 ..Default::default()
63 })
64 }
65
66 #[tracing::instrument(skip(self, context), fields(resource_name))]
67 async fn create_model_version(
68 &self,
69 request: CreateModelVersionRequest,
70 context: RequestContext,
71 ) -> Result<ModelVersion> {
72 self.check_required(&request, &context).await?;
73 let full_name = format!(
74 "{}.{}.{}",
75 request.catalog_name, request.schema_name, request.model_name
76 );
77 tracing::Span::current().record("resource_name", &full_name);
78
79 let parent = self
82 .get_registered_model(
83 GetRegisteredModelRequest {
84 full_name: full_name.clone(),
85 ..Default::default()
86 },
87 context.clone(),
88 )
89 .await?;
90
91 let next_version = self.next_model_version(&parent).await?;
94
95 let storage_location = parent
96 .storage_location
97 .as_ref()
98 .map(|root| format!("{root}/versions/{next_version}"));
99
100 let id = uuid::Uuid::now_v7().hyphenated().to_string();
101 let resource = ModelVersion {
102 model_name: request.model_name,
103 catalog_name: request.catalog_name,
104 schema_name: request.schema_name,
105 version: next_version,
106 source: request.source,
107 run_id: request.run_id,
108 status: ModelVersionStatus::PendingRegistration.into(),
109 storage_location,
110 comment: request.comment,
111 id: Some(id),
112 ..Default::default()
113 };
114 Ok(self.create(resource.into()).await?.0.try_into()?)
115 }
116
117 #[tracing::instrument(skip(self, context), fields(resource_name))]
118 async fn get_model_version(
119 &self,
120 request: GetModelVersionRequest,
121 context: RequestContext,
122 ) -> Result<ModelVersion> {
123 tracing::Span::current().record("resource_name", &request.full_name);
124 self.check_required(&request, &context).await?;
125 Ok(self.get(&request.resource()).await?.0.try_into()?)
126 }
127
128 #[tracing::instrument(skip(self, context), fields(resource_name))]
129 async fn update_model_version(
130 &self,
131 request: UpdateModelVersionRequest,
132 context: RequestContext,
133 ) -> Result<ModelVersion> {
134 tracing::Span::current().record("resource_name", &request.full_name);
135 self.check_required(&request, &context).await?;
136 let ident = request.resource();
137 let (current, _) = self.get(&ident).await?;
139 let mut model_version: ModelVersion = current.try_into()?;
140 model_version.comment = request.comment;
141 Ok(self
142 .update(&ident, model_version.into())
143 .await?
144 .0
145 .try_into()?)
146 }
147
148 #[tracing::instrument(skip(self, context), fields(resource_name))]
149 async fn delete_model_version(
150 &self,
151 request: DeleteModelVersionRequest,
152 context: RequestContext,
153 ) -> Result<()> {
154 tracing::Span::current().record("resource_name", &request.full_name);
155 self.check_required(&request, &context).await?;
156 Ok(self.delete(&request.resource()).await?)
157 }
158
159 #[tracing::instrument(skip(self, context), fields(resource_name))]
160 async fn finalize_model_version(
161 &self,
162 request: FinalizeModelVersionRequest,
163 context: RequestContext,
164 ) -> Result<ModelVersion> {
165 tracing::Span::current().record("resource_name", &request.full_name);
166 self.check_required(&request, &context).await?;
167 let ident = request.resource();
168 let (current, _) = self.get(&ident).await?;
169 let mut model_version: ModelVersion = current.try_into()?;
170 model_version.status = ModelVersionStatus::Ready.into();
173 Ok(self
174 .update(&ident, model_version.into())
175 .await?
176 .0
177 .try_into()?)
178 }
179}
180
181#[async_trait::async_trait]
183trait ModelVersionAllocExt: ResourceStore {
184 async fn next_model_version(&self, parent: &RegisteredModel) -> Result<i64> {
187 let namespace =
188 ResourceName::new([&parent.catalog_name, &parent.schema_name, &parent.name]);
189 let mut max_version = 0i64;
192 let mut page_token = None;
193 loop {
194 let (resources, next) = self
195 .list(
196 &ObjectLabel::ModelVersion,
197 Some(&namespace),
198 None,
199 page_token,
200 )
201 .await?;
202 for resource in resources {
203 let mv: ModelVersion = resource.try_into()?;
204 max_version = max_version.max(mv.version);
205 }
206 match next {
207 Some(token) => page_token = Some(token),
208 None => break,
209 }
210 }
211 Ok(max_version + 1)
212 }
213}
214
215impl<T: ResourceStore> ModelVersionAllocExt for T {}
216
217impl SecuredAction for CreateModelVersionRequest {
218 fn resource(&self) -> ResourceIdent {
219 ResourceIdent::registered_model(ResourceName::new([
220 self.catalog_name.as_str(),
221 self.schema_name.as_str(),
222 self.model_name.as_str(),
223 ]))
224 }
225
226 fn permission(&self) -> &'static Permission {
227 &Permission::Create
228 }
229}
230
231impl SecuredAction for ListModelVersionsRequest {
232 fn resource(&self) -> ResourceIdent {
233 ResourceIdent::registered_model(ResourceName::from_naive_str_split(self.full_name.as_str()))
234 }
235
236 fn permission(&self) -> &'static Permission {
237 &Permission::Read
238 }
239}
240
241impl SecuredAction for GetModelVersionRequest {
242 fn resource(&self) -> ResourceIdent {
243 match split_model_full_name(&self.full_name) {
244 Ok((catalog, schema, model)) => {
245 model_version_ident(&catalog, &schema, &model, self.version)
246 }
247 Err(_) => ResourceIdent::model_version(ResourceName::from_naive_str_split(
250 self.full_name.as_str(),
251 )),
252 }
253 }
254
255 fn permission(&self) -> &'static Permission {
256 &Permission::Read
257 }
258}
259
260impl SecuredAction for UpdateModelVersionRequest {
261 fn resource(&self) -> ResourceIdent {
262 match split_model_full_name(&self.full_name) {
263 Ok((catalog, schema, model)) => {
264 model_version_ident(&catalog, &schema, &model, self.version)
265 }
266 Err(_) => ResourceIdent::model_version(ResourceName::from_naive_str_split(
267 self.full_name.as_str(),
268 )),
269 }
270 }
271
272 fn permission(&self) -> &'static Permission {
273 &Permission::Manage
274 }
275}
276
277impl SecuredAction for DeleteModelVersionRequest {
278 fn resource(&self) -> ResourceIdent {
279 match split_model_full_name(&self.full_name) {
280 Ok((catalog, schema, model)) => {
281 model_version_ident(&catalog, &schema, &model, self.version)
282 }
283 Err(_) => ResourceIdent::model_version(ResourceName::from_naive_str_split(
284 self.full_name.as_str(),
285 )),
286 }
287 }
288
289 fn permission(&self) -> &'static Permission {
290 &Permission::Manage
291 }
292}
293
294impl SecuredAction for FinalizeModelVersionRequest {
295 fn resource(&self) -> ResourceIdent {
296 match split_model_full_name(&self.full_name) {
297 Ok((catalog, schema, model)) => {
298 model_version_ident(&catalog, &schema, &model, self.version)
299 }
300 Err(_) => ResourceIdent::model_version(ResourceName::from_naive_str_split(
301 self.full_name.as_str(),
302 )),
303 }
304 }
305
306 fn permission(&self) -> &'static Permission {
307 &Permission::Manage
308 }
309}
310
311#[cfg(test)]
312mod tests {
313 use std::sync::Arc;
314
315 use unitycatalog_common::models::catalogs::v1::CreateCatalogRequest;
316 use unitycatalog_common::models::credentials::v1::{
317 AwsIamRoleConfig, CreateCredentialRequest, Purpose,
318 };
319 use unitycatalog_common::models::external_locations::v1::CreateExternalLocationRequest;
320 use unitycatalog_common::models::registered_models::v1::CreateRegisteredModelRequest;
321 use unitycatalog_common::models::schemas::v1::CreateSchemaRequest;
322 use unitycatalog_common::services::encryption::{EnvelopeEncryptor, LocalKeyProvider};
323
324 use super::*;
325 use crate::api::{
326 CatalogHandler, CredentialHandler, ExternalLocationHandler, RegisteredModelHandler,
327 SchemaHandler,
328 };
329 use crate::memory::InMemoryResourceStore;
330 use crate::policy::ConstantPolicy;
331 use crate::services::ServerHandler;
332
333 fn handler() -> ServerHandler<RequestContext> {
334 let encryptor =
335 EnvelopeEncryptor::local(LocalKeyProvider::single("test", vec![0x42; 32]).unwrap());
336 let store = Arc::new(InMemoryResourceStore::new(encryptor));
337 let policy: Arc<dyn Policy<RequestContext>> = Arc::new(ConstantPolicy::default());
338 ServerHandler::try_new_tokio(policy, store).unwrap()
339 }
340
341 fn ctx() -> RequestContext {
342 RequestContext {
343 recipient: crate::policy::Principal::anonymous(),
344 }
345 }
346
347 async fn make_covering_location(h: &ServerHandler<RequestContext>, tag: &str, url: &str) {
349 h.create_credential(
350 CreateCredentialRequest {
351 name: format!("{tag}-cred"),
352 purpose: Purpose::Storage.into(),
353 aws_iam_role: Some(AwsIamRoleConfig {
354 role_arn: "arn:aws:iam::123456789012:role/test".to_string(),
355 ..Default::default()
356 })
357 .into(),
358 ..Default::default()
359 },
360 ctx(),
361 )
362 .await
363 .unwrap();
364 h.create_external_location(
365 CreateExternalLocationRequest {
366 name: format!("{tag}-el"),
367 url: url.to_string(),
368 credential_name: format!("{tag}-cred"),
369 ..Default::default()
370 },
371 ctx(),
372 )
373 .await
374 .unwrap();
375 }
376
377 async fn setup_model(h: &ServerHandler<RequestContext>, storage_root: &str) {
380 make_covering_location(h, "cat", storage_root).await;
381 h.create_catalog(
382 CreateCatalogRequest {
383 name: "cat".to_string(),
384 storage_root: Some(storage_root.to_string()),
385 ..Default::default()
386 },
387 ctx(),
388 )
389 .await
390 .unwrap();
391 h.create_schema(
392 CreateSchemaRequest {
393 name: "sch".to_string(),
394 catalog_name: "cat".to_string(),
395 ..Default::default()
396 },
397 ctx(),
398 )
399 .await
400 .unwrap();
401 h.create_registered_model(
402 CreateRegisteredModelRequest {
403 name: "mdl".to_string(),
404 catalog_name: "cat".to_string(),
405 schema_name: "sch".to_string(),
406 ..Default::default()
407 },
408 ctx(),
409 )
410 .await
411 .unwrap();
412 }
413
414 fn create_version(source: &str) -> CreateModelVersionRequest {
415 CreateModelVersionRequest {
416 model_name: "mdl".to_string(),
417 catalog_name: "cat".to_string(),
418 schema_name: "sch".to_string(),
419 source: source.to_string(),
420 ..Default::default()
421 }
422 }
423
424 #[tokio::test]
425 async fn create_assigns_monotonic_versions_and_pending_status() {
426 let h = handler();
427 setup_model(&h, "s3://bucket/cat").await;
428
429 let v1 = h
430 .create_model_version(create_version("s3://src/1"), ctx())
431 .await
432 .unwrap();
433 assert_eq!(v1.version, 1);
434 assert_eq!(
435 v1.status.as_known(),
436 Some(ModelVersionStatus::PendingRegistration)
437 );
438 let loc = v1.storage_location.as_deref().unwrap();
440 assert!(loc.ends_with("/versions/1"), "got {loc}");
441 assert!(
442 loc.contains("/__unitystorage/catalogs/") && loc.contains("/models/"),
443 "got {loc}"
444 );
445
446 let v2 = h
447 .create_model_version(create_version("s3://src/2"), ctx())
448 .await
449 .unwrap();
450 assert_eq!(v2.version, 2);
451 }
452
453 #[tokio::test]
454 async fn get_and_finalize_transitions_to_ready() {
455 let h = handler();
456 setup_model(&h, "s3://bucket/cat").await;
457 h.create_model_version(create_version("s3://src/1"), ctx())
458 .await
459 .unwrap();
460
461 let got = h
462 .get_model_version(
463 GetModelVersionRequest {
464 full_name: "cat.sch.mdl".to_string(),
465 version: 1,
466 ..Default::default()
467 },
468 ctx(),
469 )
470 .await
471 .unwrap();
472 assert_eq!(got.version, 1);
473 assert_eq!(
474 got.status.as_known(),
475 Some(ModelVersionStatus::PendingRegistration)
476 );
477
478 let finalized = h
479 .finalize_model_version(
480 FinalizeModelVersionRequest {
481 full_name: "cat.sch.mdl".to_string(),
482 version: 1,
483 ..Default::default()
484 },
485 ctx(),
486 )
487 .await
488 .unwrap();
489 assert_eq!(finalized.status.as_known(), Some(ModelVersionStatus::Ready));
490 }
491
492 #[tokio::test]
493 async fn list_returns_versions_for_the_model() {
494 let h = handler();
495 setup_model(&h, "s3://bucket/cat").await;
496 h.create_model_version(create_version("s3://src/1"), ctx())
497 .await
498 .unwrap();
499 h.create_model_version(create_version("s3://src/2"), ctx())
500 .await
501 .unwrap();
502
503 let listed = h
504 .list_model_versions(
505 ListModelVersionsRequest {
506 full_name: "cat.sch.mdl".to_string(),
507 ..Default::default()
508 },
509 ctx(),
510 )
511 .await
512 .unwrap();
513 let mut versions: Vec<i64> = listed.model_versions.iter().map(|v| v.version).collect();
514 versions.sort_unstable();
515 assert_eq!(versions, vec![1, 2]);
516 }
517
518 #[tokio::test]
519 async fn delete_removes_the_version() {
520 let h = handler();
521 setup_model(&h, "s3://bucket/cat").await;
522 h.create_model_version(create_version("s3://src/1"), ctx())
523 .await
524 .unwrap();
525 h.delete_model_version(
526 DeleteModelVersionRequest {
527 full_name: "cat.sch.mdl".to_string(),
528 version: 1,
529 ..Default::default()
530 },
531 ctx(),
532 )
533 .await
534 .unwrap();
535 let res = h
536 .get_model_version(
537 GetModelVersionRequest {
538 full_name: "cat.sch.mdl".to_string(),
539 version: 1,
540 ..Default::default()
541 },
542 ctx(),
543 )
544 .await;
545 assert!(res.is_err(), "deleted version should not be found");
546 }
547}