Skip to main content

unitycatalog_server/
memory.rs

1//! In-memory resource store for tests and local development.
2//!
3//! [`InMemoryResourceStore`] is a thin composition over
4//! [`olai_store::InMemoryStore`] wrapped in an
5//! [`olai_store::ManagedObjectStore`] (for inline sensitive-field sealing) and
6//! lifted to the typed [`ResourceStore`] API by
7//! [`ObjectStoreAdapter`](unitycatalog_common::store::ObjectStoreAdapter). It
8//! mirrors the durable backends (sqlite/postgres) so the same code paths —
9//! object/association storage and inline secret sealing — are exercised in
10//! tests, without a database.
11
12use std::str::FromStr;
13use std::sync::Arc;
14
15use olai_store::{InMemoryStore, ManagedObjectStore, ResourceRegistry};
16use unitycatalog_common::models::AssociationLabel;
17use unitycatalog_common::models::ObjectLabel;
18use unitycatalog_common::models::labels::RESOURCE_DESCRIPTORS;
19use unitycatalog_common::services::encryption::EnvelopeEncryptor;
20use unitycatalog_common::store::{ObjectStoreAdapter, ProvidesResourceStore, ResourceStore};
21
22/// Map an [`AssociationLabel`] string to its inverse label string, for the
23/// generic store's inverse-edge resolver (mirrors the sqlite backend).
24fn inverse_resolver(label: &str) -> Option<String> {
25    AssociationLabel::from_str(label)
26        .ok()
27        .and_then(|l| l.inverse())
28        .map(|inv| inv.to_string())
29}
30
31/// The concrete store stack backing the in-memory backend: a registry-aware,
32/// encrypting object store over an in-memory graph, lifted to [`ResourceStore`].
33type MemoryAdapter =
34    ObjectStoreAdapter<ManagedObjectStore<ObjectLabel, InMemoryStore<ObjectLabel>>>;
35
36/// An in-memory implementation of a resource store.
37///
38/// Not intended for production use, but useful for testing and development. Like
39/// the durable backends, credential secret fields are sealed inline on the
40/// object row (see [`ManagedObjectStore`]); there is no separate secret store.
41#[derive(Clone)]
42pub struct InMemoryResourceStore {
43    store: Arc<MemoryAdapter>,
44}
45
46impl InMemoryResourceStore {
47    pub fn new(encryptor: EnvelopeEncryptor) -> Self {
48        let inner = InMemoryStore::<ObjectLabel>::with_inverse(inverse_resolver);
49        let registry = ResourceRegistry::from_static(RESOURCE_DESCRIPTORS);
50        let managed = ManagedObjectStore::with_encryptor(inner, encryptor, registry);
51        Self {
52            store: Arc::new(ObjectStoreAdapter::new(managed)),
53        }
54    }
55}
56
57impl ProvidesResourceStore for InMemoryResourceStore {
58    fn store(&self) -> &dyn ResourceStore {
59        self.store.as_ref()
60    }
61}
62
63#[cfg(test)]
64mod tests {
65    use super::*;
66    use unitycatalog_common::models::{Catalog, ObjectLabel, ResourceExt, ResourceRef};
67    use unitycatalog_common::services::encryption::LocalKeyProvider;
68    use unitycatalog_common::store::{ResourceStore, ResourceStoreReader};
69    use unitycatalog_common::{Error, Result};
70    use uuid::Uuid;
71
72    fn test_store() -> InMemoryResourceStore {
73        let encryptor =
74            EnvelopeEncryptor::local(LocalKeyProvider::single("test", vec![0x42; 32]).unwrap());
75        InMemoryResourceStore::new(encryptor)
76    }
77
78    #[tokio::test]
79    async fn test_create_get_delete() -> Result<()> {
80        let store = test_store();
81        let resource: unitycatalog_common::models::Resource = Catalog {
82            name: "new_catalog".into(),
83            ..Default::default()
84        }
85        .into();
86        let (created, reference) = store.create(resource.clone()).await?;
87        assert_eq!(created.resource_name(), resource.resource_name());
88
89        let ident = ObjectLabel::Catalog.to_ident(reference);
90        let (retrieved, _) = store.get(&ident).await?;
91        assert_eq!(retrieved, created);
92
93        store.delete(&ident).await?;
94        let result = store.get(&ident).await;
95        assert!(matches!(result.unwrap_err(), Error::NotFound));
96        Ok(())
97    }
98
99    #[tokio::test]
100    async fn create_honors_pre_allocated_id() -> Result<()> {
101        use unitycatalog_common::models::volumes::v1::Volume;
102
103        let store = test_store();
104        let id = Uuid::new_v4();
105        let resource: unitycatalog_common::models::Resource = Volume {
106            name: "vol".into(),
107            catalog_name: "cat".into(),
108            schema_name: "sch".into(),
109            volume_id: id.hyphenated().to_string(),
110            ..Default::default()
111        }
112        .into();
113
114        let (_, reference) = store.create(resource).await?;
115        // The store persists under the supplied id rather than minting a new one.
116        assert_eq!(reference, ResourceRef::Uuid(id));
117        Ok(())
118    }
119
120    #[tokio::test]
121    async fn create_generates_id_when_absent() -> Result<()> {
122        // A resource with no id set (the common case) still gets a fresh minted id.
123        let store = test_store();
124        let resource: unitycatalog_common::models::Resource = Catalog {
125            name: "cat".into(),
126            ..Default::default()
127        }
128        .into();
129        let (_, reference) = store.create(resource).await?;
130        let ResourceRef::Uuid(id) = reference else {
131            panic!("expected a uuid reference, got {reference:?}");
132        };
133        assert!(!id.is_nil(), "store should mint a non-nil id");
134        Ok(())
135    }
136
137    #[tokio::test]
138    async fn create_at_same_uuid_is_rejected() -> Result<()> {
139        use unitycatalog_common::models::staging_tables::v1::StagingTable;
140        use unitycatalog_common::models::tables::v1::Table;
141
142        // The object store keys objects by a single uuid: one object per uuid is the
143        // store's contract. The managed-table flow therefore does not let a Table
144        // coexist with its StagingTable at the same id — it *replaces* the staging
145        // reservation atomically (see `ResourceStore::replace_atomically`). A raw
146        // create of a second object at an already-occupied uuid must be rejected.
147        let store = test_store();
148        let id = Uuid::new_v4();
149        let id_str = id.hyphenated().to_string();
150
151        store
152            .create(
153                StagingTable {
154                    name: "t".into(),
155                    catalog_name: "cat".into(),
156                    schema_name: "sch".into(),
157                    id: id_str.clone(),
158                    ..Default::default()
159                }
160                .into(),
161            )
162            .await?;
163        // Creating a Table at the same uuid (a different label) must fail: the id is
164        // already occupied by the staging reservation.
165        let res = store
166            .create(
167                Table {
168                    name: "t".into(),
169                    catalog_name: "cat".into(),
170                    schema_name: "sch".into(),
171                    table_id: Some(id_str.clone()),
172                    ..Default::default()
173                }
174                .into(),
175            )
176            .await;
177        assert!(matches!(res, Err(Error::AlreadyExists)), "{res:?}");
178
179        // The staging reservation is untouched and still readable at its uuid.
180        let staging_ident = ObjectLabel::StagingTable.to_ident(ResourceRef::Uuid(id));
181        let staging: StagingTable = store.get(&staging_ident).await?.0.try_into()?;
182        assert_eq!(staging.id, id_str);
183        Ok(())
184    }
185
186    #[tokio::test]
187    async fn create_rejects_duplicate_pre_allocated_id() -> Result<()> {
188        use unitycatalog_common::models::volumes::v1::Volume;
189
190        let store = test_store();
191        let id = Uuid::new_v4();
192        let volume = |name: &str| -> unitycatalog_common::models::Resource {
193            Volume {
194                name: name.into(),
195                catalog_name: "cat".into(),
196                schema_name: "sch".into(),
197                volume_id: id.hyphenated().to_string(),
198                ..Default::default()
199            }
200            .into()
201        };
202        store.create(volume("a")).await?;
203        // A different name but the same pre-allocated id must not overwrite the
204        // existing row (the id primary key enforces this).
205        let res = store.create(volume("b")).await;
206        assert!(matches!(res, Err(Error::AlreadyExists)), "{res:?}");
207        Ok(())
208    }
209
210    #[tokio::test]
211    async fn test_list() -> Result<()> {
212        let store = test_store();
213        let resource: unitycatalog_common::models::Resource = Catalog {
214            name: "new_catalog".into(),
215            ..Default::default()
216        }
217        .into();
218        let (created, _) = store.create(resource.clone()).await?;
219
220        let (resources, next) = store.list(&ObjectLabel::Catalog, None, None, None).await?;
221        assert_eq!(resources.len(), 1);
222        assert_eq!(resources[0], created);
223        assert!(next.is_none());
224
225        // add more resources
226        for name in ["new_catalog2", "new_catalog3"] {
227            let resource: unitycatalog_common::models::Resource = Catalog {
228                name: name.into(),
229                ..Default::default()
230            }
231            .into();
232            store.create(resource).await?;
233        }
234
235        let (resources, next) = store
236            .list(&ObjectLabel::Catalog, None, Some(2), None)
237            .await?;
238        assert_eq!(resources.len(), 2);
239        assert!(next.is_some());
240
241        let (resources, next) = store
242            .list(&ObjectLabel::Catalog, None, Some(2), next)
243            .await?;
244        assert_eq!(resources.len(), 1);
245        assert!(next.is_none());
246        Ok(())
247    }
248
249    #[tokio::test]
250    async fn test_provider_round_trip() -> Result<()> {
251        use unitycatalog_common::models::providers::v1::{Provider, ProviderAuthenticationType};
252
253        let store = test_store();
254        let resource: unitycatalog_common::models::Resource = Provider {
255            name: "acme".into(),
256            authentication_type: ProviderAuthenticationType::Token.into(),
257            comment: Some("inbound share from acme".into()),
258            ..Default::default()
259        }
260        .into();
261
262        // Create exercises the Resource::Provider -> Object conversion.
263        let (created, reference) = store.create(resource.clone()).await?;
264        assert_eq!(created.resource_name(), resource.resource_name());
265
266        // Get exercises the Object -> Resource::Provider conversion and the
267        // hand-written ObjectLabel::Provider -> ResourceIdent mapping.
268        let ident = ObjectLabel::Provider.to_ident(reference);
269        let (retrieved, _) = store.get(&ident).await?;
270        assert_eq!(retrieved, created);
271        let provider: Provider = retrieved.try_into()?;
272        assert_eq!(provider.name, "acme");
273        assert_eq!(provider.comment.as_deref(), Some("inbound share from acme"));
274
275        // List by the Provider label.
276        let (resources, _) = store.list(&ObjectLabel::Provider, None, None, None).await?;
277        assert_eq!(resources.len(), 1);
278
279        store.delete(&ident).await?;
280        assert!(matches!(
281            store.get(&ident).await.unwrap_err(),
282            Error::NotFound
283        ));
284        Ok(())
285    }
286
287    #[tokio::test]
288    async fn update_checked_cas_succeeds_on_match_conflicts_on_stale() -> Result<()> {
289        use unitycatalog_common::store::Precondition;
290
291        let store = test_store();
292        let (_, reference) = store
293            .create(
294                Catalog {
295                    name: "cat".into(),
296                    ..Default::default()
297                }
298                .into(),
299            )
300            .await?;
301        let ident = ObjectLabel::Catalog.to_ident(reference);
302
303        // Read the current version (etag) to pin the CAS against.
304        let (resource, _, version) = store.get_versioned(&ident).await?;
305        let mut catalog: Catalog = resource.try_into()?;
306        catalog.comment = Some("first".into());
307
308        // CAS with the observed version succeeds and bumps the version.
309        let (_, _, new_version) = store
310            .update_checked(
311                &ident,
312                catalog.clone().into(),
313                Precondition::Version(version),
314            )
315            .await?;
316        assert!(new_version > version, "version must advance on update");
317
318        // A second CAS against the now-stale original version must conflict.
319        catalog.comment = Some("second".into());
320        let res = store
321            .update_checked(&ident, catalog.into(), Precondition::Version(version))
322            .await;
323        assert!(matches!(res, Err(Error::Conflict)), "{res:?}");
324        Ok(())
325    }
326
327    #[tokio::test]
328    async fn rename_preserves_id_and_associations() -> Result<()> {
329        use unitycatalog_common::models::AssociationLabel;
330        use unitycatalog_common::models::ResourceName;
331        use unitycatalog_common::store::Precondition;
332
333        let store = test_store();
334        // Two catalogs, linked by an association edge.
335        let (_, from_ref) = store
336            .create(
337                Catalog {
338                    name: "old_name".into(),
339                    ..Default::default()
340                }
341                .into(),
342            )
343            .await?;
344        let (_, to_ref) = store
345            .create(
346                Catalog {
347                    name: "peer".into(),
348                    ..Default::default()
349                }
350                .into(),
351            )
352            .await?;
353        let from_ident = ObjectLabel::Catalog.to_ident(from_ref.clone());
354        let to_ident = ObjectLabel::Catalog.to_ident(to_ref);
355        store
356            .add_association(&from_ident, &to_ident, &AssociationLabel::ParentOf, None)
357            .await?;
358
359        // Rename the catalog; the id is preserved and the edge still resolves.
360        let (_, renamed_ref, _) = store
361            .rename(
362                &from_ident,
363                &ResourceName::new(["new_name"]),
364                Precondition::Any,
365            )
366            .await?;
367        assert_eq!(renamed_ref, from_ref, "rename must preserve the id");
368
369        let renamed: Catalog = store.get(&from_ident).await?.0.try_into()?;
370        assert_eq!(renamed.name, "new_name");
371
372        let (edges, _) = store
373            .list_associations(&from_ident, &AssociationLabel::ParentOf, None, None, None)
374            .await?;
375        assert_eq!(edges.len(), 1, "association must survive the rename");
376        Ok(())
377    }
378}