1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
|
from os import makedirs, path, PathLike, listdir, rename, urandom, fsync
from dataclasses import dataclass
import time
from typing import Generator
from io import BufferedReader, BufferedWriter
from hashlib import md5
@dataclass
class FemtoTask:
id: str
data: bytes
class FemtoQueue:
RESERVED_NAMES = [
"creating",
"pending",
"done",
"failed",
"scheduled",
]
def __init__(
self,
data_dir: PathLike | str,
node_id: str,
timeout_stale_ms: int = 30_000,
sync_after_write: bool = False,
):
"""
Construct a FemtoQueue client.
Parameters
----------
data_dir : os.PathLike or str
Directory where data files are persisted
node_id : str
Stable identifier for this instance
timeout_stale_ms : int, default 30_000
Time in milliseconds after which clients can release in-progress tasks back to pending.
sync_after_write : bool, default False
Run fsync() after writes to ensure data is synced to disk. Useful if you're worried about sudden power loss,
but note that integrity checks will discard invalid writes even when sync_after_write is off.
Setting sync_after_write on will slow down writes. Not necessary on certain file systems, such as ZFS.
"""
assert node_id not in self.RESERVED_NAMES
self.node_id = node_id
assert timeout_stale_ms > 0
self.timeout_stale_ms = timeout_stale_ms
self.latest_stale_check_ts_us: int | None = None
self.sync_after_write = sync_after_write
self.todo_cache: Generator[str, None, None] | None = None
self.data_dir = data_dir
self.dir_creating = path.join(data_dir, "creating")
self.dir_pending = path.join(data_dir, "pending")
self.dir_in_progress = path.join(data_dir, node_id)
self.dir_done = path.join(data_dir, "done")
self.dir_failed = path.join(data_dir, "failed")
self.dir_scheduled = path.join(data_dir, "scheduled")
makedirs(self.data_dir, exist_ok=True)
makedirs(self.dir_creating, exist_ok=True)
makedirs(self.dir_pending, exist_ok=True)
makedirs(self.dir_in_progress, exist_ok=True)
makedirs(self.dir_done, exist_ok=True)
makedirs(self.dir_failed, exist_ok=True)
makedirs(self.dir_scheduled, exist_ok=True)
self.monotonic_time_started_us: int = int(time.monotonic() * 1_000_000)
self.reference_time_us: int = self._resolve_reference_time_us()
self.interval_check_scheduled_us = 1_000_000
self.latest_scheduled_check_ts_us: int | None = None
def _resolve_reference_time_us(self) -> int:
tasks_pending = listdir(self.dir_pending)
newest_ts_us = None
for task_name in tasks_pending:
ts_us = int(task_name.split("_")[0])
if newest_ts_us is None or ts_us > newest_ts_us:
newest_ts_us = ts_us
if newest_ts_us is None:
return int(time.time() * 1_000_000)
else:
return newest_ts_us
def _monotonic_time_now_us(self) -> int:
return (
self.reference_time_us
+ int(time.monotonic() * 1_000_000)
- self.monotonic_time_started_us
)
def _gen_increasing_uuid(self, time_us: int) -> str:
rand_bytes = urandom(8)
return f"{str(time_us)}_{rand_bytes.hex}"
def _write_v1(self, f: BufferedWriter, data: bytes):
version = (1).to_bytes(1, "little") # 1 byte
padding = (0).to_bytes(7, "little") # 7 bytes
hash = md5(data).digest()
header = version + padding + hash
f.write(header)
f.write(data)
def _read_v1(self, f: BufferedReader) -> bytes | None:
version_bytes = f.read(1)
if version_bytes is None or len(version_bytes) == 0:
return None
version = version_bytes[0]
if version != 1:
return None
f.seek(8)
stored_hash = f.read(16)
if stored_hash is None or len(stored_hash) != 16:
return None
data = f.read()
if data is None:
return None
data_hash = md5(data).digest()
if data_hash != stored_hash:
return None
return data
def schedule(self, data: bytes, time_us: int) -> str:
"""
Schedule a new task to be pushed to the queue at the given timestamp.
Upon calling `pop`, scheduled tasks whose timestamp is in the past (based on time-of-day clock, i.e. `time.time()`)
will be moved to the end of the queue. Note that these events will get a different ID when moved.
Parameters
----------
data : bytes
A bytes object representing the task. For JSON, you can use `json.dumps(obj).encode("utf-8")`.
time_us : int
A timestamp in microseconds.
Returns
-------
id : str
The task identifier, i.e. the file name.
"""
id = self._gen_increasing_uuid(time_us)
creating_path = path.join(self.dir_creating, id)
scheduled_path = path.join(self.dir_scheduled, id)
with open(creating_path, "wb") as f:
self._write_v1(f, data)
if self.sync_after_write:
fsync(f)
rename(creating_path, scheduled_path)
return id
def push(self, data: bytes) -> str:
"""
Push a new task into the queue.
Parameters
----------
data : bytes
A bytes object representing the task. For JSON, you can use `json.dumps(obj).encode("utf-8")`.
Returns
-------
id : str
The task identifier, i.e. the file name.
"""
m_now_us = self._monotonic_time_now_us()
id = self._gen_increasing_uuid(m_now_us)
creating_path = path.join(self.dir_creating, id)
pending_path = path.join(self.dir_pending, id)
with open(creating_path, "wb") as f:
self._write_v1(f, data)
if self.sync_after_write:
fsync(f)
rename(creating_path, pending_path)
return id
def _release_stale_tasks(self, m_now_us: int):
# Only run this every `timeout_stale_ms` milliseconds because iterating
# through all tasks is slow
timeout_us = self.timeout_stale_ms * 1_000
if (
self.latest_stale_check_ts_us is not None
and m_now_us - self.latest_stale_check_ts_us < timeout_us
):
return
self.latest_stale_check_ts_us = m_now_us
for dir_name in listdir(self.data_dir):
full_dir_path = path.join(self.data_dir, dir_name)
# Skip non-directories and reserved names
if not path.isdir(full_dir_path):
continue
if dir_name in self.RESERVED_NAMES + [self.node_id]:
continue
# Check tasks in this node's in-progress directory
for task_file in listdir(full_dir_path):
task_path = path.join(full_dir_path, task_file)
modified_time_us = int(task_file.split("_")[0])
if m_now_us - modified_time_us < timeout_us:
continue
try:
pending_path = path.join(self.dir_pending, task_file)
rename(task_path, pending_path)
except FileNotFoundError:
continue # Task may have been moved by another node
def _trigger_scheduled_tasks(self, m_now_us: int):
if (
self.latest_scheduled_check_ts_us is not None
and m_now_us - self.latest_scheduled_check_ts_us
< self.interval_check_scheduled_us
):
return
self.latest_scheduled_check_ts_us = m_now_us
w_now_us = int(time.time() * 1_000_000) # wall clock time
for task_file in listdir(self.dir_scheduled):
scheduled_time_us = int(task_file.split("_")[0])
if scheduled_time_us < w_now_us:
scheduled_path = path.join(self.dir_scheduled, task_file)
id = self._gen_increasing_uuid(m_now_us)
try:
pending_path = path.join(self.dir_pending, id)
rename(scheduled_path, pending_path)
except FileNotFoundError:
continue # Task may have been moved by another node
def _pop_task_path(self) -> str | None:
# Check cache
if self.todo_cache:
try:
return next(self.todo_cache)
except StopIteration:
pass
# If cache empty, then check assigned tasks in progress (aborted)
self.todo_cache = (
path.join(self.dir_in_progress, x)
for x in sorted(listdir(self.dir_in_progress))
)
try:
return next(self.todo_cache)
except StopIteration:
pass
# Then check pending tasks
self.todo_cache = (
path.join(self.dir_pending, x) for x in sorted(listdir(self.dir_pending))
)
try:
return next(self.todo_cache)
except StopIteration:
pass
return None
def pop(self) -> FemtoTask | None:
"""
Pop the oldest available task from the queue, or `None` if empty.
If previous task processing was aborted (process was terminated unexpectedly), this method will return that incomplete task,
effectively providing retry capability. If not, this will return the oldest task from "pending" state, if one exists.
Returns
-------
task : FemtoTask or None
"""
m_now_us = self._monotonic_time_now_us()
self._release_stale_tasks(m_now_us)
self._trigger_scheduled_tasks(m_now_us)
while True:
task = self._pop_task_path()
if task is None:
return None
id = path.basename(task)
in_progress_path = path.join(self.dir_in_progress, id)
try:
rename(task, in_progress_path)
except FileNotFoundError:
# If another node grabbed the task, just get another one
continue
with open(in_progress_path, "rb") as f:
data = self._read_v1(f)
if data is None:
# Data was corrupted, skip
continue
return FemtoTask(id=id, data=data)
def done(self, task: FemtoTask):
"""
Move a task to "done" status.
Parameters
----------
task : FemtoTask
The in-progress task instance.
"""
in_progress_path = path.join(self.dir_in_progress, task.id)
done_path = path.join(self.dir_done, task.id)
try:
rename(in_progress_path, done_path)
except FileNotFoundError as e:
raise Exception(
f"Tried to complete a task that is not in progress, id={task.id}"
) from e
def fail(self, task: FemtoTask):
"""
Move a task to "failed" status.
Parameters
----------
task : FemtoTask
The in-progress task instance.
"""
in_progress_path = path.join(self.dir_in_progress, task.id)
failed_path = path.join(self.dir_failed, task.id)
try:
rename(in_progress_path, failed_path)
except FileNotFoundError as e:
raise Exception(
f"Tried to fail a task that is not in progress, id={task.id}"
) from e
|