Make scan directory manager thread safe

This commit is contained in:
Julian Stirling 2025-08-04 19:48:33 +01:00
parent 4b4256aefe
commit 9be9097246
4 changed files with 161 additions and 0 deletions

View file

@ -5,9 +5,12 @@ import os
import re
import shutil
import zipfile
import threading
from pydantic import BaseModel
from openflexure_microscope_server.utilities import requires_lock
IMG_DIR_NAME = "images"
SCAN_ZERO_PAD_DIGITS = 4
@ -43,6 +46,7 @@ class ScanDirectoryManager:
self._base_scan_dir = base_scan_dir
if not os.path.exists(self._base_scan_dir):
os.makedirs(self._base_scan_dir)
self._lock = threading.RLock()
@property
def base_dir(self) -> str:
@ -67,6 +71,7 @@ class ScanDirectoryManager:
"""
return os.path.join(self._base_scan_dir, scan_name, IMG_DIR_NAME)
@requires_lock
def get_file_path_from(
self, scan_name: str, filename: str, check_exists: bool = False
) -> Optional[str]:
@ -81,6 +86,7 @@ class ScanDirectoryManager:
return None
return file_path
@requires_lock
def get_file_path_from_img_dir(
self, scan_name: str, filename: str, check_exists: bool = False
) -> Optional[str]:
@ -106,10 +112,12 @@ class ScanDirectoryManager:
return self.get_file_path_from_img_dir(scan_name, stitch_fname)
@property
@requires_lock
def all_scans(self) -> list[str]:
"""Return a list of the scan names in the base directory."""
return [f.name for f in os.scandir(self._base_scan_dir) if f.is_dir()]
@requires_lock
def all_scans_info(self) -> list[ScanInfo]:
"""Return a lists of ScanInfo objects for each scan."""
all_info: list[ScanInfo] = []
@ -117,6 +125,7 @@ class ScanDirectoryManager:
all_info.append(ScanDirectory(scan_name, self.base_dir).scan_info())
return all_info
@requires_lock
def _unique_scan_name(self, scan_name: str) -> str:
"""Get the next unique scan name starting with the given name.
@ -146,6 +155,7 @@ class ScanDirectoryManager:
return f"{scan_name}_{scan_num:0{SCAN_ZERO_PAD_DIGITS}d}"
@requires_lock
def new_scan_dir(self, scan_name: str) -> "ScanDirectory":
"""Get a unique name for this scan and create a directory for it.
@ -168,10 +178,12 @@ class ScanDirectoryManager:
os.makedirs(self.img_dir_for(full_scan_name))
return ScanDirectory(full_scan_name, self.base_dir)
@requires_lock
def delete_scan(self, scan_name: str) -> None:
"""Delete a scan."""
shutil.rmtree(self.path_for(scan_name))
@requires_lock
def zip_scan(self, scan_name: str, final_version: bool = False) -> "ScanDirectory":
"""Zips any images from the scan not yet zipped, return full path to zip.
@ -181,6 +193,7 @@ class ScanDirectoryManager:
"""
return ScanDirectory(scan_name, self.base_dir).zip_files(final_version)
@requires_lock
def check_free_disk_space(self, min_space: int = 500000000) -> None:
"""Raise an exception if there is not enough free disk space to continue scanning.

View file

@ -717,6 +717,9 @@ class SmartScanThing(lt.Thing):
This is a wrapper around scan manager's delete_scan that logs to the
invocation logger id there is a problem.
"""
if self._ongoing_scan is not None and scan_name == self._ongoing_scan.name:
logger.error("Attempted to delete ongoing scan.")
return False
try:
self._scan_dir_manager.delete_scan(scan_name)
return True

View file

@ -1,14 +1,19 @@
"""Utility functions and classes."""
from typing import TypeVar, Callable, ParamSpec
import os
import re
from threading import Thread
import logging
from importlib.metadata import version
import tomllib
from functools import wraps
from pydantic import BaseModel
T = TypeVar("T")
P = ParamSpec("P")
LOGGER = logging.getLogger(__name__)
REPO_DIR = os.path.dirname(os.path.dirname(os.path.dirname(__file__)))
@ -18,6 +23,22 @@ COMMIT_REGEX = re.compile(r"[0-9a-f]{40}")
REF_REGEX = re.compile(r"^ref:\s(.*)$")
def requires_lock(method: Callable[P, T]) -> Callable[P, T]:
"""Decorate a class method so that it requires the class lock to run.
The class should have a reentrant lock with the name ``self._lock``.
"""
@wraps(method)
def wrapper(self, *args: P.args, **kwargs: P.kwargs) -> T:
if not hasattr(self, "_lock"):
raise AttributeError(f"{self.__class__.__name__} has no '_lock' attribute")
with self._lock:
return method(self, *args, **kwargs)
return wrapper
class ErrorCapturingThread(Thread):
"""Subclass of Thread that captures exceptions from the target function.