|
| 1 | +from __future__ import annotations |
| 2 | + |
| 3 | +import glob |
| 4 | +import os |
| 5 | +import pickle |
| 6 | +import shutil |
| 7 | +from contextlib import suppress |
| 8 | +from datetime import datetime |
| 9 | +from pathlib import Path |
| 10 | +from typing import TYPE_CHECKING, Any, Optional, Union, cast |
| 11 | + |
| 12 | +from asyncz.exceptions import ConflictIdError, TaskLookupError |
| 13 | +from asyncz.file_locking import LOCK_EX, LOCK_SH, with_lock |
| 14 | +from asyncz.stores.base import BaseStore |
| 15 | +from asyncz.tasks import Task |
| 16 | +from asyncz.tasks.types import TaskType |
| 17 | + |
| 18 | +if TYPE_CHECKING: |
| 19 | + from asyncz.schedulers.types import SchedulerType |
| 20 | + |
| 21 | + |
| 22 | +class FileStore(BaseStore): |
| 23 | + """ |
| 24 | + Stores tasks via sqlalchemy in a database. |
| 25 | +
|
| 26 | + Args: |
| 27 | + directory - The directory to store the tasks. String or Path. |
| 28 | + suffix - The task suffix. |
| 29 | + pickle_protocol - Pickle protocol level to use (for serialization), defaults to the |
| 30 | + highest available. |
| 31 | + """ |
| 32 | + |
| 33 | + forbidden_characters: set[str] = {"/", "\\", "\0", ":"} |
| 34 | + |
| 35 | + def __init__( |
| 36 | + self, |
| 37 | + directory: Union[str, os.PathLike], |
| 38 | + suffix: str = ".task", |
| 39 | + mode: int = 0o700, |
| 40 | + cleanup_directory: bool = False, |
| 41 | + pickle_protocol: Optional[int] = pickle.HIGHEST_PROTOCOL, |
| 42 | + **kwargs: Any, |
| 43 | + ) -> None: |
| 44 | + super().__init__(**kwargs) |
| 45 | + self.pickle_protocol = pickle_protocol |
| 46 | + self.directory = Path(directory) |
| 47 | + self.mode = mode |
| 48 | + self.cleanup_directory = cleanup_directory |
| 49 | + self.suffix = suffix |
| 50 | + |
| 51 | + def check_task_id(self, task_id: str | None) -> None: |
| 52 | + if task_id is None: |
| 53 | + raise RuntimeError("Task id is None") |
| 54 | + if task_id.startswith("."): |
| 55 | + raise RuntimeError(f'Invalid character in task id: "{task_id}".') |
| 56 | + for char in task_id: |
| 57 | + if char in self.forbidden_characters: |
| 58 | + raise RuntimeError(f'Invalid character in task id: "{task_id}".') |
| 59 | + |
| 60 | + def start(self, scheduler: Any, alias: str) -> None: |
| 61 | + """ |
| 62 | + When starting omits from the index any documents that lack next_run_time field. |
| 63 | + """ |
| 64 | + super().start(scheduler, alias) |
| 65 | + self.directory.mkdir(self.mode, parents=True, exist_ok=True) |
| 66 | + if not self.directory.is_dir(): |
| 67 | + raise RuntimeError("Not a directory.") |
| 68 | + |
| 69 | + def shutdown(self) -> None: |
| 70 | + if self.cleanup_directory: |
| 71 | + shutil.rmtree(self.directory, ignore_errors=True) |
| 72 | + super().shutdown() |
| 73 | + |
| 74 | + def lookup_task(self, task_id: str) -> Optional[TaskType]: |
| 75 | + self.check_task_id(task_id) |
| 76 | + task_path = self.directory / f"{task_id}{self.suffix}" |
| 77 | + try: |
| 78 | + with open(task_path, "rb") as read_ob, with_lock(read_ob, LOCK_SH): |
| 79 | + task = self.rebuild_task(read_ob.read()) |
| 80 | + except Exception: |
| 81 | + task_path.unlink(missing_ok=True) |
| 82 | + task = None |
| 83 | + return task |
| 84 | + |
| 85 | + def rebuild_task(self, state: Any) -> TaskType: |
| 86 | + state = pickle.loads(self.conditional_decrypt(state)) |
| 87 | + task = Task.__new__(Task) |
| 88 | + task.__setstate__(state) |
| 89 | + task.scheduler = cast("SchedulerType", self.scheduler) |
| 90 | + task.store_alias = self.alias |
| 91 | + return task |
| 92 | + |
| 93 | + def get_due_tasks(self, now: datetime) -> list[TaskType]: |
| 94 | + return [ |
| 95 | + task |
| 96 | + for task in self.get_all_tasks() |
| 97 | + if task.next_run_time is not None and task.next_run_time <= now |
| 98 | + ] |
| 99 | + |
| 100 | + def get_tasks(self) -> list[TaskType]: |
| 101 | + tasks: list[tuple[TaskType, os.stat_result]] = [] |
| 102 | + with os.scandir(self.directory) as scanner: |
| 103 | + for entry in scanner: |
| 104 | + if not entry.name.endswith(self.suffix) or not entry.is_file(): |
| 105 | + continue |
| 106 | + try: |
| 107 | + with open(entry.path, "rb") as read_ob, with_lock(read_ob, LOCK_SH): |
| 108 | + task = self.rebuild_task(read_ob.read()) |
| 109 | + tasks.append((task, entry.stat())) |
| 110 | + except Exception: |
| 111 | + with suppress(FileNotFoundError): |
| 112 | + os.unlink(entry.path) |
| 113 | + return [ |
| 114 | + task |
| 115 | + for task, _ in sorted( |
| 116 | + tasks, |
| 117 | + key=lambda task_stat: ( |
| 118 | + int(task_stat[0].next_run_time is None), |
| 119 | + task_stat[0].next_run_time, |
| 120 | + # sort for task creation not update |
| 121 | + task_stat[1].st_ctime, |
| 122 | + ), |
| 123 | + ) |
| 124 | + ] |
| 125 | + |
| 126 | + def get_next_run_time(self) -> Optional[datetime]: |
| 127 | + next_run_time: datetime | None = None |
| 128 | + for task in self.get_all_tasks(): |
| 129 | + if task.next_run_time is None: |
| 130 | + break |
| 131 | + if next_run_time is None or next_run_time >= task.next_run_time: |
| 132 | + next_run_time = task.next_run_time |
| 133 | + return next_run_time |
| 134 | + |
| 135 | + def get_all_tasks(self) -> list[TaskType]: |
| 136 | + return self.get_tasks() |
| 137 | + |
| 138 | + def add_task(self, task: TaskType) -> None: |
| 139 | + self.check_task_id(task.id) |
| 140 | + task_path = self.directory / f"{task.id}{self.suffix}" |
| 141 | + try: |
| 142 | + with task_path.open("xb") as write_ob, with_lock(write_ob, LOCK_EX): |
| 143 | + write_ob.write( |
| 144 | + self.conditional_encrypt( |
| 145 | + pickle.dumps(task.__getstate__(), self.pickle_protocol) |
| 146 | + ) |
| 147 | + ) |
| 148 | + except FileExistsError: |
| 149 | + raise ConflictIdError(task.id) from None |
| 150 | + |
| 151 | + def update_task(self, task: TaskType) -> None: |
| 152 | + self.check_task_id(task.id) |
| 153 | + task_path = self.directory / f"{task.id}{self.suffix}" |
| 154 | + try: |
| 155 | + with task_path.open("r+b") as write_ob, with_lock(write_ob, LOCK_EX): |
| 156 | + write_ob.truncate() |
| 157 | + write_ob.write( |
| 158 | + self.conditional_encrypt( |
| 159 | + pickle.dumps(task.__getstate__(), self.pickle_protocol) |
| 160 | + ) |
| 161 | + ) |
| 162 | + except FileNotFoundError: |
| 163 | + raise TaskLookupError(task.id) from None |
| 164 | + |
| 165 | + def delete_task(self, task_id: str) -> None: |
| 166 | + self.check_task_id(task_id) |
| 167 | + task_path = self.directory / f"{task_id}{self.suffix}" |
| 168 | + try: |
| 169 | + task_path.unlink(missing_ok=False) |
| 170 | + except FileNotFoundError: |
| 171 | + raise TaskLookupError(task_id) from None |
| 172 | + |
| 173 | + def remove_all_tasks(self) -> None: |
| 174 | + for task_path in self.directory.glob(f"*{glob.escape(self.suffix)}"): |
| 175 | + task_path.unlink(missing_ok=True) |
| 176 | + |
| 177 | + def __repr__(self) -> str: |
| 178 | + return f"<{self.__class__.__name__} (directory={self.directory})>" |
0 commit comments