Coverage for app/venv/lib/python3.14/site-packages/weblate/utils/lock.py: 71%
86 statements
« prev ^ index » next coverage.py v7.15.2, created at 2026-10-07 07:15 +0000
« prev ^ index » next coverage.py v7.15.2, created at 2026-10-07 07:15 +0000
1# Copyright © Michal Čihař <michal@weblate.org>
2#
3# SPDX-License-Identifier: GPL-3.0-or-later
5from __future__ import annotations
7import os
8import threading
9from typing import TYPE_CHECKING, cast
11import sentry_sdk
12from django.core.cache import cache
13from filelock import FileLock, Timeout
15from weblate.utils.cache import is_redis_cache
16from weblate.utils.errors import add_breadcrumb
18if TYPE_CHECKING: 18 ↛ 19line 18 didn't jump to line 19 because the condition on line 18 was never true
19 from types import TracebackType
21 from django_redis.cache import RedisCache
22 from redis.lock import Lock as RedisLock
25class WeblateLockError(Exception):
26 def __init__(self, message: str, *, lock: WeblateLock) -> None:
27 super().__init__(message)
28 self.lock = lock
31class WeblateLockTimeoutError(WeblateLockError):
32 """Weblate lock timeout."""
35class WeblateLockNotLockedError(WeblateLockError):
36 """Weblate lock not locked on release."""
39class WeblateLock:
40 """Wrapper around Redis or file based lock."""
42 _redis_lock: RedisLock
43 _file_lock: FileLock
45 _redis_expiry_timeout = 3600
47 def __init__(
48 self,
49 *,
50 lock_path: str,
51 scope: str,
52 key: int | str,
53 slug: str,
54 cache_template: str = "lock:{scope}:{key}",
55 file_template: str = "{slug}-{scope}.lock",
56 timeout: int = 1,
57 origin: str | None = None,
58 ) -> None:
59 self._timeout = timeout
60 self._lock_path = lock_path
61 self._scope = scope
62 self._key = key
63 self._slug = slug
64 self._origin = origin
65 self._using_redis = is_redis_cache()
66 self._local = threading.local()
67 self._local.depth = 0
68 if self._using_redis: 68 ↛ 80line 68 didn't jump to line 80 because the condition on line 68 was always true
69 # Prefer Redis locking as it works distributed
70 self._name = self._format_template(cache_template)
71 self._redis_lock = cast("RedisCache", cache).lock(
72 key=self._name,
73 blocking=True,
74 timeout=self._redis_expiry_timeout,
75 blocking_timeout=self._timeout,
76 thread_local=True,
77 )
78 else:
79 # Fall back to file based locking
80 self._name = os.path.join(lock_path, self._format_template(file_template))
81 self._file_lock = FileLock(
82 self._name,
83 timeout=self._timeout,
84 )
86 @property
87 def scope(self) -> str:
88 return self._scope
90 @property
91 def origin(self) -> str | None:
92 return self._origin
94 def _format_template(self, template: str) -> str:
95 return template.format(
96 scope=self._scope,
97 key=self._key,
98 slug=self._slug,
99 )
101 def get_error_message(self) -> str:
102 if self.origin:
103 return f"Lock on {self.origin} ({self.scope}) could not be acquired in {self._timeout}s"
104 return f"Lock on {self._name} could not be acquired in {self._timeout}s"
106 def _enter_redis(self) -> None:
107 # Make the lock reentrant
108 if self._redis_lock.owned(): 108 ↛ 110line 108 didn't jump to line 110 because the condition on line 108 was never true
109 # Extend lock if already owned (nested with statements)
110 lock_result = self._redis_lock.reacquire()
111 else:
112 lock_result = self._redis_lock.acquire()
114 if not lock_result: 114 ↛ 115line 114 didn't jump to line 115 because the condition on line 114 was never true
115 raise WeblateLockTimeoutError(self.get_error_message(), lock=self)
117 def _enter_file(self) -> None:
118 # Fall back to file based locking
119 try:
120 self._file_lock.acquire()
121 except Timeout as error:
122 raise WeblateLockTimeoutError(
123 self.get_error_message(), lock=self
124 ) from error
126 def add_breadcrumb(self, operation: str) -> None:
127 add_breadcrumb(
128 category="lock", message=f"{operation} {self._name} ({self._local.depth})"
129 )
131 def __enter__(self) -> None:
132 self.add_breadcrumb("enter")
133 if not self.is_locked:
134 self.add_breadcrumb("acquire")
135 with sentry_sdk.start_span(op="lock.wait", name=self._name):
136 if self._using_redis: 136 ↛ 139line 136 didn't jump to line 139 because the condition on line 136 was always true
137 self._enter_redis()
138 else:
139 self._enter_file()
140 self._local.depth += 1
142 def __exit__(
143 self,
144 exc_type: type[BaseException] | None,
145 exc_value: BaseException | None,
146 traceback: TracebackType | None,
147 ) -> None:
148 if not self.is_locked: 148 ↛ 149line 148 didn't jump to line 149 because the condition on line 148 was never true
149 msg = f"Lock on {self._name} was not held on release"
150 raise WeblateLockNotLockedError(msg, lock=self)
152 self.add_breadcrumb("exit")
153 self._local.depth -= 1
155 # Release underlying lock
156 if self._local.depth == 0:
157 self.add_breadcrumb("release")
158 if self._using_redis: 158 ↛ 161line 158 didn't jump to line 161 because the condition on line 158 was always true
159 self._redis_lock.release()
160 else:
161 self._file_lock.release()
163 @property
164 def is_locked(self) -> bool:
165 return self._local.depth > 0