Skip to main content

orbok_models/
model_store.rs

1//! Cross-process serialization for the managed model store (RFC-050 ยง5).
2
3use crate::generation::ModelStoreProfileId;
4use std::fmt;
5use std::fs::{File, OpenOptions};
6use std::marker::PhantomData;
7use std::path::{Path, PathBuf};
8use std::time::{Duration, Instant};
9
10pub const MODEL_STORE_LOCK_FILE: &str = ".model-store.lock";
11const LOCK_POLL_INTERVAL: Duration = Duration::from_millis(10);
12
13/// Canonical binding between one managed profile and its model-store root.
14#[derive(Debug, Clone, PartialEq, Eq)]
15pub struct ManagedModelStore {
16    models_dir: PathBuf,
17    profile_id: ModelStoreProfileId,
18}
19
20impl ManagedModelStore {
21    pub fn default_embedding(models_dir: impl Into<PathBuf>) -> Self {
22        Self {
23            models_dir: models_dir.into(),
24            profile_id: ModelStoreProfileId::default_embedding(),
25        }
26    }
27
28    pub fn models_dir(&self) -> &Path {
29        &self.models_dir
30    }
31
32    pub fn profile_id(&self) -> &ModelStoreProfileId {
33        &self.profile_id
34    }
35
36    pub fn acquire_shared(
37        &self,
38        timeout: Duration,
39    ) -> Result<ModelStoreMutationGuard<SharedAccess>, ModelStoreLockError> {
40        ModelStoreMutationGuard::acquire_shared(&self.models_dir, self.profile_id.clone(), timeout)
41    }
42
43    pub fn acquire_exclusive(
44        &self,
45        timeout: Duration,
46    ) -> Result<ModelStoreMutationGuard<ExclusiveAccess>, ModelStoreLockError> {
47        ModelStoreMutationGuard::acquire_exclusive(
48            &self.models_dir,
49            self.profile_id.clone(),
50            timeout,
51        )
52    }
53}
54
55#[derive(Debug)]
56pub struct SharedAccess;
57
58#[derive(Debug)]
59pub struct ExclusiveAccess;
60
61#[derive(Debug)]
62pub struct ModelStoreMutationGuard<Mode> {
63    file: File,
64    lock_path: PathBuf,
65    profile_id: ModelStoreProfileId,
66    _mode: PhantomData<Mode>,
67}
68
69impl ModelStoreMutationGuard<SharedAccess> {
70    pub fn acquire_shared(
71        models_dir: &Path,
72        profile_id: ModelStoreProfileId,
73        timeout: Duration,
74    ) -> Result<Self, ModelStoreLockError> {
75        acquire(models_dir, profile_id, timeout, LockMode::Shared)
76    }
77}
78
79impl ModelStoreMutationGuard<ExclusiveAccess> {
80    pub fn acquire_exclusive(
81        models_dir: &Path,
82        profile_id: ModelStoreProfileId,
83        timeout: Duration,
84    ) -> Result<Self, ModelStoreLockError> {
85        acquire(models_dir, profile_id, timeout, LockMode::Exclusive)
86    }
87}
88
89impl<Mode> ModelStoreMutationGuard<Mode> {
90    pub fn profile_id(&self) -> &ModelStoreProfileId {
91        &self.profile_id
92    }
93
94    pub fn lock_path(&self) -> &Path {
95        &self.lock_path
96    }
97}
98
99impl<Mode> Drop for ModelStoreMutationGuard<Mode> {
100    fn drop(&mut self) {
101        #[cfg(any(unix, windows))]
102        {
103            let _ = fs4::FileExt::unlock(&self.file);
104        }
105    }
106}
107
108#[derive(Debug)]
109pub enum ModelStoreLockError {
110    Io(std::io::Error),
111    Timeout,
112    UnsupportedTarget,
113}
114
115impl fmt::Display for ModelStoreLockError {
116    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
117        match self {
118            Self::Io(error) => write!(formatter, "model-store lock I/O failed: {error}"),
119            Self::Timeout => formatter.write_str("model-store lock acquisition timed out"),
120            Self::UnsupportedTarget => {
121                formatter.write_str("model-store locking is unsupported on this target")
122            }
123        }
124    }
125}
126
127impl std::error::Error for ModelStoreLockError {
128    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
129        match self {
130            Self::Io(error) => Some(error),
131            Self::Timeout | Self::UnsupportedTarget => None,
132        }
133    }
134}
135
136impl From<std::io::Error> for ModelStoreLockError {
137    fn from(error: std::io::Error) -> Self {
138        Self::Io(error)
139    }
140}
141
142#[derive(Debug, Clone, Copy)]
143enum LockMode {
144    Shared,
145    Exclusive,
146}
147
148fn acquire<Mode>(
149    models_dir: &Path,
150    profile_id: ModelStoreProfileId,
151    timeout: Duration,
152    mode: LockMode,
153) -> Result<ModelStoreMutationGuard<Mode>, ModelStoreLockError> {
154    #[cfg(not(any(unix, windows)))]
155    {
156        let _ = (models_dir, profile_id, timeout, mode);
157        return Err(ModelStoreLockError::UnsupportedTarget);
158    }
159
160    #[cfg(any(unix, windows))]
161    {
162        let lock_path = models_dir.join(MODEL_STORE_LOCK_FILE);
163        let file = OpenOptions::new()
164            .read(true)
165            .write(true)
166            .create(true)
167            .truncate(false)
168            .open(&lock_path)?;
169        let deadline = Instant::now()
170            .checked_add(timeout)
171            .ok_or(ModelStoreLockError::Timeout)?;
172        loop {
173            let result = match mode {
174                LockMode::Shared => fs4::FileExt::try_lock_shared(&file),
175                LockMode::Exclusive => fs4::FileExt::try_lock(&file),
176            };
177            match result {
178                Ok(()) => {
179                    return Ok(ModelStoreMutationGuard {
180                        file,
181                        lock_path,
182                        profile_id,
183                        _mode: PhantomData,
184                    });
185                }
186                Err(fs4::TryLockError::Error(error)) => {
187                    return Err(ModelStoreLockError::Io(error));
188                }
189                Err(fs4::TryLockError::WouldBlock) => {
190                    let now = Instant::now();
191                    if now >= deadline {
192                        return Err(ModelStoreLockError::Timeout);
193                    }
194                    std::thread::sleep(
195                        LOCK_POLL_INTERVAL.min(deadline.saturating_duration_since(now)),
196                    );
197                }
198            }
199        }
200    }
201}
202
203#[cfg(all(test, any(unix, windows)))]
204mod tests {
205    use super::*;
206    use std::process::{Child, Command, Stdio};
207
208    const CHILD_ACTION: &str = "ORBOK_TEST_LOCK_CHILD_ACTION";
209    const CHILD_MODELS_DIR: &str = "ORBOK_TEST_LOCK_MODELS_DIR";
210    const CHILD_MARKER: &str = "ORBOK_TEST_LOCK_MARKER";
211
212    #[test]
213    fn cross_process_lock_child() {
214        let Ok(action) = std::env::var(CHILD_ACTION) else {
215            return;
216        };
217        let models_dir = PathBuf::from(std::env::var(CHILD_MODELS_DIR).unwrap());
218        let marker = PathBuf::from(std::env::var(CHILD_MARKER).unwrap());
219        let profile = ModelStoreProfileId::default_embedding();
220        match action.as_str() {
221            "shared-once" => {
222                let _guard = ModelStoreMutationGuard::acquire_shared(
223                    &models_dir,
224                    profile,
225                    Duration::from_secs(2),
226                )
227                .unwrap();
228                std::fs::write(marker, b"locked").unwrap();
229            }
230            "expect-shared-timeout" => {
231                let result = ModelStoreMutationGuard::acquire_shared(
232                    &models_dir,
233                    profile,
234                    Duration::from_millis(100),
235                );
236                assert!(matches!(result, Err(ModelStoreLockError::Timeout)));
237            }
238            "expect-exclusive-timeout" => {
239                let result = ModelStoreMutationGuard::acquire_exclusive(
240                    &models_dir,
241                    profile,
242                    Duration::from_millis(100),
243                );
244                assert!(matches!(result, Err(ModelStoreLockError::Timeout)));
245            }
246            "exclusive-hold" => {
247                let _guard = ModelStoreMutationGuard::acquire_exclusive(
248                    &models_dir,
249                    profile,
250                    Duration::from_secs(2),
251                )
252                .unwrap();
253                std::fs::write(marker, b"locked").unwrap();
254                loop {
255                    std::thread::sleep(Duration::from_millis(50));
256                }
257            }
258            other => panic!("unknown lock child action: {other}"),
259        }
260    }
261
262    #[test]
263    fn separate_process_lock_mode_matrix_is_enforced() {
264        let temp = tempfile::tempdir().unwrap();
265        let marker = temp.path().join("child-ready");
266        let profile = ModelStoreProfileId::default_embedding();
267        let shared = ModelStoreMutationGuard::acquire_shared(
268            temp.path(),
269            profile.clone(),
270            Duration::from_secs(1),
271        )
272        .unwrap();
273        let status = spawn_child("shared-once", temp.path(), &marker)
274            .wait()
275            .unwrap();
276        assert!(status.success());
277        assert!(marker.exists());
278        let status = spawn_child("expect-exclusive-timeout", temp.path(), &marker)
279            .wait()
280            .unwrap();
281        assert!(status.success());
282        drop(shared);
283
284        let exclusive = ModelStoreMutationGuard::acquire_exclusive(
285            temp.path(),
286            profile,
287            Duration::from_secs(1),
288        )
289        .unwrap();
290        let status = spawn_child("expect-shared-timeout", temp.path(), &marker)
291            .wait()
292            .unwrap();
293        assert!(status.success());
294        let status = spawn_child("expect-exclusive-timeout", temp.path(), &marker)
295            .wait()
296            .unwrap();
297        assert!(status.success());
298        assert!(exclusive.lock_path().exists());
299    }
300
301    #[test]
302    fn crashed_process_releases_lock_without_deleting_lock_file() {
303        let temp = tempfile::tempdir().unwrap();
304        let marker = temp.path().join("child-ready");
305        let mut child = spawn_child("exclusive-hold", temp.path(), &marker);
306        wait_for_marker(&mut child, &marker);
307        child.kill().unwrap();
308        child.wait().unwrap();
309
310        let guard = ModelStoreMutationGuard::acquire_exclusive(
311            temp.path(),
312            ModelStoreProfileId::default_embedding(),
313            Duration::from_secs(1),
314        )
315        .unwrap();
316        assert!(guard.lock_path().exists());
317        drop(guard);
318        assert!(temp.path().join(MODEL_STORE_LOCK_FILE).exists());
319    }
320
321    fn spawn_child(action: &str, models_dir: &Path, marker: &Path) -> Child {
322        Command::new(std::env::current_exe().unwrap())
323            .args([
324                "--exact",
325                "model_store::tests::cross_process_lock_child",
326                "--nocapture",
327            ])
328            .env(CHILD_ACTION, action)
329            .env(CHILD_MODELS_DIR, models_dir)
330            .env(CHILD_MARKER, marker)
331            .stdin(Stdio::null())
332            .stdout(Stdio::null())
333            .stderr(Stdio::null())
334            .spawn()
335            .unwrap()
336    }
337
338    fn wait_for_marker(child: &mut Child, marker: &Path) {
339        let deadline = Instant::now() + Duration::from_secs(5);
340        while !marker.exists() {
341            assert!(Instant::now() < deadline, "child did not acquire lock");
342            assert!(child.try_wait().unwrap().is_none(), "child exited early");
343            std::thread::sleep(Duration::from_millis(10));
344        }
345    }
346}