1use std::collections::HashMap;
25use std::path::PathBuf;
26use std::sync::Arc;
27
28use parking_lot::RwLock;
29use thiserror::Error;
30
31pub const BASE_SUFFIX: &str = "_base";
35
36pub type Owner = (u64, Option<String>);
38
39type Key = (String, String, String, String, String, String);
41
42#[allow(clippy::too_many_arguments)]
43fn make_key(
44 namespace: &str,
45 component: &str,
46 endpoint: &str,
47 slug: &str,
48 suffix: &str,
49 filename: &str,
50) -> Key {
51 (
52 namespace.to_string(),
53 component.to_string(),
54 endpoint.to_string(),
55 slug.to_string(),
56 suffix.to_string(),
57 filename.to_string(),
58 )
59}
60
61#[derive(Debug, Error)]
63#[error("metadata-registry collision on key {key:?}: prior_owner={prior:?}, new_owner={new:?}")]
64pub struct CollisionError {
65 pub key: Key,
66 pub prior: Owner,
67 pub new: Owner,
68}
69
70#[derive(Clone, Debug, Default)]
72pub struct MetadataArtifactRegistry {
73 entries: Arc<RwLock<HashMap<Key, (PathBuf, Owner)>>>,
74}
75
76impl MetadataArtifactRegistry {
77 pub fn new() -> Self {
78 Self::default()
79 }
80
81 #[allow(clippy::too_many_arguments)]
84 pub fn register(
85 &self,
86 owner: &Owner,
87 namespace: &str,
88 component: &str,
89 endpoint: &str,
90 slug: &str,
91 suffix: &str,
92 filename: &str,
93 path: PathBuf,
94 ) -> Result<(), Box<CollisionError>> {
95 let key = make_key(namespace, component, endpoint, slug, suffix, filename);
96 let mut entries = self.entries.write();
97 if let Some((_, prior)) = entries.get(&key)
98 && prior != owner
99 {
100 return Err(Box::new(CollisionError {
101 key,
102 prior: prior.clone(),
103 new: owner.clone(),
104 }));
105 }
106 entries.insert(key, (path, owner.clone()));
107 tracing::debug!(
108 namespace,
109 component,
110 endpoint,
111 slug,
112 suffix,
113 filename,
114 "registered metadata artifact",
115 );
116 Ok(())
117 }
118
119 #[allow(clippy::too_many_arguments)]
120 pub fn get(
121 &self,
122 namespace: &str,
123 component: &str,
124 endpoint: &str,
125 slug: &str,
126 suffix: &str,
127 filename: &str,
128 ) -> Option<PathBuf> {
129 let key = make_key(namespace, component, endpoint, slug, suffix, filename);
130 self.entries.read().get(&key).map(|(p, _)| p.clone())
131 }
132
133 pub fn unregister_for_owner(&self, owner: &Owner) {
136 self.entries.write().retain(|_, (_, o)| o != owner);
137 }
138
139 pub fn len(&self) -> usize {
140 self.entries.read().len()
141 }
142
143 pub fn is_empty(&self) -> bool {
144 self.entries.read().is_empty()
145 }
146}
147
148#[cfg(test)]
149mod tests {
150 use super::*;
151
152 fn base() -> Owner {
153 (1, None)
154 }
155
156 fn lora(slug: &str) -> Owner {
157 (1, Some(slug.to_string()))
158 }
159
160 #[test]
161 fn register_get_roundtrip() {
162 let reg = MetadataArtifactRegistry::new();
163 let p = PathBuf::from("/tmp/tokenizer.json");
164 reg.register(
165 &base(),
166 "ns",
167 "comp",
168 "ep",
169 "llama-3-8b",
170 "_base",
171 "tokenizer.json",
172 p.clone(),
173 )
174 .unwrap();
175
176 assert_eq!(
177 reg.get("ns", "comp", "ep", "llama-3-8b", "_base", "tokenizer.json"),
178 Some(p)
179 );
180 assert!(
181 reg.get("ns", "comp", "ep", "llama-3-8b", "_base", "missing.json")
182 .is_none()
183 );
184 assert!(
185 reg.get(
186 "ns",
187 "comp",
188 "ep",
189 "llama-3-8b",
190 "lora-v1",
191 "tokenizer.json"
192 )
193 .is_none()
194 );
195 }
196
197 #[test]
198 fn unregister_for_owner_clears_only_that_owner() {
199 let reg = MetadataArtifactRegistry::new();
200 let lora_owner = lora("lora-v1");
201 reg.register(
202 &base(),
203 "ns",
204 "comp",
205 "ep",
206 "m",
207 "_base",
208 "config.json",
209 PathBuf::from("/m/c"),
210 )
211 .unwrap();
212 reg.register(
213 &base(),
214 "ns",
215 "comp",
216 "ep",
217 "m",
218 "_base",
219 "tokenizer.json",
220 PathBuf::from("/m/t"),
221 )
222 .unwrap();
223 reg.register(
224 &lora_owner,
225 "ns",
226 "comp",
227 "ep",
228 "m",
229 "lora-v1",
230 "adapter.json",
231 PathBuf::from("/m/a"),
232 )
233 .unwrap();
234
235 reg.unregister_for_owner(&lora_owner);
236
237 assert!(
238 reg.get("ns", "comp", "ep", "m", "lora-v1", "adapter.json")
239 .is_none()
240 );
241 assert_eq!(
242 reg.get("ns", "comp", "ep", "m", "_base", "config.json"),
243 Some(PathBuf::from("/m/c"))
244 );
245 reg.unregister_for_owner(&lora_owner);
247 assert_eq!(reg.len(), 2);
248 }
249
250 #[test]
251 fn register_returns_err_on_owner_collision() {
252 let reg = MetadataArtifactRegistry::new();
253 let owner_a = (1, None);
254 let owner_b = (2, None);
255 reg.register(
256 &owner_a,
257 "ns",
258 "comp",
259 "ep",
260 "m",
261 "_base",
262 "config.json",
263 PathBuf::from("/a"),
264 )
265 .unwrap();
266 let err = reg
267 .register(
268 &owner_b,
269 "ns",
270 "comp",
271 "ep",
272 "m",
273 "_base",
274 "config.json",
275 PathBuf::from("/b"),
276 )
277 .unwrap_err();
278 assert_eq!(err.prior, owner_a);
279 assert_eq!(err.new, owner_b);
280 }
281
282 #[test]
283 fn register_same_owner_updates_path() {
284 let reg = MetadataArtifactRegistry::new();
285 reg.register(
286 &base(),
287 "ns",
288 "comp",
289 "ep",
290 "m",
291 "_base",
292 "config.json",
293 PathBuf::from("/a"),
294 )
295 .unwrap();
296 reg.register(
297 &base(),
298 "ns",
299 "comp",
300 "ep",
301 "m",
302 "_base",
303 "config.json",
304 PathBuf::from("/b"),
305 )
306 .unwrap();
307 assert_eq!(
308 reg.get("ns", "comp", "ep", "m", "_base", "config.json"),
309 Some(PathBuf::from("/b"))
310 );
311 }
312
313 #[test]
314 fn different_endpoints_coexist() {
315 let reg = MetadataArtifactRegistry::new();
316 let owner_a = (1, None);
317 let owner_b = (2, None);
318 reg.register(
319 &owner_a,
320 "ns",
321 "comp",
322 "ep-a",
323 "m",
324 "_base",
325 "config.json",
326 PathBuf::from("/a"),
327 )
328 .unwrap();
329 reg.register(
331 &owner_b,
332 "ns",
333 "comp",
334 "ep-b",
335 "m",
336 "_base",
337 "config.json",
338 PathBuf::from("/b"),
339 )
340 .unwrap();
341 assert_eq!(
342 reg.get("ns", "comp", "ep-a", "m", "_base", "config.json"),
343 Some(PathBuf::from("/a"))
344 );
345 assert_eq!(
346 reg.get("ns", "comp", "ep-b", "m", "_base", "config.json"),
347 Some(PathBuf::from("/b"))
348 );
349 }
350}