mithril-locks 0.1.0__py3-none-any.whl
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.
- mithril/UPGRADING.md +86 -0
- mithril/__init__.py +67 -0
- mithril/__version__.py +3 -0
- mithril/_fencing.py +164 -0
- mithril/_file_io.py +117 -0
- mithril/_observability.py +168 -0
- mithril/_renewal.py +115 -0
- mithril/_retry.py +94 -0
- mithril/_types.py +197 -0
- mithril/aio/__init__.py +12 -0
- mithril/aio/_renewal.py +126 -0
- mithril/aio/_retry.py +83 -0
- mithril/aio/lock.py +571 -0
- mithril/backends/__init__.py +17 -0
- mithril/backends/_database.py +192 -0
- mithril/backends/_file.py +245 -0
- mithril/backends/_postgres.py +332 -0
- mithril/backends/_redis.py +162 -0
- mithril/backends/_redis_cluster.py +167 -0
- mithril/backends/_redis_common.py +53 -0
- mithril/backends/_redis_scripts.py +141 -0
- mithril/backends/_redlock.py +248 -0
- mithril/backends/_sqlite.py +305 -0
- mithril/backends/aio/__init__.py +8 -0
- mithril/backends/aio/_database.py +166 -0
- mithril/backends/aio/_postgres.py +144 -0
- mithril/backends/aio/_redis.py +169 -0
- mithril/backends/aio/_sqlite.py +156 -0
- mithril/exceptions.py +126 -0
- mithril/lock.py +548 -0
- mithril/py.typed +0 -0
- mithril_locks-0.1.0.dist-info/METADATA +104 -0
- mithril_locks-0.1.0.dist-info/RECORD +35 -0
- mithril_locks-0.1.0.dist-info/WHEEL +4 -0
- mithril_locks-0.1.0.dist-info/licenses/LICENSE +202 -0
mithril/UPGRADING.md
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
# Reliability changes
|
|
2
|
+
|
|
3
|
+
## Before upgrading an existing deployment
|
|
4
|
+
|
|
5
|
+
Stop all old-version lock users and drain their leases before starting the new
|
|
6
|
+
version. Redis reader metadata and file lease metadata have changed; old and new
|
|
7
|
+
holders must not run concurrently against the same resources.
|
|
8
|
+
|
|
9
|
+
Preserve fencing history. Back up existing counters and remove their legacy Redis
|
|
10
|
+
expirations with `PERSIST` during the maintenance window. Names and namespaces
|
|
11
|
+
containing punctuation are now percent-encoded in Redis keys, so migrate those
|
|
12
|
+
counters to the encoded keys without lowering their values. Cluster hash tags use
|
|
13
|
+
the same encoding. Ordinary alphanumeric Redis fencing keys and file fencing
|
|
14
|
+
counter paths are unchanged. While all file-lock users are stopped, remove their
|
|
15
|
+
old `.lock` metadata files so the new format can be created. Keep the adjacent
|
|
16
|
+
`.fence` counters and do not delete live `.guard` files.
|
|
17
|
+
|
|
18
|
+
If an old counter has already expired, recover a floor from durable history or
|
|
19
|
+
the downstream system's highest accepted token before resuming. Draining holders
|
|
20
|
+
alone does not make resetting a fencing counter safe.
|
|
21
|
+
|
|
22
|
+
## Redlock requires a fencing authority
|
|
23
|
+
|
|
24
|
+
`RedlockConfig` now requires `fencing_provider`. Taking the maximum of independent
|
|
25
|
+
node counters cannot order acquisitions across changing quorums. All clients
|
|
26
|
+
locking the same resource must share the same persistent authority and namespace.
|
|
27
|
+
Seed that authority at least as high as every previously issued or downstream-
|
|
28
|
+
accepted token before migrating an existing Redlock deployment.
|
|
29
|
+
|
|
30
|
+
```python
|
|
31
|
+
from redis import Redis
|
|
32
|
+
|
|
33
|
+
from mithril import RedisFencingTokenProvider
|
|
34
|
+
from mithril.backends import RedlockBackend, RedlockConfig
|
|
35
|
+
|
|
36
|
+
authority = Redis(
|
|
37
|
+
host="fencing-service",
|
|
38
|
+
socket_timeout=0.1,
|
|
39
|
+
socket_connect_timeout=0.1,
|
|
40
|
+
)
|
|
41
|
+
backend = RedlockBackend(
|
|
42
|
+
RedlockConfig(
|
|
43
|
+
instances=[Redis(host=host) for host in ("redis-a", "redis-b", "redis-c")],
|
|
44
|
+
fencing_provider=RedisFencingTokenProvider(authority),
|
|
45
|
+
)
|
|
46
|
+
)
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
The authority must never roll back, expire, or evict issued counters. A Redis
|
|
50
|
+
authority is only as durable as its deployment: asynchronous failover or restoring
|
|
51
|
+
an old snapshot can violate this requirement. Supply a custom `FencingTokenProvider`
|
|
52
|
+
backed by a suitable durable service when needed. The in-memory provider is for
|
|
53
|
+
isolated tests only. Call `backend.close()` when finished to close its private
|
|
54
|
+
bounded-timeout Redis pools; caller-owned clients are not closed.
|
|
55
|
+
|
|
56
|
+
## Database backends
|
|
57
|
+
|
|
58
|
+
An SQLAlchemy `Engine` / `AsyncEngine` is now accepted and recommended. Existing
|
|
59
|
+
`Connection` / `AsyncConnection` arguments still work: Mithril borrows independent
|
|
60
|
+
connections from their engine rather than sharing the caller's transaction.
|
|
61
|
+
Configure bounded pool and driver timeouts appropriate for the lease TTL. Retry
|
|
62
|
+
budgets cannot interrupt synchronous driver calls already in progress.
|
|
63
|
+
|
|
64
|
+
SQLite requires a file-backed database with an independent-connection pool;
|
|
65
|
+
`StaticPool` and `SingletonThreadPool` are rejected. In-memory databases cannot
|
|
66
|
+
provide persistent fencing or safely shared transactions. Async backends still
|
|
67
|
+
require `await backend.initialize()` before use.
|
|
68
|
+
|
|
69
|
+
## Lifecycle behavior
|
|
70
|
+
|
|
71
|
+
Nonblocking acquisition makes one attempt. Concurrent acquisition through the
|
|
72
|
+
same facade is rejected. Expired or released instances remain single-use, expose
|
|
73
|
+
no current token, and context exit raises on ownership loss unless preserving an
|
|
74
|
+
exception from the context body. Renewals refresh the facade's deadline; backend
|
|
75
|
+
latency and Redlock drift are subtracted from validity. A manually shortened TTL
|
|
76
|
+
must still exceed the configured background-renewal interval, including jitter.
|
|
77
|
+
|
|
78
|
+
File leases now allow takeover after TTL expiry even while the previous process
|
|
79
|
+
is alive, and detect process crashes using OS locks. As with every TTL-based
|
|
80
|
+
backend, downstream writes must enforce fencing tokens. A standalone guard check
|
|
81
|
+
followed by an unrelated write is not an atomic fenced write.
|
|
82
|
+
|
|
83
|
+
Logging and metrics failures no longer interrupt lease ownership or cleanup.
|
|
84
|
+
Epochs are backend-issued persistent sequence numbers, not caller-supplied counts.
|
|
85
|
+
The backend `epoch` argument remains accepted for compatibility but is ignored by
|
|
86
|
+
the built-in backends.
|
mithril/__init__.py
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Public API for Mithril.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
from .__version__ import __version__
|
|
8
|
+
from ._fencing import FencingTokenProvider, FileFencingTokenProvider, RedisFencingTokenProvider
|
|
9
|
+
from ._observability import LockHooks, configure_logging, get_logger, set_hooks
|
|
10
|
+
from ._types import LockConfig, LockMode, LockState, LockToken, Result
|
|
11
|
+
from .exceptions import (
|
|
12
|
+
AlreadyAcquiredError,
|
|
13
|
+
AutoRenewalStoppedError,
|
|
14
|
+
BackendError,
|
|
15
|
+
BackendTimeoutError,
|
|
16
|
+
BackendUnavailableError,
|
|
17
|
+
ConfigurationError,
|
|
18
|
+
FencingTokenError,
|
|
19
|
+
LockAcquisitionError,
|
|
20
|
+
LockExpiredError,
|
|
21
|
+
LockReleaseError,
|
|
22
|
+
LockRenewalError,
|
|
23
|
+
LockTimeoutError,
|
|
24
|
+
MithrilError,
|
|
25
|
+
NotAcquiredError,
|
|
26
|
+
RenewalFailedError,
|
|
27
|
+
StaleTokenError,
|
|
28
|
+
)
|
|
29
|
+
from .lock import Lock
|
|
30
|
+
|
|
31
|
+
__author__ = "Rohan B. Dalton"
|
|
32
|
+
|
|
33
|
+
__all__ = [
|
|
34
|
+
"__version__",
|
|
35
|
+
"FencingTokenProvider",
|
|
36
|
+
"FileFencingTokenProvider",
|
|
37
|
+
"RedisFencingTokenProvider",
|
|
38
|
+
"LockConfig",
|
|
39
|
+
"LockMode",
|
|
40
|
+
"LockState",
|
|
41
|
+
"LockToken",
|
|
42
|
+
"Result",
|
|
43
|
+
"Lock",
|
|
44
|
+
"LockHooks",
|
|
45
|
+
"configure_logging",
|
|
46
|
+
"get_logger",
|
|
47
|
+
"set_hooks",
|
|
48
|
+
"MithrilError",
|
|
49
|
+
"LockAcquisitionError",
|
|
50
|
+
"LockTimeoutError",
|
|
51
|
+
"AlreadyAcquiredError",
|
|
52
|
+
"LockReleaseError",
|
|
53
|
+
"NotAcquiredError",
|
|
54
|
+
"LockExpiredError",
|
|
55
|
+
"LockRenewalError",
|
|
56
|
+
"RenewalFailedError",
|
|
57
|
+
"AutoRenewalStoppedError",
|
|
58
|
+
"FencingTokenError",
|
|
59
|
+
"StaleTokenError",
|
|
60
|
+
"BackendError",
|
|
61
|
+
"BackendUnavailableError",
|
|
62
|
+
"BackendTimeoutError",
|
|
63
|
+
"ConfigurationError",
|
|
64
|
+
]
|
|
65
|
+
|
|
66
|
+
if __name__ == "__main__":
|
|
67
|
+
pass
|
mithril/__version__.py
ADDED
mithril/_fencing.py
ADDED
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Fencing token providers for Mithril.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import threading
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Any, Protocol
|
|
10
|
+
from urllib.parse import quote
|
|
11
|
+
|
|
12
|
+
from ._file_io import file_mutex, read_file, replace_file, validate_component
|
|
13
|
+
from .exceptions import FencingTokenError
|
|
14
|
+
|
|
15
|
+
__author__ = "Rohan B. Dalton"
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class FencingTokenProvider(Protocol):
|
|
19
|
+
"""
|
|
20
|
+
Protocol for fencing token providers.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
def next_token(self, lock_name: str) -> int: ...
|
|
24
|
+
|
|
25
|
+
def current_token(self, lock_name: str) -> int: ...
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class InMemoryFencingTokenProvider:
|
|
29
|
+
"""
|
|
30
|
+
Thread-safe in-memory fencing token provider.
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
def __init__(self) -> None:
|
|
34
|
+
self._lock = threading.Lock()
|
|
35
|
+
self._tokens: dict[str, int] = {}
|
|
36
|
+
|
|
37
|
+
def next_token(self, lock_name: str) -> int:
|
|
38
|
+
with self._lock:
|
|
39
|
+
current = self._tokens.get(lock_name, 0)
|
|
40
|
+
next_value = current + 1
|
|
41
|
+
self._tokens[lock_name] = next_value
|
|
42
|
+
return next_value
|
|
43
|
+
|
|
44
|
+
def current_token(self, lock_name: str) -> int:
|
|
45
|
+
with self._lock:
|
|
46
|
+
return self._tokens.get(lock_name, 0)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class FileFencingTokenProvider:
|
|
50
|
+
"""
|
|
51
|
+
Durable counters protected by a separate OS mutex and atomic replacement.
|
|
52
|
+
"""
|
|
53
|
+
|
|
54
|
+
def __init__(self, directory: str | Path, namespace: str = "mithril") -> None:
|
|
55
|
+
validate_component(namespace)
|
|
56
|
+
self._directory = Path(directory)
|
|
57
|
+
self._directory.mkdir(parents=True, exist_ok=True)
|
|
58
|
+
self._namespace = namespace
|
|
59
|
+
|
|
60
|
+
def next_token(self, lock_name: str) -> int:
|
|
61
|
+
path = self._path_for(lock_name)
|
|
62
|
+
with file_mutex(path.with_suffix(".fence.guard")):
|
|
63
|
+
current = self._read_counter(path)
|
|
64
|
+
replace_file(path, str(value := current + 1))
|
|
65
|
+
return value
|
|
66
|
+
|
|
67
|
+
def current_token(self, lock_name: str) -> int:
|
|
68
|
+
path = self._path_for(lock_name)
|
|
69
|
+
with file_mutex(path.with_suffix(".fence.guard")):
|
|
70
|
+
return self._read_counter(path)
|
|
71
|
+
|
|
72
|
+
def _path_for(self, lock_name: str) -> Path:
|
|
73
|
+
validate_component(lock_name)
|
|
74
|
+
return self._directory / f"{self._namespace}.{lock_name}.fence"
|
|
75
|
+
|
|
76
|
+
@staticmethod
|
|
77
|
+
def _read_counter(path: Path) -> int:
|
|
78
|
+
if (data := read_file(path)) is None:
|
|
79
|
+
return 0
|
|
80
|
+
if not data.isascii() or not data.isdecimal():
|
|
81
|
+
raise FencingTokenError(f"Invalid fencing counter: {path}")
|
|
82
|
+
return int(data)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
class FencingGuard:
|
|
86
|
+
"""
|
|
87
|
+
Helper for downstream systems to validate fencing tokens.
|
|
88
|
+
"""
|
|
89
|
+
|
|
90
|
+
def validate_and_update(self, lock_name: str, token: int) -> bool:
|
|
91
|
+
"""
|
|
92
|
+
Validate a fencing token and update the stored max.
|
|
93
|
+
"""
|
|
94
|
+
raise NotImplementedError("Provide a backend-specific implementation")
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
class RedisFencingTokenProvider:
|
|
98
|
+
"""
|
|
99
|
+
Explicit fencing authority with persistent counters on one Redis service.
|
|
100
|
+
|
|
101
|
+
The service must preserve its counters across failures. Configure bounded
|
|
102
|
+
client timeouts; availability of this authority is required by Redlock.
|
|
103
|
+
"""
|
|
104
|
+
|
|
105
|
+
_SCRIPT = """
|
|
106
|
+
redis.call('INCR', KEYS[1])
|
|
107
|
+
redis.call('PERSIST', KEYS[1])
|
|
108
|
+
return redis.call('GET', KEYS[1])
|
|
109
|
+
"""
|
|
110
|
+
|
|
111
|
+
def __init__(self, client: Any, *, namespace: str = "mithril") -> None:
|
|
112
|
+
self._client = client
|
|
113
|
+
self._namespace = namespace
|
|
114
|
+
self._script = client.register_script(self._SCRIPT)
|
|
115
|
+
|
|
116
|
+
def _key(self, lock_name: str) -> str:
|
|
117
|
+
return f"{quote(self._namespace, safe='')}:authority:{quote(lock_name, safe='')}"
|
|
118
|
+
|
|
119
|
+
def next_token(self, lock_name: str) -> int:
|
|
120
|
+
return int(self._script(keys=[self._key(lock_name)]))
|
|
121
|
+
|
|
122
|
+
def current_token(self, lock_name: str) -> int:
|
|
123
|
+
return int(self._client.get(self._key(lock_name)) or 0)
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
class RedisFencingGuard(FencingGuard):
|
|
127
|
+
"""
|
|
128
|
+
Redis-backed fencing token guard.
|
|
129
|
+
"""
|
|
130
|
+
|
|
131
|
+
_SCRIPT = """
|
|
132
|
+
local current = redis.call('GET', KEYS[1])
|
|
133
|
+
if not current then
|
|
134
|
+
redis.call('SET', KEYS[1], ARGV[1])
|
|
135
|
+
return 1
|
|
136
|
+
end
|
|
137
|
+
if tonumber(ARGV[1]) > tonumber(current) then
|
|
138
|
+
redis.call('SET', KEYS[1], ARGV[1])
|
|
139
|
+
return 1
|
|
140
|
+
end
|
|
141
|
+
return 0
|
|
142
|
+
"""
|
|
143
|
+
|
|
144
|
+
def __init__(self, client: Any, *, namespace: str = "mithril") -> None:
|
|
145
|
+
self._client = client
|
|
146
|
+
self._namespace = namespace
|
|
147
|
+
self._script = client.register_script(self._SCRIPT)
|
|
148
|
+
|
|
149
|
+
def validate_and_update(self, lock_name: str, token: int) -> bool:
|
|
150
|
+
"""
|
|
151
|
+
Validate a fencing token and update the stored max.
|
|
152
|
+
"""
|
|
153
|
+
key = self._key(lock_name)
|
|
154
|
+
result = self._script(keys=[key], args=[token])
|
|
155
|
+
return bool(result)
|
|
156
|
+
|
|
157
|
+
def _key(self, lock_name: str) -> str:
|
|
158
|
+
if not self._namespace:
|
|
159
|
+
return f"fence:{lock_name}:max"
|
|
160
|
+
return f"{self._namespace}:fence:{lock_name}:max"
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
if __name__ == "__main__":
|
|
164
|
+
pass
|
mithril/_file_io.py
ADDED
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Safe file opening, fixed-byte mutexes, and durable metadata replacement.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import errno
|
|
8
|
+
import os
|
|
9
|
+
import stat
|
|
10
|
+
import tempfile
|
|
11
|
+
from contextlib import contextmanager
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
from .exceptions import BackendError, ConfigurationError
|
|
15
|
+
|
|
16
|
+
if os.name == "nt":
|
|
17
|
+
import msvcrt
|
|
18
|
+
else:
|
|
19
|
+
import fcntl
|
|
20
|
+
|
|
21
|
+
__author__ = "Rohan B. Dalton"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def validate_component(value: str) -> None:
|
|
25
|
+
if not isinstance(value, str) or not value or "/" in value or "\\" in value:
|
|
26
|
+
raise ConfigurationError("file names and namespaces must be nonempty path components")
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def open_file(path: Path, *, create: bool = True):
|
|
30
|
+
if path.is_symlink():
|
|
31
|
+
raise ValueError("lock path must not be a symlink")
|
|
32
|
+
flags = os.O_RDWR | (os.O_CREAT if create else 0) | getattr(os, "O_NOFOLLOW", 0)
|
|
33
|
+
fd = os.open(path, flags, 0o600)
|
|
34
|
+
try:
|
|
35
|
+
info = os.fstat(fd)
|
|
36
|
+
if not stat.S_ISREG(info.st_mode) or info.st_nlink != 1:
|
|
37
|
+
raise ValueError("lock path must be a regular file with no hard links")
|
|
38
|
+
return os.fdopen(fd, "r+", encoding="utf-8")
|
|
39
|
+
except BaseException:
|
|
40
|
+
os.close(fd)
|
|
41
|
+
raise
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def lock_file(handle, *, blocking: bool) -> bool:
|
|
45
|
+
if os.name == "nt":
|
|
46
|
+
handle.seek(0, os.SEEK_END)
|
|
47
|
+
if handle.tell() == 0:
|
|
48
|
+
handle.write("\0")
|
|
49
|
+
handle.flush()
|
|
50
|
+
handle.seek(0)
|
|
51
|
+
mode = msvcrt.LK_LOCK if blocking else msvcrt.LK_NBLCK
|
|
52
|
+
try:
|
|
53
|
+
msvcrt.locking(handle.fileno(), mode, 1)
|
|
54
|
+
except OSError as exc:
|
|
55
|
+
if exc.errno in (errno.EACCES, errno.EAGAIN, errno.EDEADLK):
|
|
56
|
+
return False
|
|
57
|
+
raise
|
|
58
|
+
else:
|
|
59
|
+
flags = fcntl.LOCK_EX | (0 if blocking else fcntl.LOCK_NB)
|
|
60
|
+
try:
|
|
61
|
+
fcntl.flock(handle.fileno(), flags)
|
|
62
|
+
except OSError as exc:
|
|
63
|
+
if exc.errno in (errno.EACCES, errno.EAGAIN):
|
|
64
|
+
return False
|
|
65
|
+
raise
|
|
66
|
+
return True
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def unlock_file(handle) -> None:
|
|
70
|
+
handle.seek(0)
|
|
71
|
+
if os.name == "nt":
|
|
72
|
+
msvcrt.locking(handle.fileno(), msvcrt.LK_UNLCK, 1)
|
|
73
|
+
else:
|
|
74
|
+
fcntl.flock(handle.fileno(), fcntl.LOCK_UN)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
@contextmanager
|
|
78
|
+
def file_mutex(path: Path):
|
|
79
|
+
with open_file(path) as fh:
|
|
80
|
+
if not lock_file(fh, blocking=True):
|
|
81
|
+
raise BackendError("Could not acquire file metadata mutex")
|
|
82
|
+
try:
|
|
83
|
+
yield
|
|
84
|
+
finally:
|
|
85
|
+
unlock_file(fh)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def read_file(path: Path) -> str | None:
|
|
89
|
+
try:
|
|
90
|
+
with open_file(path, create=False) as fh:
|
|
91
|
+
return fh.read()
|
|
92
|
+
except FileNotFoundError:
|
|
93
|
+
return None
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def replace_file(path: Path, data: str) -> None:
|
|
97
|
+
"""
|
|
98
|
+
Replace metadata while a separate, stable mutex file is locked.
|
|
99
|
+
"""
|
|
100
|
+
if path.is_symlink():
|
|
101
|
+
raise ValueError("lock path must not be a symlink")
|
|
102
|
+
fd, temporary = tempfile.mkstemp(prefix=".mithril-", dir=path.parent)
|
|
103
|
+
try:
|
|
104
|
+
with os.fdopen(fd, "w", encoding="utf-8") as fh:
|
|
105
|
+
fh.write(data)
|
|
106
|
+
fh.flush()
|
|
107
|
+
os.fsync(fh.fileno())
|
|
108
|
+
os.replace(temporary, path)
|
|
109
|
+
if os.name != "nt":
|
|
110
|
+
directory_fd = os.open(path.parent, os.O_RDONLY | getattr(os, "O_DIRECTORY", 0))
|
|
111
|
+
try:
|
|
112
|
+
os.fsync(directory_fd)
|
|
113
|
+
finally:
|
|
114
|
+
os.close(directory_fd)
|
|
115
|
+
finally:
|
|
116
|
+
if os.path.exists(temporary):
|
|
117
|
+
os.unlink(temporary)
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Observability helpers for Mithril: structured logging and metrics hooks.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from typing import Any, Callable
|
|
9
|
+
|
|
10
|
+
import structlog
|
|
11
|
+
from opentelemetry.metrics import get_meter
|
|
12
|
+
|
|
13
|
+
from ._types import LockMode, LockState
|
|
14
|
+
|
|
15
|
+
__author__ = "Rohan B. Dalton"
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass(frozen=True)
|
|
19
|
+
class LockEvent:
|
|
20
|
+
"""
|
|
21
|
+
Snapshot of a lock lifecycle event.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
event: str
|
|
25
|
+
lock_name: str
|
|
26
|
+
owner_id: str | None
|
|
27
|
+
mode: LockMode | None
|
|
28
|
+
fencing_token: int | None
|
|
29
|
+
epoch: int | None
|
|
30
|
+
ttl: float | None
|
|
31
|
+
timeout: float | None
|
|
32
|
+
elapsed: float | None
|
|
33
|
+
state: LockState | None
|
|
34
|
+
backend: str | None
|
|
35
|
+
namespace: str | None
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@dataclass(frozen=True)
|
|
39
|
+
class LockHooks:
|
|
40
|
+
"""
|
|
41
|
+
Optional callbacks for lifecycle events.
|
|
42
|
+
"""
|
|
43
|
+
|
|
44
|
+
on_acquire: Callable[[LockEvent], None] | None = None
|
|
45
|
+
on_release: Callable[[LockEvent], None] | None = None
|
|
46
|
+
on_renewal: Callable[[LockEvent], None] | None = None
|
|
47
|
+
on_timeout: Callable[[LockEvent], None] | None = None
|
|
48
|
+
on_expired: Callable[[LockEvent], None] | None = None
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
_HOOKS = LockHooks()
|
|
52
|
+
_LOGGER = structlog.get_logger("mithril")
|
|
53
|
+
_METER = get_meter("mithril")
|
|
54
|
+
|
|
55
|
+
_ACQUIRE_COUNTER = _METER.create_counter(
|
|
56
|
+
"mithril.lock.acquire.total", description="Total lock acquisitions", unit="1"
|
|
57
|
+
)
|
|
58
|
+
_RELEASE_COUNTER = _METER.create_counter(
|
|
59
|
+
"mithril.lock.release.total", description="Total lock releases", unit="1"
|
|
60
|
+
)
|
|
61
|
+
_RENEW_COUNTER = _METER.create_counter(
|
|
62
|
+
"mithril.lock.renew.total", description="Total lock renewals", unit="1"
|
|
63
|
+
)
|
|
64
|
+
_TIMEOUT_COUNTER = _METER.create_counter(
|
|
65
|
+
"mithril.lock.timeout.total", description="Total lock acquisition timeouts", unit="1"
|
|
66
|
+
)
|
|
67
|
+
_EXPIRED_COUNTER = _METER.create_counter(
|
|
68
|
+
"mithril.lock.expired.total", description="Total lock expirations", unit="1"
|
|
69
|
+
)
|
|
70
|
+
_ACQUIRE_LATENCY = _METER.create_histogram(
|
|
71
|
+
"mithril.lock.acquire.latency", description="Lock acquisition latency", unit="s"
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def configure_logging(**kwargs: Any) -> None:
|
|
76
|
+
"""
|
|
77
|
+
Configure structlog for Mithril consumers.
|
|
78
|
+
|
|
79
|
+
kwargs are passed directly to structlog.configure.
|
|
80
|
+
"""
|
|
81
|
+
|
|
82
|
+
structlog.configure(**kwargs)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def set_hooks(hooks: LockHooks | None) -> None:
|
|
86
|
+
"""
|
|
87
|
+
Set global lock hooks. Pass None to clear.
|
|
88
|
+
"""
|
|
89
|
+
|
|
90
|
+
global _HOOKS
|
|
91
|
+
_HOOKS = hooks or LockHooks()
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def get_hooks() -> LockHooks:
|
|
95
|
+
"""
|
|
96
|
+
Return the global lock hooks.
|
|
97
|
+
"""
|
|
98
|
+
|
|
99
|
+
return _HOOKS
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def get_logger() -> structlog.BoundLogger:
|
|
103
|
+
"""
|
|
104
|
+
Return the default Mithril logger.
|
|
105
|
+
"""
|
|
106
|
+
|
|
107
|
+
return _LOGGER
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def record_event(event: LockEvent, hooks: LockHooks | None = None) -> None:
|
|
111
|
+
"""
|
|
112
|
+
Invoke hooks and record OpenTelemetry metrics.
|
|
113
|
+
"""
|
|
114
|
+
|
|
115
|
+
try:
|
|
116
|
+
_record_event(event, hooks)
|
|
117
|
+
except Exception:
|
|
118
|
+
log_safely(_LOGGER, "exception", "lock_observability_failed", lock_event=event.event)
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def log_safely(logger, level: str, event: str, *args, **kwargs) -> None:
|
|
122
|
+
"""
|
|
123
|
+
Logging failures must never interrupt ownership transitions or cleanup.
|
|
124
|
+
"""
|
|
125
|
+
try:
|
|
126
|
+
getattr(logger, level)(event, *args, **kwargs)
|
|
127
|
+
except Exception:
|
|
128
|
+
# Retrying through the same broken logging pipeline would fail again.
|
|
129
|
+
pass
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _record_event(event: LockEvent, hooks: LockHooks | None = None) -> None:
|
|
133
|
+
hooks = hooks or _HOOKS
|
|
134
|
+
if event.event == "acquire":
|
|
135
|
+
if hooks.on_acquire is not None:
|
|
136
|
+
hooks.on_acquire(event)
|
|
137
|
+
_ACQUIRE_COUNTER.add(1, _attributes(event))
|
|
138
|
+
if event.elapsed is not None:
|
|
139
|
+
_ACQUIRE_LATENCY.record(event.elapsed, _attributes(event))
|
|
140
|
+
elif event.event == "release":
|
|
141
|
+
if hooks.on_release is not None:
|
|
142
|
+
hooks.on_release(event)
|
|
143
|
+
_RELEASE_COUNTER.add(1, _attributes(event))
|
|
144
|
+
elif event.event == "renew":
|
|
145
|
+
if hooks.on_renewal is not None:
|
|
146
|
+
hooks.on_renewal(event)
|
|
147
|
+
_RENEW_COUNTER.add(1, _attributes(event))
|
|
148
|
+
elif event.event == "timeout":
|
|
149
|
+
if hooks.on_timeout is not None:
|
|
150
|
+
hooks.on_timeout(event)
|
|
151
|
+
_TIMEOUT_COUNTER.add(1, _attributes(event))
|
|
152
|
+
elif event.event == "expired":
|
|
153
|
+
if hooks.on_expired is not None:
|
|
154
|
+
hooks.on_expired(event)
|
|
155
|
+
_EXPIRED_COUNTER.add(1, _attributes(event))
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def _attributes(event: LockEvent) -> dict[str, Any]:
|
|
159
|
+
return {
|
|
160
|
+
"lock_name": event.lock_name,
|
|
161
|
+
"mode": event.mode.value if event.mode else None,
|
|
162
|
+
"backend": event.backend,
|
|
163
|
+
"namespace": event.namespace or "",
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
if __name__ == "__main__":
|
|
168
|
+
pass
|