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 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
@@ -0,0 +1,3 @@
1
+ """Single source of truth for the Mithril release version."""
2
+
3
+ __version__ = "0.1.0"
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