1use 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#[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}