Skip to content

Commit 41172d2

Browse files
authored
Merge pull request #3 from CCL-KULeuven/development
release candidate 2.0.0
2 parents e4a91b4 + d8aa041 commit 41172d2

32 files changed

Lines changed: 641 additions & 151 deletions

.env

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
APP_VERSION=release
1+
APP_VERSION=2.0.0
22

33
# TODO: set the env in a more configurable way.
4-
CALLBACK_SERVER=http://server:8010/internal/jobs
4+
CALLBACK_SERVER=http://server:8010/internal/jobs

.gitmodules

Lines changed: 0 additions & 3 deletions
This file was deleted.

base/Dockerfile

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,11 @@
1-
FROM python:3.10-slim-bookworm
2-
ENV LC_ALL C.UTF-8
3-
ENV LANG C.UTF-8
1+
FROM python:3.11-slim-bookworm
42

3+
# Install requirements
54
COPY requirements.txt ./
65
RUN pip install --no-cache-dir -r requirements.txt \
76
&& mkdir input status output process
7+
8+
# copy python source
89
COPY --link . ./
910

1011
# Optionally set a callback server

base/requirements.txt

Lines changed: 6 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
1-
bottle==0.12.25
2-
bottle-log==1.0.0
3-
certifi==2023.7.22
4-
charset-normalizer==3.3.0
5-
idna==3.4
6-
requests==2.31.0
7-
urllib3==2.2.1
1+
bottle==0.13.2
2+
certifi==2024.8.30
3+
charset-normalizer==3.4.0
4+
idna==3.10
5+
requests==2.32.3
6+
urllib3==2.2.3

base/statuslogger.py

Lines changed: 106 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,9 @@
2525
import sys
2626
import logging
2727
from typing import Any, Optional
28+
import pathlib
29+
import time
30+
import fcntl
2831

2932
STATUS_FOLDER = "status"
3033
PROCESS_FOLDER = "process"
@@ -33,6 +36,62 @@
3336
logging.basicConfig(stream=sys.stdout, format=log_format, level=logging.INFO)
3437

3538

39+
class FileMutex:
40+
"""
41+
A mutex for file access. First acquires a lock on a lock file named original_file.lock, creating it if needed.
42+
Then opens the original file. When the mutex is released, the lock file is removed in an attempt to clean up.
43+
"""
44+
45+
def __init__(self, file_path: str, timeout: int = 5):
46+
"""
47+
Create a new mutex for the file_path. timeout is the maximum time to wait for the lock.
48+
"""
49+
self.timeout = timeout
50+
# Paths
51+
self.file_path = file_path
52+
self._lock_path = file_path + ".lock"
53+
# Files
54+
self._lock = None
55+
self.file = None
56+
57+
def acquire(self, file_mode: str) -> None:
58+
"""
59+
Acquire the lock and open the file in file_mode, once the lock is acquired. Sets self.file.
60+
"""
61+
start_time = time.time()
62+
# Try to acquire the lock, if it fails, wait for a bit and try again.
63+
while True:
64+
try:
65+
self._lock = open(self._lock_path, "a+", encoding="utf-8")
66+
fcntl.flock(self._lock, fcntl.LOCK_EX)
67+
break # Acquired!
68+
except (IOError, OSError):
69+
if time.time() - start_time > self.timeout:
70+
raise TimeoutError(
71+
"Timeout occurred while trying to acquire the lock."
72+
)
73+
time.sleep(0.1)
74+
# Open the file after acquiring the lock.
75+
self.file = open(self.file_path, file_mode, encoding="utf-8")
76+
77+
def release(self) -> None:
78+
"""
79+
Release the lock and close the file. Try to remove the lock file.
80+
"""
81+
if self.file:
82+
self.file.close()
83+
self.file = None
84+
85+
if self._lock:
86+
fcntl.flock(self._lock, fcntl.LOCK_UN)
87+
self._lock.close()
88+
self._lock = None
89+
try:
90+
pathlib.Path(self._lock_path).unlink(missing_ok=True)
91+
except:
92+
pass # Well, we tried.
93+
94+
3695
class StatusLogger:
3796
"""
3897
A status object for files at the tagger. Keeps a json status that can be sent to the server.
@@ -91,40 +150,55 @@ def get_status(self) -> dict[str, Any]:
91150
"message": "File not on server",
92151
"pending": False,
93152
"busy": False,
94-
"error": False,
153+
"error": True,
95154
"finished": False,
96155
}
97-
with open(self.status_path, encoding="utf-8") as f:
98-
try:
99-
return json.load(f)
100-
except:
101-
logging.error(f"Error decoding status file { self.status_path }")
102-
return {
103-
"message": "Error decoding status file",
104-
"pending": False,
105-
"busy": False,
106-
"error": True,
107-
"finished": False,
108-
}
156+
try:
157+
mutex = FileMutex(self.status_path)
158+
mutex.acquire("r")
159+
return json.load(mutex.file)
160+
except Exception as e:
161+
return {
162+
"message": f"Could not read status file. {e}",
163+
"pending": False,
164+
"busy": False,
165+
"error": True,
166+
"finished": False,
167+
}
168+
finally:
169+
mutex.release()
109170

110171
def delete_status(self) -> None:
111172
"""
112173
Deletes the file storage associated with this status, as well as the process status if present.
113174
"""
114-
if self.exists():
115-
os.remove(self.status_path)
175+
self.delete_status_file()
116176
# We might have to remove its process status as well.
117177
process_status = ProcessStatus(self.filename)
118178
if process_status.exists():
119179
process_status.kill()
120180

181+
def delete_status_file(self) -> None:
182+
"""
183+
Delete only the status file. Used by ProcessStatus to avoid recursion.
184+
"""
185+
try:
186+
pathlib.Path(self.status_path).unlink(missing_ok=True)
187+
except:
188+
raise
189+
121190
def _dump_status(self, status: dict[str, Any]) -> None:
122191
"""
123192
Logs the current status, replacing the previous one.
124193
"""
125-
f = open(self.status_path, "w", encoding="utf-8")
126-
json.dump(status, f)
127-
f.close()
194+
try:
195+
mutex = FileMutex(self.status_path)
196+
mutex.acquire("w")
197+
json.dump(status, mutex.file)
198+
except:
199+
raise
200+
finally:
201+
mutex.release()
128202

129203
# Logging functions
130204

@@ -186,6 +260,9 @@ def get_all_statusloggers() -> list[ProcessStatus]:
186260
)
187261

188262
def __init__(self, filename: str, pid: Optional[int] = None) -> None:
263+
"""
264+
When no pid is given, we try to find the pid from the file. Otherwise, we create a new status file.
265+
"""
189266
self.filename = filename
190267
self.status_path = os.path.join(PROCESS_FOLDER, filename)
191268
if pid is not None:
@@ -194,29 +271,31 @@ def __init__(self, filename: str, pid: Optional[int] = None) -> None:
194271
pid = self.get_pid()
195272
if pid is not None:
196273
try:
197-
os.kill(pid, 0)
274+
os.kill(pid, 0) # Check if alive.
198275
except:
199276
# No process with this pid exists.
200277
# delete ourselves, otherwise the tagger thinks we are busy.
201278
self.delete_status()
202-
StatusLogger(self.filename).init("File processing ended. Retry later.")
279+
StatusLogger(self.filename).init(
280+
"File processing ended. Retry later."
281+
)
203282

204283
def get_pid(self) -> Optional[int]:
205284
"""
206-
Process ID of the current thread (i.e. mp.pool).
285+
Process ID of the current thread.
207286
"""
208-
if not self.exists():
287+
try:
288+
return self.get_status()["pid"]
289+
except:
209290
return None
210-
with open(self.status_path, encoding="utf-8") as f:
211-
status = json.load(f)
212-
return status["pid"]
213291

214292
def kill(self) -> None:
215293
"""
216-
Kill the thread (i.e. mp.pool) that is currently tagging the file.
294+
Kill the thread that is currently tagging the file.
217295
"""
218296
pid = self.get_pid()
219297
if pid is not None:
298+
print(f"Killing process {pid}")
220299
os.kill(pid, signal.SIGKILL)
221300
self.delete_status()
222301

@@ -225,6 +304,5 @@ def delete_status(self) -> None:
225304
Called when a processed is killed, or naturally ends.
226305
Removes ourselves, signifying the tagger is no longer busy.
227306
"""
228-
if self.exists():
229-
os.remove(self.status_path)
307+
self.delete_status_file()
230308
# Note that calling the super would cause recursion.

base/tagger_worker.py

Lines changed: 42 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,8 @@
1919
from typing import Optional
2020
import subprocess
2121
import requests
22+
import traceback
23+
import pathlib
2224

2325
# Local
2426
import process
@@ -28,6 +30,7 @@
2830
from process import PROCESSING_SPEED
2931

3032
CALLBACK_SERVER: str = os.getenv("CALLBACK_SERVER") or ""
33+
NUM_WORKERS = int(os.getenv("NUM_WORKERS") or 1)
3134

3235

3336
def run_pending_tasks() -> None:
@@ -38,7 +41,8 @@ def run_pending_tasks() -> None:
3841
global pool
3942

4043
# One task at a time.
41-
if StatusLogger.busy_task_exists():
44+
tasks_in_queue = pool._taskqueue.qsize()
45+
if tasks_in_queue > 0:
4246
return
4347

4448
# Start new task when not busy
@@ -52,11 +56,9 @@ def run_pending_tasks() -> None:
5256
# Extra None check for typing
5357
if (not is_pool_running(pool)) or pool is None:
5458
# Spawn pool if not running
55-
pool = mp.Pool(processes=1, initializer=process.init)
59+
pool = mp.Pool(processes=NUM_WORKERS, initializer=process.init)
5660
# Perform task at running pool
5761
pool.apply_async(process_file, args=(sl.filename,))
58-
# Only start one task at a time, so return.
59-
return
6062

6163

6264
def process_file(filename: str):
@@ -83,9 +85,9 @@ def process_file(filename: str):
8385
# Process failed, free up the pid
8486
ps.delete_status()
8587
sl.error(f"An exception occurred: {e}")
88+
print(traceback.format_exc())
8689
# copy input file to error folder if it exists
87-
if os.path.exists(in_path):
88-
sl.error("Moving input file to error folder")
90+
if os.path.isfile(in_path):
8991
os.rename(in_path, error_path)
9092
if CALLBACK_SERVER != "":
9193
sl.error("Sending error to callback server")
@@ -97,20 +99,32 @@ def tag(
9799
) -> None:
98100
"""
99101
Attempt to tag the file by the tagger with a timeout.
100-
Send the result to the server, whether sucessful or not.
101-
Also appropiately logs the status.
102+
Send the result to the server, whether successful or not.
103+
Also appropriately logs the status.
102104
"""
103105
# 300s = 5min fixed time
104106
# plus
105107
# bytes * speed variable time
106-
in_bytes_size = int(
107-
subprocess.check_output(["du", "-sb", in_path]).split()[0].decode("utf-8")
108-
)
109-
TIMEOUT = 300 + in_bytes_size + PROCESSING_SPEED
110-
sl.busy("Will process with a timeout after " + str(TIMEOUT) + " seconds")
111-
112-
# Runs the respective tagger software.
113-
@timeout(TIMEOUT, os.strerror(errno.ETIME))
108+
in_bytes_size = None
109+
while in_bytes_size is None:
110+
if os.path.isfile(in_path):
111+
try:
112+
in_bytes_size = int(
113+
subprocess.check_output(["du", "-sb", in_path])
114+
.split()[0]
115+
.decode("utf-8")
116+
)
117+
except Exception as e:
118+
print(f"Error getting file size: {e}")
119+
time.sleep(1)
120+
else:
121+
raise FileNotFoundError(f"File {in_path} not found")
122+
123+
time_out = 300 + in_bytes_size + PROCESSING_SPEED
124+
sl.busy("Will process with a timeout after " + str(time_out) + " seconds")
125+
126+
# Runs the respective tagger software synchronously.
127+
@timeout(time_out, os.strerror(errno.ETIME))
114128
def doTagging():
115129
process.process(in_path, out_path)
116130

@@ -119,11 +133,7 @@ def doTagging():
119133
# Done processing
120134
ps.delete_status() # Frees up the tagger
121135
sl.finished("Removing input file")
122-
# "try", because the task might have been cancelled and deleted in the meantime.
123-
try:
124-
os.remove(in_path)
125-
except:
126-
pass
136+
pathlib.Path(in_path).unlink(missing_ok=True)
127137

128138
sl.finished(
129139
"Finished processing %s, result has size %d"
@@ -167,8 +177,9 @@ def send_error_to_callback_server(filename: str, out_path: str, message: str) ->
167177
Send the error to the callback server and keep or delete the file based on the server response.
168178
"""
169179
url = CALLBACK_SERVER + "/error"
170-
payload = {"file_id": filename, "message": message}
171-
r = requests.post(url, data=payload)
180+
payload = {"file_id": filename}
181+
json_data = {"file_id": filename, "message": message}
182+
r = requests.post(url, json=json_data, params=payload)
172183
keep_or_delete_file(r, out_path)
173184

174185

@@ -188,12 +199,15 @@ def is_pool_running(pool: Optional[Pool]) -> bool:
188199

189200
# Pool needs to be defined after the functions it will execute.
190201
# https://stackoverflow.com/questions/41385708/multiprocessing-example-giving-attributeerror#comment101561695_42383397
191-
pool: Optional[Pool] = mp.Pool(processes=1, initializer=process.init)
192-
202+
pool = None
193203

194-
# It is ugly, but it is also used here:
195-
# https://pypi.org/project/schedule/
196204
if __name__ == "__main__":
205+
# Can't use fork with the gpu.
206+
mp.set_start_method("spawn", force=True)
207+
pool = mp.Pool(processes=NUM_WORKERS, initializer=process.init)
208+
209+
# It is ugly, but it is also used here:
210+
# https://pypi.org/project/schedule/
197211
while True:
198212
run_pending_tasks()
199-
time.sleep(1)
213+
time.sleep(1)

0 commit comments

Comments
 (0)