kernel/registry.rs
1//! The store of owned models: a `models.json` file holding every [`ModelRecord`]
2//! the kernel knows about, with change-detecting writes and corruption recovery.
3
4use std::collections::BTreeMap;
5use std::path::{Path, PathBuf};
6
7use serde::{Deserialize, Serialize};
8
9use crate::persistence::{self, StoreError};
10use crate::records::{ModelRecord, ModelState};
11
12const STORE_FILE: &str = "models.json";
13const LOCK_FILE: &str = "models.json.lock";
14const SCHEMA_VERSION: u32 = 1;
15
16/// Errors raised by the registry.
17#[derive(Debug, thiserror::Error)]
18pub enum RegistryError {
19 /// The store file could not be decoded. It has been quarantined; the payload
20 /// describes the decode failure.
21 #[error("corrupt registry store: {0}")]
22 CorruptStore(String),
23
24 /// The store was written by a newer schema version than this build supports.
25 /// It is left untouched rather than downgraded, so its data is not lost.
26 #[error("registry store schema {found} is newer than supported {supported}")]
27 FutureSchema {
28 /// The version found on disk.
29 found: u32,
30 /// The newest version this build understands.
31 supported: u32,
32 },
33
34 /// A filesystem or encoding error while reading or writing the store.
35 #[error(transparent)]
36 Store(#[from] StoreError),
37
38 /// The advisory file lock guarding a mutation could not be acquired.
39 #[error("locking registry store: {0}")]
40 Lock(String),
41}
42
43#[derive(Debug, Deserialize)]
44struct Envelope {
45 schema_version: u32,
46 models: Vec<ModelRecord>,
47}
48
49#[derive(Serialize)]
50struct EnvelopeRef<'a> {
51 schema_version: u32,
52 models: Vec<&'a ModelRecord>,
53}
54
55/// The in-memory model store, backed by `<directory>/models.json`. Every mutating
56/// method acquires a short-held advisory file lock, reloads the on-disk state
57/// under it, applies the change, and persists before returning — so two
58/// instances on the same directory serialize their writes instead of one
59/// silently clobbering the other. The lock is held only for the span of a
60/// single mutation, never across the instance's lifetime.
61#[derive(Debug)]
62pub struct Registry {
63 directory: PathBuf,
64 models: BTreeMap<String, ModelRecord>,
65 generation: u64,
66}
67
68impl Registry {
69 /// Open the registry rooted at `directory`, loading `models.json` if present.
70 /// A missing store opens empty; a corrupt store is quarantined and reported
71 /// as [`RegistryError::CorruptStore`]; a store from a newer schema is left in
72 /// place and reported as [`RegistryError::FutureSchema`]. If the file holds
73 /// two records with the same id, the last one in file order wins.
74 pub fn open(directory: &Path) -> Result<Self, RegistryError> {
75 let models = Self::load_models(directory)?;
76 Ok(Self {
77 directory: directory.to_path_buf(),
78 models,
79 generation: 0,
80 })
81 }
82
83 /// Read `<directory>/models.json` into a fresh map, applying the same
84 /// missing/corrupt/future-schema handling as [`Self::open`].
85 fn load_models(directory: &Path) -> Result<BTreeMap<String, ModelRecord>, RegistryError> {
86 let file = directory.join(STORE_FILE);
87 match persistence::read_json::<Envelope>(&file) {
88 Ok(Some(envelope)) => {
89 if envelope.schema_version > SCHEMA_VERSION {
90 return Err(RegistryError::FutureSchema {
91 found: envelope.schema_version,
92 supported: SCHEMA_VERSION,
93 });
94 }
95 Ok(envelope
96 .models
97 .into_iter()
98 .map(|mut record| {
99 // A shelf written before sizes were exact carries whole
100 // mebibytes; folded in here rather than at every reader,
101 // so a size shows before the next scan measures one.
102 record.adopt_legacy_footprint();
103 (record.id.clone(), record)
104 })
105 .collect())
106 }
107 Ok(None) => Ok(BTreeMap::new()),
108 Err(StoreError::Corrupt { source, .. }) => {
109 Err(RegistryError::CorruptStore(source.to_string()))
110 }
111 Err(other) => Err(RegistryError::Store(other)),
112 }
113 }
114
115 /// Re-read the on-disk store into memory, discarding the in-memory view. Called
116 /// under the advisory lock so a mutation applies to the latest committed state.
117 fn reload(&mut self) -> Result<(), RegistryError> {
118 self.models = Self::load_models(&self.directory)?;
119 Ok(())
120 }
121
122 /// Re-read the store, picking up what another process has committed since
123 /// this one loaded it. Returns whether anything changed.
124 ///
125 /// The in-memory view is otherwise only reloaded under a mutation, which is
126 /// enough while one process owns the shelf. A pull worker registering what
127 /// it fetched is another process, and a screen that never re-read would show
128 /// a download as landed with the model nowhere on its shelf.
129 pub fn refresh(&mut self) -> Result<bool, RegistryError> {
130 let models = Self::load_models(&self.directory)?;
131 if models == self.models {
132 return Ok(false);
133 }
134 self.models = models;
135 // Whatever was cached against the old generation was derived from
136 // records that have just been replaced.
137 self.generation += 1;
138 Ok(true)
139 }
140
141 /// Acquire an exclusive OS advisory lock on a `models.json.lock` sibling,
142 /// blocking until it is available. The returned file releases the lock when
143 /// dropped; callers hold it only for the span of one mutation, never longer.
144 fn lock(&self) -> Result<std::fs::File, RegistryError> {
145 use fs2::FileExt;
146 std::fs::create_dir_all(&self.directory)
147 .map_err(|source| RegistryError::Lock(source.to_string()))?;
148 let path = self.directory.join(LOCK_FILE);
149 let file = std::fs::OpenOptions::new()
150 .create(true)
151 .truncate(false)
152 .read(true)
153 .write(true)
154 .open(&path)
155 .map_err(|source| RegistryError::Lock(source.to_string()))?;
156 file.lock_exclusive()
157 .map_err(|source| RegistryError::Lock(source.to_string()))?;
158 Ok(file)
159 }
160
161 /// The record with `id`, if present.
162 pub fn get(&self, id: &str) -> Option<&ModelRecord> {
163 self.models.get(id)
164 }
165
166 /// Whether a record with `id` is present.
167 pub fn contains(&self, id: &str) -> bool {
168 self.models.contains_key(id)
169 }
170
171 /// The number of records held.
172 pub fn len(&self) -> usize {
173 self.models.len()
174 }
175
176 /// Whether the registry holds no records.
177 pub fn is_empty(&self) -> bool {
178 self.models.is_empty()
179 }
180
181 /// A counter that advances every time [`Self::save`] persists a change.
182 /// Callers can cache work derived from the registry's contents keyed on this
183 /// value and rebuild only when it moves.
184 pub fn generation(&self) -> u64 {
185 self.generation
186 }
187
188 /// Every record, sorted by display name (case-insensitive) then id.
189 pub fn list(&self) -> Vec<&ModelRecord> {
190 let mut records: Vec<&ModelRecord> = self.models.values().collect();
191 records.sort_by_cached_key(|record| (record.name.to_lowercase(), record.id.clone()));
192 records
193 }
194
195 /// Insert or replace `record`. Returns whether anything changed; an identical
196 /// record is a no-op that touches no disk.
197 pub fn register(&mut self, record: ModelRecord) -> Result<bool, RegistryError> {
198 let _lock = self.lock()?;
199 self.reload()?;
200 if self.models.get(&record.id) == Some(&record) {
201 return Ok(false);
202 }
203 self.models.insert(record.id.clone(), record);
204 self.save()?;
205 Ok(true)
206 }
207
208 /// Insert or replace many records, writing once. Returns how many input
209 /// records differed from the store (counted per input record, so two inputs
210 /// with the same id both count even though only the last survives).
211 pub fn register_all(&mut self, records: Vec<ModelRecord>) -> Result<usize, RegistryError> {
212 let _lock = self.lock()?;
213 self.reload()?;
214 let mut changed = 0;
215 for record in records {
216 if self.models.get(&record.id) != Some(&record) {
217 self.models.insert(record.id.clone(), record);
218 changed += 1;
219 }
220 }
221 if changed > 0 {
222 self.save()?;
223 }
224 Ok(changed)
225 }
226
227 /// Remove the record with `id`, returning it if it was present.
228 pub fn unregister(&mut self, id: &str) -> Result<Option<ModelRecord>, RegistryError> {
229 let _lock = self.lock()?;
230 self.reload()?;
231 let removed = self.models.remove(id);
232 if removed.is_some() {
233 self.save()?;
234 }
235 Ok(removed)
236 }
237
238 /// Set the lifecycle state of `id` if it is present. Returns whether the
239 /// record was present; only a real state change touches disk.
240 pub fn set_state_if_present(
241 &mut self,
242 id: &str,
243 state: ModelState,
244 ) -> Result<bool, RegistryError> {
245 let _lock = self.lock()?;
246 self.reload()?;
247 let Some(record) = self.models.get_mut(id) else {
248 return Ok(false);
249 };
250 if record.state == state {
251 return Ok(true);
252 }
253 record.state = state;
254 self.save()?;
255 Ok(true)
256 }
257
258 /// Apply `transform` to each present id. When it returns a record that differs
259 /// from the current one, the record is stored under its own id — if the
260 /// transform changed the id, the old key is removed so the record migrates
261 /// cleanly rather than leaving the map keyed by a stale id. Returns the changed
262 /// records; writes once if anything changed.
263 pub fn update(
264 &mut self,
265 ids: &[String],
266 transform: impl Fn(&ModelRecord) -> Option<ModelRecord>,
267 ) -> Result<Vec<ModelRecord>, RegistryError> {
268 let _lock = self.lock()?;
269 self.reload()?;
270 let mut changed = Vec::new();
271 for id in ids {
272 let Some(next) = self.models.get(id).and_then(&transform) else {
273 continue;
274 };
275 if self.models.get(id) == Some(&next) {
276 continue;
277 }
278 if next.id != *id {
279 self.models.remove(id);
280 }
281 self.models.insert(next.id.clone(), next.clone());
282 changed.push(next);
283 }
284 if !changed.is_empty() {
285 self.save()?;
286 }
287 Ok(changed)
288 }
289
290 fn save(&mut self) -> Result<(), RegistryError> {
291 let envelope = EnvelopeRef {
292 schema_version: SCHEMA_VERSION,
293 models: self.models.values().collect(),
294 };
295 persistence::write_json_atomic(&self.directory.join(STORE_FILE), &envelope)?;
296 self.generation += 1;
297 Ok(())
298 }
299}