Skip to main content

unitycatalog_server/api/
model_versions.rs

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
15/// Split a registered model's three-level `full_name` into its components.
16fn 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
26/// The store `ResourceIdent` for a specific model version, keyed by the composite
27/// `[catalog, schema, model_name, version]` name — matching `ModelVersion::resource_name`.
28fn 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        // Resolve the parent registered model to derive the version's storage location
80        // under the model's managed root, and to confirm the model exists.
81        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        // Assign the next version number. Versions are monotonically increasing per
92        // model; a fresh model starts at version 1.
93        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        // Only `comment` is mutable via update; preserve the rest by reading first.
138        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        // Finalization transitions the version to READY, signaling that all
171        // artifacts have been written to its storage location.
172        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/// Version-allocation helper, factored out so it is not part of the public handler trait.
182#[async_trait::async_trait]
183trait ModelVersionAllocExt: ResourceStore {
184    /// Compute the next version number for a registered model: one greater than the
185    /// highest existing version, or 1 for a model with no versions yet.
186    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        // Page through all existing versions to find the current maximum. Model
190        // version counts are small, so a full scan is acceptable.
191        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            // A malformed name yields an undefined ref; the handler's name parse
248            // surfaces the real error.
249            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    /// Register a covering external location + credential for `url`.
348    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    /// Create catalog `cat` (rooted at `storage_root`), schema `sch`, and registered
378    /// model `mdl` so model versions can be created beneath it.
379    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        // The version's storage location nests under the model's managed root.
439        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}