Skip to main content

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(|record| (record.id.clone(), record))
99                    .collect())
100            }
101            Ok(None) => Ok(BTreeMap::new()),
102            Err(StoreError::Corrupt { source, .. }) => {
103                Err(RegistryError::CorruptStore(source.to_string()))
104            }
105            Err(other) => Err(RegistryError::Store(other)),
106        }
107    }
108
109    /// Re-read the on-disk store into memory, discarding the in-memory view. Called
110    /// under the advisory lock so a mutation applies to the latest committed state.
111    fn reload(&mut self) -> Result<(), RegistryError> {
112        self.models = Self::load_models(&self.directory)?;
113        Ok(())
114    }
115
116    /// Acquire an exclusive OS advisory lock on a `models.json.lock` sibling,
117    /// blocking until it is available. The returned file releases the lock when
118    /// dropped; callers hold it only for the span of one mutation, never longer.
119    fn lock(&self) -> Result<std::fs::File, RegistryError> {
120        use fs2::FileExt;
121        std::fs::create_dir_all(&self.directory)
122            .map_err(|source| RegistryError::Lock(source.to_string()))?;
123        let path = self.directory.join(LOCK_FILE);
124        let file = std::fs::OpenOptions::new()
125            .create(true)
126            .truncate(false)
127            .read(true)
128            .write(true)
129            .open(&path)
130            .map_err(|source| RegistryError::Lock(source.to_string()))?;
131        file.lock_exclusive()
132            .map_err(|source| RegistryError::Lock(source.to_string()))?;
133        Ok(file)
134    }
135
136    /// The record with `id`, if present.
137    pub fn get(&self, id: &str) -> Option<&ModelRecord> {
138        self.models.get(id)
139    }
140
141    /// Whether a record with `id` is present.
142    pub fn contains(&self, id: &str) -> bool {
143        self.models.contains_key(id)
144    }
145
146    /// The number of records held.
147    pub fn len(&self) -> usize {
148        self.models.len()
149    }
150
151    /// Whether the registry holds no records.
152    pub fn is_empty(&self) -> bool {
153        self.models.is_empty()
154    }
155
156    /// A counter that advances every time [`Self::save`] persists a change.
157    /// Callers can cache work derived from the registry's contents keyed on this
158    /// value and rebuild only when it moves.
159    pub fn generation(&self) -> u64 {
160        self.generation
161    }
162
163    /// Every record, sorted by display name (case-insensitive) then id.
164    pub fn list(&self) -> Vec<&ModelRecord> {
165        let mut records: Vec<&ModelRecord> = self.models.values().collect();
166        records.sort_by_cached_key(|record| (record.name.to_lowercase(), record.id.clone()));
167        records
168    }
169
170    /// Insert or replace `record`. Returns whether anything changed; an identical
171    /// record is a no-op that touches no disk.
172    pub fn register(&mut self, record: ModelRecord) -> Result<bool, RegistryError> {
173        let _lock = self.lock()?;
174        self.reload()?;
175        if self.models.get(&record.id) == Some(&record) {
176            return Ok(false);
177        }
178        self.models.insert(record.id.clone(), record);
179        self.save()?;
180        Ok(true)
181    }
182
183    /// Insert or replace many records, writing once. Returns how many input
184    /// records differed from the store (counted per input record, so two inputs
185    /// with the same id both count even though only the last survives).
186    pub fn register_all(&mut self, records: Vec<ModelRecord>) -> Result<usize, RegistryError> {
187        let _lock = self.lock()?;
188        self.reload()?;
189        let mut changed = 0;
190        for record in records {
191            if self.models.get(&record.id) != Some(&record) {
192                self.models.insert(record.id.clone(), record);
193                changed += 1;
194            }
195        }
196        if changed > 0 {
197            self.save()?;
198        }
199        Ok(changed)
200    }
201
202    /// Remove the record with `id`, returning it if it was present.
203    pub fn unregister(&mut self, id: &str) -> Result<Option<ModelRecord>, RegistryError> {
204        let _lock = self.lock()?;
205        self.reload()?;
206        let removed = self.models.remove(id);
207        if removed.is_some() {
208            self.save()?;
209        }
210        Ok(removed)
211    }
212
213    /// Set the lifecycle state of `id` if it is present. Returns whether the
214    /// record was present; only a real state change touches disk.
215    pub fn set_state_if_present(
216        &mut self,
217        id: &str,
218        state: ModelState,
219    ) -> Result<bool, RegistryError> {
220        let _lock = self.lock()?;
221        self.reload()?;
222        let Some(record) = self.models.get_mut(id) else {
223            return Ok(false);
224        };
225        if record.state == state {
226            return Ok(true);
227        }
228        record.state = state;
229        self.save()?;
230        Ok(true)
231    }
232
233    /// Apply `transform` to each present id. When it returns a record that differs
234    /// from the current one, the record is stored under its own id — if the
235    /// transform changed the id, the old key is removed so the record migrates
236    /// cleanly rather than leaving the map keyed by a stale id. Returns the changed
237    /// records; writes once if anything changed.
238    pub fn update(
239        &mut self,
240        ids: &[String],
241        transform: impl Fn(&ModelRecord) -> Option<ModelRecord>,
242    ) -> Result<Vec<ModelRecord>, RegistryError> {
243        let _lock = self.lock()?;
244        self.reload()?;
245        let mut changed = Vec::new();
246        for id in ids {
247            let Some(next) = self.models.get(id).and_then(&transform) else {
248                continue;
249            };
250            if self.models.get(id) == Some(&next) {
251                continue;
252            }
253            if next.id != *id {
254                self.models.remove(id);
255            }
256            self.models.insert(next.id.clone(), next.clone());
257            changed.push(next);
258        }
259        if !changed.is_empty() {
260            self.save()?;
261        }
262        Ok(changed)
263    }
264
265    fn save(&mut self) -> Result<(), RegistryError> {
266        let envelope = EnvelopeRef {
267            schema_version: SCHEMA_VERSION,
268            models: self.models.values().collect(),
269        };
270        persistence::write_json_atomic(&self.directory.join(STORE_FILE), &envelope)?;
271        self.generation += 1;
272        Ok(())
273    }
274}