Skip to content

Commit bfd9409

Browse files
authored
add FileStore & fix passing kwargs to sqlalchemy (#89)
* add file store - add FileStore - add with_lock * update notes, bump version * add warning for lifecycle tasks - fixes py3.9 - add more documentation regarding lifecycle tasks
1 parent 1cb909f commit bfd9409

16 files changed

Lines changed: 616 additions & 26 deletions

asyncz/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
__version__ = "0.13.0"
1+
__version__ = "0.13.1"

asyncz/file_locking.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,11 @@
1717
... f.write('Edgy')
1818
"""
1919

20+
from __future__ import annotations
21+
2022
import os
23+
from collections.abc import Generator
24+
from contextlib import contextmanager
2125
from typing import IO, Any
2226

2327
__all__ = ("LOCK_EX", "LOCK_SH", "LOCK_NB", "lock", "unlock")
@@ -114,3 +118,10 @@ def lock(f: IO, flags: int) -> bool:
114118
def unlock(f: IO) -> bool:
115119
fcntl.flock(_fd(f), fcntl.LOCK_UN)
116120
return True
121+
122+
123+
@contextmanager
124+
def with_lock(f: IO, flags: int) -> Generator[IO, None, None]:
125+
lock(f, flags)
126+
yield f
127+
unlock(f)

asyncz/schedulers/base.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1138,7 +1138,7 @@ def _process_tasks_of_store(
11381138
due_tasks: list[TaskType] = store.get_due_tasks(now)
11391139
except Exception as e:
11401140
self.loggers[self.logger_name].warning(
1141-
f"Error getting due tasks from the store {store_alias}: {e}."
1141+
f'Error getting due tasks from the store "{store_alias}": {e}.'
11421142
)
11431143
retry_wakeup_time = now + timedelta(seconds=self.store_retry_interval)
11441144
if not next_wakeup_time or next_wakeup_time > retry_wakeup_time:

asyncz/schedulers/defaults.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919

2020
stores: dict[str, str] = {
2121
"memory": "asyncz.stores.memory:MemoryStore",
22+
"file": "asyncz.stores.file:FileStore",
2223
"mongodb": "asyncz.stores.mongo:MongoDBStore",
2324
"redis": "asyncz.stores.redis:RedisStore",
2425
"sqlalchemy": "asyncz.stores.sqlalchemy:SQLAlchemyStore",

asyncz/stores/file.py

Lines changed: 178 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,178 @@
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})>"

asyncz/stores/sqlalchemy.py

Lines changed: 7 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@ def __init__(
4141
super().__init__(**kwargs)
4242
self.pickle_protocol = pickle_protocol
4343
if isinstance(database, str):
44-
database = sqlalchemy.create_engine(database)
44+
database = sqlalchemy.create_engine(database, **kwargs)
4545
if not database:
4646
raise ValueError("database must not be empty or None")
4747
self.engine: sqlalchemy.Engine = database
@@ -63,6 +63,10 @@ def start(self, scheduler: Any, alias: str) -> None:
6363
super().start(scheduler, alias)
6464
self.metadata.create_all(self.engine)
6565

66+
def shutdown(self) -> None:
67+
self.engine.dispose()
68+
super().shutdown()
69+
6670
def lookup_task(self, task_id: str) -> Optional[TaskType]:
6771
tasks = self.get_tasks(self.table.c.id == task_id, limit=1)
6872
return tasks[0] if tasks else None
@@ -82,7 +86,7 @@ def get_due_tasks(self, now: datetime) -> list[TaskType]:
8286
def get_tasks(self, conditions: Any = None, limit: int = 0) -> list[TaskType]:
8387
tasks: list[TaskType] = []
8488
failed_task_ids = []
85-
stmt = self.table.select().order_by(self.table.c.next_run_time.asc())
89+
stmt = self.table.select().order_by(self.table.c.next_run_time.asc().nullslast())
8690
if conditions is not None:
8791
stmt = stmt.where(conditions)
8892

@@ -119,9 +123,7 @@ def get_next_run_time(self) -> Optional[datetime]:
119123
return utc_timestamp_to_datetime(row.next_run_time) if row else None
120124

121125
def get_all_tasks(self) -> list[TaskType]:
122-
tasks = self.get_tasks()
123-
self.fix_paused_tasks(tasks)
124-
return tasks
126+
return self.get_tasks()
125127

126128
def add_task(self, task: TaskType) -> None:
127129
data = {
@@ -168,9 +170,5 @@ def remove_all_tasks(self) -> None:
168170
with self.engine.begin() as conn:
169171
conn.execute(self.table.delete())
170172

171-
def shutdown(self) -> None:
172-
self.engine.dispose()
173-
super().shutdown()
174-
175173
def __repr__(self) -> str:
176174
return f"<{self.__class__.__name__} (database={self.engine.url})>"

docs/release-notes.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,17 @@
11
# Release Notes
22

3+
4+
## 0.13.1
5+
6+
### Added
7+
8+
- `FileStore` was added (simple synchronization via files in a directory).
9+
- `with_lock` was added to `asyncz.file_locking`.
10+
11+
### Fixed
12+
13+
- SQLAlchemyStore didn't pass extra arguments to create_engine.
14+
315
## 0.13.0
416

517
### Added

docs/schedulers.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -94,7 +94,7 @@ The third option is by starting the scheduler and use the `setup` method.
9494
### Multi-Proccessing mode
9595

9696
Asyncz schedulers have an optional multiprocessing mode. It can be activated by setting the
97-
`lock_path` option to e.g. `"/tmp/asyncz_{store}_{ppid}.pid"`
97+
`lock_path` option to e.g. `"/tmp/asyncz_{store}_{pgrp}.lock"`
9898

9999
This defines a per-store process lock via a file.
100100

docs/stores.md

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,38 @@ stateless tasks or tasks that do not require some sort of cache.
3737
from asyncz.stores.memory import MemoryStore
3838
```
3939

40+
## FileStore
41+
42+
This is also a basic store which is always available. It supports the task synchronization
43+
between multiple processes via task files in a directory.
44+
It is not fast and its security relies solely on file permissions and encryption.
45+
46+
!!! Warning
47+
People who can inject a file in the directory,
48+
will be able to inject code. Except if you use encryption.
49+
50+
**Store Alias** - `file`
51+
52+
### Parameters
53+
54+
- **directory** - The directory to use. Should be well protected.
55+
- **suffix** - The suffix of task files. Files with other suffixes are ignored.
56+
57+
<sup>Default: `".task"`</sup>
58+
59+
- **mode** - The mode of the directory.
60+
61+
<sup>Default: `0o700`</sup>
62+
63+
- **cleanup_directory** - Shall the directory be deleted after shutdown? This will cleanup old tasks.
64+
65+
<sup>Default: `False`</sup>
66+
67+
- **pickle_protocol**- Pickle protocol level to use (for serialization), defaults to the
68+
highest available.
69+
70+
<sup>Default: `pickle.HIGHEST_PROTOCOL`</sup>
71+
4072
## RedisStore
4173

4274
**Store Alias** - `redis`
@@ -146,6 +178,9 @@ available.
146178

147179
<sup>Default: `pickle.HIGHEST_PROTOCOL`</sup>
148180

181+
Other kwargs are passed to sqlalchemy.create_engine.
182+
183+
149184
## Custom store
150185

151186
```python

0 commit comments

Comments
 (0)