tuspyserver 4.2.3__tar.gz → 4.2.4__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: tuspyserver
3
- Version: 4.2.3
3
+ Version: 4.2.4
4
4
  Summary: A Python tus server implementation as a FastAPI router
5
5
  Author: Edi Hasaj
6
6
  Author-email: Edi Hasaj <edi.hasaj@applifyer.com>
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "tuspyserver"
3
- version = "4.2.3"
3
+ version = "4.2.4"
4
4
  description ="A Python tus server implementation as a FastAPI router"
5
5
  readme = "README.md"
6
6
  authors = [
@@ -0,0 +1,230 @@
1
+ """
2
+ File locking utilities for tus uploads.
3
+
4
+ Provides advisory file locking using fcntl (Unix) to prevent race conditions
5
+ during concurrent upload operations. This implementation matches tusd's approach
6
+ by using separate lock files stored in a .locks directory, where each lock file
7
+ contains the PID of the process holding the lock.
8
+
9
+ The lock acquisition is bounded by a timeout so a wedged shared filesystem
10
+ (e.g. flapping Azure Files / SMB) cannot hang request workers indefinitely.
11
+ If the lock directory is unwritable (EACCES / EROFS), we fall back to a
12
+ best-effort no-op lock so a single request fails fast instead of dragging
13
+ all workers down.
14
+ """
15
+ import errno
16
+ import fcntl
17
+ import logging
18
+ import os
19
+ import time
20
+ from contextlib import contextmanager
21
+ from typing import Optional
22
+
23
+ logger = logging.getLogger(__name__)
24
+
25
+
26
+ # Bounded waits for filesystem operations. Tuned for shared SMB mounts where
27
+ # operations can briefly stall during reconnects, but must never block forever.
28
+ DEFAULT_LOCK_TIMEOUT = 30.0
29
+ LOCK_POLL_INTERVAL = 0.1
30
+
31
+ _FS_UNWRITABLE_ERRNOS = {errno.EACCES, errno.EPERM, errno.EROFS, errno.ENOSPC}
32
+
33
+
34
+ class LockTimeoutError(IOError):
35
+ """Raised when a lock cannot be acquired within the configured timeout."""
36
+
37
+
38
+ class FileLock:
39
+ """
40
+ Advisory file lock using fcntl (Unix) with separate lock files.
41
+
42
+ Matches tusd's filelocker approach by creating separate .lock files
43
+ in a .locks directory. Each lock file contains the PID of the process
44
+ holding the lock, allowing for lock cleanup if a process crashes.
45
+ """
46
+
47
+ def __init__(self, file_path: str, locks_dir: Optional[str] = None):
48
+ """
49
+ Initialize a file lock.
50
+
51
+ Args:
52
+ file_path: Path to the upload file to lock
53
+ locks_dir: Directory to store lock files (defaults to {files_dir}/.locks)
54
+ """
55
+ self.file_path = file_path
56
+ self.locks_dir = locks_dir
57
+
58
+ # Derive lock file path from upload file path
59
+ if locks_dir:
60
+ lock_filename = os.path.basename(file_path) + ".lock"
61
+ self.lock_file_path = os.path.join(locks_dir, lock_filename)
62
+ else:
63
+ # Default: create .locks directory next to upload file
64
+ upload_dir = os.path.dirname(file_path)
65
+ self.locks_dir = os.path.join(upload_dir, ".locks")
66
+ lock_filename = os.path.basename(file_path) + ".lock"
67
+ self.lock_file_path = os.path.join(self.locks_dir, lock_filename)
68
+
69
+ self._lock_fd: Optional[int] = None
70
+ # Set when the underlying filesystem rejected lock-file creation.
71
+ # Release becomes a no-op so we don't crash on cleanup.
72
+ self._best_effort: bool = False
73
+
74
+ def acquire(
75
+ self,
76
+ blocking: bool = True,
77
+ timeout: float = DEFAULT_LOCK_TIMEOUT,
78
+ ) -> bool:
79
+ """
80
+ Acquire an exclusive lock on the upload file.
81
+
82
+ Creates a separate lock file and applies an exclusive lock on it.
83
+ The lock file contains the PID of the process holding the lock.
84
+ This matches tusd's filelocker approach.
85
+
86
+ Args:
87
+ blocking: If True, wait up to ``timeout`` seconds for the lock.
88
+ If False, return immediately when the lock is contended.
89
+ timeout: Max seconds to wait for the exclusive lock when
90
+ ``blocking=True``. Bounded so a wedged shared filesystem
91
+ cannot hang request workers.
92
+
93
+ Returns:
94
+ True if lock was acquired (or running in best-effort mode),
95
+ False only when ``blocking=False`` and the lock was held
96
+ elsewhere.
97
+
98
+ Raises:
99
+ LockTimeoutError: ``blocking=True`` and the lock could not be
100
+ acquired within ``timeout`` seconds.
101
+ IOError / OSError: unexpected filesystem failure.
102
+ """
103
+ # Open the lock file. If the lock directory is unwritable (EACCES on
104
+ # a flapping SMB share, read-only filesystem, ...), degrade to
105
+ # best-effort: do not block the request, just proceed without a
106
+ # cross-process lock. Single-pod correctness still holds via the
107
+ # per-process Python state above this layer; multi-pod contention on
108
+ # the same upload UUID is extremely rare in practice.
109
+ try:
110
+ os.makedirs(self.locks_dir, exist_ok=True)
111
+ self._lock_fd = os.open(
112
+ self.lock_file_path, os.O_RDWR | os.O_CREAT, 0o644
113
+ )
114
+ except OSError as e:
115
+ if e.errno in _FS_UNWRITABLE_ERRNOS:
116
+ logger.warning(
117
+ "Lock dir %s unwritable (%s); proceeding without lock",
118
+ self.locks_dir, e,
119
+ )
120
+ self._best_effort = True
121
+ self._lock_fd = None
122
+ return True
123
+ raise
124
+
125
+ # Best-effort PID write (purely informational, like tusd).
126
+ try:
127
+ os.ftruncate(self._lock_fd, 0)
128
+ os.lseek(self._lock_fd, 0, os.SEEK_SET)
129
+ os.write(self._lock_fd, str(os.getpid()).encode("utf-8"))
130
+ os.lseek(self._lock_fd, 0, os.SEEK_SET)
131
+ except OSError as e:
132
+ logger.warning(
133
+ "Failed to write PID to lock file %s: %s",
134
+ self.lock_file_path, e,
135
+ )
136
+
137
+ # Acquire exclusive lock. Use non-blocking + polling so we always have
138
+ # a hard upper bound, even on a misbehaving network filesystem.
139
+ deadline = time.monotonic() + max(timeout, 0.0) if blocking else None
140
+ while True:
141
+ try:
142
+ fcntl.flock(self._lock_fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
143
+ return True
144
+ except OSError as e:
145
+ if e.errno not in (errno.EAGAIN, errno.EWOULDBLOCK):
146
+ self._close_fd()
147
+ raise
148
+ if not blocking:
149
+ self._close_fd()
150
+ return False
151
+ if time.monotonic() >= deadline:
152
+ self._close_fd()
153
+ raise LockTimeoutError(
154
+ f"Timed out after {timeout:.1f}s waiting for lock "
155
+ f"{self.lock_file_path}"
156
+ )
157
+ time.sleep(LOCK_POLL_INTERVAL)
158
+
159
+ def _close_fd(self) -> None:
160
+ if self._lock_fd is not None:
161
+ try:
162
+ os.close(self._lock_fd)
163
+ except Exception:
164
+ pass
165
+ self._lock_fd = None
166
+
167
+ def release(self) -> None:
168
+ """Release the lock and close the file descriptor."""
169
+ if self._best_effort:
170
+ self._best_effort = False
171
+ return
172
+ if self._lock_fd is not None:
173
+ try:
174
+ fcntl.flock(self._lock_fd, fcntl.LOCK_UN)
175
+ except Exception as e:
176
+ logger.warning("Error unlocking %s: %s", self.lock_file_path, e)
177
+ self._close_fd()
178
+ try:
179
+ if os.path.exists(self.lock_file_path):
180
+ os.remove(self.lock_file_path)
181
+ except Exception as e:
182
+ logger.warning(
183
+ "Error removing lock file %s: %s",
184
+ self.lock_file_path, e,
185
+ )
186
+
187
+ def get_fd(self) -> Optional[int]:
188
+ """File descriptor for the lock file, or None when unlocked / best-effort."""
189
+ return self._lock_fd
190
+
191
+ def __enter__(self):
192
+ self.acquire(blocking=True)
193
+ return self
194
+
195
+ def __exit__(self, exc_type, exc_val, exc_tb):
196
+ self.release()
197
+
198
+
199
+ @contextmanager
200
+ def acquire_upload_lock(
201
+ upload_path: str,
202
+ locks_dir: Optional[str] = None,
203
+ blocking: bool = True,
204
+ timeout: float = DEFAULT_LOCK_TIMEOUT,
205
+ ):
206
+ """
207
+ Context manager for acquiring an upload lock.
208
+
209
+ Args:
210
+ upload_path: Path to the upload file to lock.
211
+ locks_dir: Directory to store lock files (defaults to ``{upload_dir}/.locks``).
212
+ blocking: If True, wait up to ``timeout`` seconds for the lock.
213
+ timeout: Max seconds to wait when blocking. Prevents indefinite
214
+ hangs on misbehaving shared filesystems.
215
+
216
+ Yields:
217
+ FileLock instance with the locked file descriptor.
218
+
219
+ Raises:
220
+ LockTimeoutError: Could not acquire the lock within ``timeout``.
221
+ IOError: ``blocking=False`` and the lock is held elsewhere.
222
+ """
223
+ lock = FileLock(upload_path, locks_dir=locks_dir)
224
+ try:
225
+ acquired = lock.acquire(blocking=blocking, timeout=timeout)
226
+ if not acquired:
227
+ raise IOError("Could not acquire lock")
228
+ yield lock
229
+ finally:
230
+ lock.release()
@@ -15,7 +15,7 @@ from fastapi import Depends, HTTPException, Path, Request
15
15
  from starlette.requests import ClientDisconnect
16
16
 
17
17
  from tuspyserver.file import TusUploadFile
18
- from tuspyserver.lock import acquire_upload_lock
18
+ from tuspyserver.lock import LockTimeoutError, acquire_upload_lock
19
19
 
20
20
 
21
21
  def make_request_chunks_dep(options: TusRouterOptions):
@@ -178,6 +178,10 @@ def make_request_chunks_dep(options: TusRouterOptions):
178
178
  new_params.upload_part += 1
179
179
  # save updated params
180
180
  file.info = new_params
181
+ except LockTimeoutError as e:
182
+ # Shared lock dir is contended or wedged. Fail fast so the worker
183
+ # is freed instead of piling up behind a stuck filesystem.
184
+ raise HTTPException(status_code=503, detail=str(e))
181
185
  except (IOError, OSError) as e:
182
186
  # Lock acquisition or file operation failed
183
187
  raise HTTPException(status_code=500, detail=f"Internal server error: {str(e)}")
@@ -1,185 +0,0 @@
1
- """
2
- File locking utilities for tus uploads.
3
-
4
- Provides advisory file locking using fcntl (Unix) to prevent race conditions
5
- during concurrent upload operations. This implementation matches tusd's approach
6
- by using separate lock files stored in a .locks directory, where each lock file
7
- contains the PID of the process holding the lock.
8
- """
9
- import fcntl
10
- import logging
11
- import os
12
- from contextlib import contextmanager
13
- from pathlib import Path
14
- from typing import Optional
15
-
16
- logger = logging.getLogger(__name__)
17
-
18
-
19
- class FileLock:
20
- """
21
- Advisory file lock using fcntl (Unix) with separate lock files.
22
-
23
- Matches tusd's filelocker approach by creating separate .lock files
24
- in a .locks directory. Each lock file contains the PID of the process
25
- holding the lock, allowing for lock cleanup if a process crashes.
26
- """
27
-
28
- def __init__(self, file_path: str, locks_dir: Optional[str] = None):
29
- """
30
- Initialize a file lock.
31
-
32
- Args:
33
- file_path: Path to the upload file to lock
34
- locks_dir: Directory to store lock files (defaults to {files_dir}/.locks)
35
- """
36
- self.file_path = file_path
37
- self.locks_dir = locks_dir
38
-
39
- # Derive lock file path from upload file path
40
- if locks_dir:
41
- # Ensure locks directory exists
42
- os.makedirs(locks_dir, exist_ok=True)
43
- lock_filename = os.path.basename(file_path) + ".lock"
44
- self.lock_file_path = os.path.join(locks_dir, lock_filename)
45
- else:
46
- # Default: create .locks directory next to upload file
47
- upload_dir = os.path.dirname(file_path)
48
- locks_dir = os.path.join(upload_dir, ".locks")
49
- os.makedirs(locks_dir, exist_ok=True)
50
- lock_filename = os.path.basename(file_path) + ".lock"
51
- self.lock_file_path = os.path.join(locks_dir, lock_filename)
52
-
53
- self._lock_fd: Optional[int] = None
54
-
55
- def acquire(self, blocking: bool = True) -> bool:
56
- """
57
- Acquire an exclusive lock on the upload file.
58
-
59
- Creates a separate lock file and applies an exclusive lock on it.
60
- The lock file contains the PID of the process holding the lock.
61
- This matches tusd's filelocker approach.
62
-
63
- Args:
64
- blocking: If True, block until lock is acquired. If False, return immediately.
65
-
66
- Returns:
67
- True if lock was acquired, False otherwise (only when blocking=False)
68
- """
69
- try:
70
- # Ensure locks directory exists
71
- os.makedirs(os.path.dirname(self.lock_file_path), exist_ok=True)
72
-
73
- # Open the lock file for read-write, create if it doesn't exist
74
- # Use O_RDWR to allow both reading and writing
75
- # Use O_CREAT to create the file if it doesn't exist
76
- self._lock_fd = os.open(self.lock_file_path, os.O_RDWR | os.O_CREAT, 0o644)
77
-
78
- # Ensure the lock file contains only the current PID before writing
79
- try:
80
- os.ftruncate(self._lock_fd, 0)
81
- os.lseek(self._lock_fd, 0, os.SEEK_SET)
82
- except OSError as e:
83
- logger.warning(f"Failed to truncate lock file {self.lock_file_path}: {e}")
84
-
85
- # Write PID to lock file (like tusd does)
86
- try:
87
- pid_str = str(os.getpid()).encode('utf-8')
88
- os.write(self._lock_fd, pid_str)
89
- os.fsync(self._lock_fd) # Ensure PID is written to disk
90
- # Seek back to beginning for reading
91
- os.lseek(self._lock_fd, 0, os.SEEK_SET)
92
- except (IOError, OSError) as e:
93
- # If writing PID fails, continue anyway - lock is still valid
94
- logger.warning(f"Failed to write PID to lock file {self.lock_file_path}: {e}")
95
-
96
- # Try to acquire exclusive lock (LOCK_EX) on the lock file
97
- # If blocking=False, use LOCK_NB (non-blocking)
98
- flags = fcntl.LOCK_EX
99
- if not blocking:
100
- flags |= fcntl.LOCK_NB
101
-
102
- fcntl.flock(self._lock_fd, flags)
103
- return True
104
- except (IOError, OSError) as e:
105
- # Lock acquisition failed
106
- if self._lock_fd is not None:
107
- try:
108
- os.close(self._lock_fd)
109
- except Exception:
110
- pass
111
- self._lock_fd = None
112
-
113
- if not blocking and e.errno in (11, 35): # EAGAIN/EWOULDBLOCK
114
- return False
115
- raise
116
-
117
- def release(self) -> None:
118
- """Release the lock and close the file descriptor."""
119
- if self._lock_fd is not None:
120
- try:
121
- fcntl.flock(self._lock_fd, fcntl.LOCK_UN)
122
- os.close(self._lock_fd)
123
- except Exception as e:
124
- logger.warning(f"Error releasing lock file descriptor: {e}")
125
- finally:
126
- self._lock_fd = None
127
-
128
- # Remove the lock file (tusd does this)
129
- # Note: We remove the lock file but keep the .locks directory
130
- try:
131
- if os.path.exists(self.lock_file_path):
132
- os.remove(self.lock_file_path)
133
- except Exception as e:
134
- logger.warning(f"Error removing lock file {self.lock_file_path}: {e}")
135
-
136
- def get_fd(self) -> Optional[int]:
137
- """
138
- Get the file descriptor for the lock file.
139
-
140
- Returns:
141
- File descriptor if lock is acquired, None otherwise
142
- """
143
- return self._lock_fd
144
-
145
- def __enter__(self):
146
- """Context manager entry."""
147
- self.acquire(blocking=True)
148
- return self
149
-
150
- def __exit__(self, exc_type, exc_val, exc_tb):
151
- """Context manager exit."""
152
- self.release()
153
-
154
-
155
- @contextmanager
156
- def acquire_upload_lock(upload_path: str, locks_dir: Optional[str] = None, blocking: bool = True):
157
- """
158
- Context manager for acquiring an upload lock.
159
-
160
- This matches tusd's filelocker pattern by creating separate lock files
161
- in a .locks directory. Each lock file contains the PID of the process
162
- holding the lock.
163
-
164
- Args:
165
- upload_path: Path to the upload file to lock
166
- locks_dir: Directory to store lock files (defaults to {upload_dir}/.locks)
167
- blocking: If True, block until lock is acquired
168
-
169
- Yields:
170
- FileLock instance with the locked file descriptor
171
-
172
- Example:
173
- with acquire_upload_lock("/path/to/upload") as lock:
174
- # Lock is held via lock file in .locks directory
175
- # Lock file contains PID of this process
176
- pass
177
- """
178
- lock = FileLock(upload_path, locks_dir=locks_dir)
179
- try:
180
- acquired = lock.acquire(blocking=blocking)
181
- if not acquired:
182
- raise IOError("Could not acquire lock")
183
- yield lock
184
- finally:
185
- lock.release()
File without changes