SessionSmith 2.1.0__tar.gz → 2.2.0__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.
- {sessionsmith-2.1.0 → sessionsmith-2.2.0}/PKG-INFO +2 -6
- {sessionsmith-2.1.0 → sessionsmith-2.2.0}/SessionSmith/__init__.py +1 -1
- {sessionsmith-2.1.0 → sessionsmith-2.2.0}/SessionSmith/core.py +9 -4
- {sessionsmith-2.1.0 → sessionsmith-2.2.0}/SessionSmith/error_handling.py +1 -1
- {sessionsmith-2.1.0 → sessionsmith-2.2.0}/SessionSmith/exceptions.py +44 -2
- {sessionsmith-2.1.0 → sessionsmith-2.2.0}/SessionSmith/i18n.py +5 -3
- {sessionsmith-2.1.0 → sessionsmith-2.2.0}/SessionSmith/info.py +0 -1
- sessionsmith-2.2.0/SessionSmith/locking.py +385 -0
- {sessionsmith-2.1.0 → sessionsmith-2.2.0}/SessionSmith/remote_backends.py +49 -0
- {sessionsmith-2.1.0 → sessionsmith-2.2.0}/SessionSmith/ssm.py +786 -408
- {sessionsmith-2.1.0 → sessionsmith-2.2.0}/SessionSmith/tracer.py +2 -2
- sessionsmith-2.2.0/SessionSmith/validation.py +158 -0
- {sessionsmith-2.1.0 → sessionsmith-2.2.0}/SessionSmith.egg-info/PKG-INFO +2 -6
- {sessionsmith-2.1.0 → sessionsmith-2.2.0}/SessionSmith.egg-info/SOURCES.txt +7 -0
- {sessionsmith-2.1.0 → sessionsmith-2.2.0}/pyproject.toml +57 -1
- {sessionsmith-2.1.0 → sessionsmith-2.2.0}/readme.md +1 -0
- sessionsmith-2.2.0/setup.py +4 -0
- sessionsmith-2.2.0/tests/test_locking.py +454 -0
- sessionsmith-2.2.0/tests/test_merge_checkout_features.py +224 -0
- sessionsmith-2.2.0/tests/test_security.py +243 -0
- sessionsmith-2.2.0/tests/test_ssm_bugfixes.py +239 -0
- sessionsmith-2.2.0/tests/test_ssm_e2e.py +466 -0
- sessionsmith-2.1.0/setup.py +0 -71
- {sessionsmith-2.1.0 → sessionsmith-2.2.0}/LICENSE +0 -0
- {sessionsmith-2.1.0 → sessionsmith-2.2.0}/SessionSmith/cli.py +0 -0
- {sessionsmith-2.1.0 → sessionsmith-2.2.0}/SessionSmith/compare.py +0 -0
- {sessionsmith-2.1.0 → sessionsmith-2.2.0}/SessionSmith/crypto.py +0 -0
- {sessionsmith-2.1.0 → sessionsmith-2.2.0}/SessionSmith/formats.py +0 -0
- {sessionsmith-2.1.0 → sessionsmith-2.2.0}/SessionSmith/jupyter_utils.py +0 -0
- {sessionsmith-2.1.0 → sessionsmith-2.2.0}/SessionSmith/logging_config.py +0 -0
- {sessionsmith-2.1.0 → sessionsmith-2.2.0}/SessionSmith/manager.py +0 -0
- {sessionsmith-2.1.0 → sessionsmith-2.2.0}/SessionSmith/py.typed +0 -0
- {sessionsmith-2.1.0 → sessionsmith-2.2.0}/SessionSmith/resource_manager.py +0 -0
- {sessionsmith-2.1.0 → sessionsmith-2.2.0}/SessionSmith/serializers.py +0 -0
- {sessionsmith-2.1.0 → sessionsmith-2.2.0}/SessionSmith/utils.py +0 -0
- {sessionsmith-2.1.0 → sessionsmith-2.2.0}/SessionSmith/visualizer.py +0 -0
- {sessionsmith-2.1.0 → sessionsmith-2.2.0}/SessionSmith/visualizer_arrays.py +0 -0
- {sessionsmith-2.1.0 → sessionsmith-2.2.0}/SessionSmith/visualizer_generic.py +0 -0
- {sessionsmith-2.1.0 → sessionsmith-2.2.0}/SessionSmith.egg-info/dependency_links.txt +0 -0
- {sessionsmith-2.1.0 → sessionsmith-2.2.0}/SessionSmith.egg-info/entry_points.txt +0 -0
- {sessionsmith-2.1.0 → sessionsmith-2.2.0}/SessionSmith.egg-info/requires.txt +0 -0
- {sessionsmith-2.1.0 → sessionsmith-2.2.0}/SessionSmith.egg-info/top_level.txt +0 -0
- {sessionsmith-2.1.0 → sessionsmith-2.2.0}/setup.cfg +0 -0
- {sessionsmith-2.1.0 → sessionsmith-2.2.0}/tests/test_cli.py +0 -0
- {sessionsmith-2.1.0 → sessionsmith-2.2.0}/tests/test_core.py +0 -0
- {sessionsmith-2.1.0 → sessionsmith-2.2.0}/tests/test_crypto.py +0 -0
- {sessionsmith-2.1.0 → sessionsmith-2.2.0}/tests/test_logging_config.py +0 -0
- {sessionsmith-2.1.0 → sessionsmith-2.2.0}/tests/test_remote_backends.py +0 -0
- {sessionsmith-2.1.0 → sessionsmith-2.2.0}/tests/test_ssm.py +0 -0
- {sessionsmith-2.1.0 → sessionsmith-2.2.0}/tests/test_verify.py +0 -0
|
@@ -1,9 +1,7 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: SessionSmith
|
|
3
|
-
Version: 2.
|
|
3
|
+
Version: 2.2.0
|
|
4
4
|
Summary: Git-style session management for Python. Save, restore, and track your variables with ease.
|
|
5
|
-
Home-page: https://github.com/yut0takagi/SessionSmith
|
|
6
|
-
Author: YutoTAKAGI
|
|
7
5
|
Author-email: YutoTAKAGI <yutotkg.1040@gmail.com>
|
|
8
6
|
License: MIT
|
|
9
7
|
Project-URL: Homepage, https://github.com/yut0takagi/SessionSmith
|
|
@@ -56,10 +54,7 @@ Requires-Dist: matplotlib>=3.5.0; extra == "all"
|
|
|
56
54
|
Requires-Dist: cryptography>=3.4; extra == "all"
|
|
57
55
|
Requires-Dist: boto3>=1.26; extra == "all"
|
|
58
56
|
Requires-Dist: google-cloud-storage>=2.0; extra == "all"
|
|
59
|
-
Dynamic: author
|
|
60
|
-
Dynamic: home-page
|
|
61
57
|
Dynamic: license-file
|
|
62
|
-
Dynamic: requires-python
|
|
63
58
|
|
|
64
59
|
# SessionSmith
|
|
65
60
|
|
|
@@ -424,6 +419,7 @@ Python notebookで複数のファイルからインポートしたり、複数
|
|
|
424
419
|
- 📈 [アルゴリズムトレーサー](docs/algorithm-tracer.md) - トレース・可視化機能
|
|
425
420
|
- 🌐 [国際化(i18n)ガイド](docs/i18n-guide.md) - 多言語対応(日本語・英語)
|
|
426
421
|
- 📚 [APIリファレンス](docs/api-reference.md) - 全APIの詳細
|
|
422
|
+
- ⏱️ [ベンチマーク](benchmarks/README.md) - 大規模セッション・チェックポイント・メモリ計測(issue #31)
|
|
427
423
|
|
|
428
424
|
## 使用例
|
|
429
425
|
|
|
@@ -118,7 +118,7 @@ from .tracer import AlgorithmTracer
|
|
|
118
118
|
from .utils import verify_session
|
|
119
119
|
from .visualizer import print_trace_summary, visualize_algorithm_trace
|
|
120
120
|
|
|
121
|
-
__version__ = "2.
|
|
121
|
+
__version__ = "2.2.0"
|
|
122
122
|
|
|
123
123
|
# 環境変数からロギングを自動設定(SESSIONSMITH_LOG_LEVEL / SESSIONSMITH_LOG_FILE)
|
|
124
124
|
try:
|
|
@@ -46,9 +46,14 @@ def _validate_file_path(file_path: Union[str, Path]) -> Path:
|
|
|
46
46
|
if not isinstance(file_path, (str, Path)):
|
|
47
47
|
raise TypeError(f"file_path must be str or Path, got {type(file_path).__name__}")
|
|
48
48
|
|
|
49
|
-
|
|
49
|
+
path_str = str(file_path)
|
|
50
|
+
|
|
51
|
+
if not path_str.strip():
|
|
50
52
|
raise ValueError("file_path cannot be empty")
|
|
51
53
|
|
|
54
|
+
if any(ord(ch) < 0x20 or ord(ch) == 0x7F for ch in path_str):
|
|
55
|
+
raise ValueError("file_path must not contain control characters")
|
|
56
|
+
|
|
52
57
|
return Path(file_path)
|
|
53
58
|
|
|
54
59
|
|
|
@@ -540,11 +545,11 @@ def load_session(
|
|
|
540
545
|
session = {k: v for k, v in session.items() if k not in exclude}
|
|
541
546
|
|
|
542
547
|
# グローバル変数に更新
|
|
543
|
-
|
|
548
|
+
loaded_names: list[str] = []
|
|
544
549
|
for k, v in session.items():
|
|
545
550
|
try:
|
|
546
551
|
globals_dict[k] = v
|
|
547
|
-
|
|
552
|
+
loaded_names.append(k)
|
|
548
553
|
if verbose:
|
|
549
554
|
print(f"Loaded variable: {k} ({type(v).__name__})")
|
|
550
555
|
except Exception as e:
|
|
@@ -552,6 +557,6 @@ def load_session(
|
|
|
552
557
|
warnings.warn(f"Failed to load variable '{k}': {str(e)}", UserWarning, stacklevel=2)
|
|
553
558
|
|
|
554
559
|
if verbose:
|
|
555
|
-
print(f"Loaded {len(
|
|
560
|
+
print(f"Loaded {len(loaded_names)} variables")
|
|
556
561
|
|
|
557
562
|
return session
|
|
@@ -118,7 +118,7 @@ def safe_execute(
|
|
|
118
118
|
func: Callable[..., T],
|
|
119
119
|
*args: Any,
|
|
120
120
|
default: Optional[T] = None,
|
|
121
|
-
on_error: Callable[[Exception], Optional[T]] = None,
|
|
121
|
+
on_error: Optional[Callable[[Exception], Optional[T]]] = None,
|
|
122
122
|
**kwargs: Any
|
|
123
123
|
) -> Optional[T]:
|
|
124
124
|
"""
|
|
@@ -9,7 +9,8 @@ SessionSmith カスタム例外クラス
|
|
|
9
9
|
│ ├── SSMNotInitializedError
|
|
10
10
|
│ ├── SSMCommitNotFoundError
|
|
11
11
|
│ ├── SSMNoCommitsError
|
|
12
|
-
│
|
|
12
|
+
│ ├── SSMConfigError
|
|
13
|
+
│ └── SSMLockError
|
|
13
14
|
├── SessionError (セッション操作関連)
|
|
14
15
|
│ ├── SessionSaveError
|
|
15
16
|
│ ├── SessionLoadError
|
|
@@ -134,6 +135,40 @@ class SSMRemoteNotFoundError(SSMError):
|
|
|
134
135
|
super().__init__(message, details={"remote_name": remote_name})
|
|
135
136
|
|
|
136
137
|
|
|
138
|
+
class SSMLockError(SSMError):
|
|
139
|
+
"""`.ssm` リポジトリのプロセス間ロック取得がタイムアウトした場合の例外"""
|
|
140
|
+
|
|
141
|
+
def __init__(
|
|
142
|
+
self,
|
|
143
|
+
ssm_path: str,
|
|
144
|
+
holder_pid: Optional[int] = None,
|
|
145
|
+
timeout: Optional[float] = None,
|
|
146
|
+
):
|
|
147
|
+
self.ssm_path = ssm_path
|
|
148
|
+
self.holder_pid = holder_pid
|
|
149
|
+
self.timeout = timeout
|
|
150
|
+
|
|
151
|
+
if holder_pid:
|
|
152
|
+
holder_desc = f"currently held by PID {holder_pid}"
|
|
153
|
+
else:
|
|
154
|
+
holder_desc = "holder could not be determined"
|
|
155
|
+
timeout_desc = f"{timeout:.1f}s" if timeout is not None else "the configured timeout"
|
|
156
|
+
|
|
157
|
+
message = (
|
|
158
|
+
f"Timed out after {timeout_desc} waiting for the lock on '{ssm_path}' "
|
|
159
|
+
f"({holder_desc}). Another process or thread is likely writing to this "
|
|
160
|
+
f".ssm repository. If no other process is actually using it, the lock "
|
|
161
|
+
f"file ('.lock') may be stale from a crashed process; SessionSmith "
|
|
162
|
+
f"automatically reclaims stale locks once the holder process is "
|
|
163
|
+
f"confirmed dead or the lock exceeds its max age, so retrying shortly "
|
|
164
|
+
f"usually resolves this."
|
|
165
|
+
)
|
|
166
|
+
super().__init__(
|
|
167
|
+
message,
|
|
168
|
+
details={"ssm_path": ssm_path, "holder_pid": holder_pid, "timeout": timeout},
|
|
169
|
+
)
|
|
170
|
+
|
|
171
|
+
|
|
137
172
|
class SSMMergeConflictError(SSMError):
|
|
138
173
|
"""マージコンフリクトが発生した場合の例外"""
|
|
139
174
|
|
|
@@ -141,7 +176,14 @@ class SSMMergeConflictError(SSMError):
|
|
|
141
176
|
self.branch_name = branch_name
|
|
142
177
|
self.conflicts = conflicts
|
|
143
178
|
i18n = _get_i18n()
|
|
144
|
-
|
|
179
|
+
# コンフリクトした変数名をメッセージ本文にも含める(呼び出し側が
|
|
180
|
+
# str(exc) だけを見ても、どの変数が衝突したか分かるようにするため)
|
|
181
|
+
message = i18n.translate(
|
|
182
|
+
"error.merge_conflict",
|
|
183
|
+
branch_name=branch_name,
|
|
184
|
+
count=len(conflicts),
|
|
185
|
+
vars=", ".join(conflicts),
|
|
186
|
+
)
|
|
145
187
|
super().__init__(message, details={"branch_name": branch_name, "conflicts": conflicts})
|
|
146
188
|
|
|
147
189
|
|
|
@@ -53,7 +53,7 @@ _TRANSLATIONS: dict[str, dict[str, str]] = {
|
|
|
53
53
|
"error.remote_no_url": "リモート '{remote_name}' にURLが設定されていません",
|
|
54
54
|
"error.remote_repository_not_found": "リモートリポジトリが見つかりません: {remote_url}",
|
|
55
55
|
"error.tag_no_commit": "タグ '{tag_name}' にコミットが設定されていません",
|
|
56
|
-
"error.merge_conflict": "マージコンフリクトが発生しました: ブランチ '{branch_name}' で {count} 個のコンフリクト",
|
|
56
|
+
"error.merge_conflict": "マージコンフリクトが発生しました: ブランチ '{branch_name}' で {count} 個のコンフリクト ({vars})",
|
|
57
57
|
|
|
58
58
|
# 警告メッセージ
|
|
59
59
|
"warn.large_variable": "大きな変数が検出されました: '{name}' ({size_mb:.1f}MB)",
|
|
@@ -62,6 +62,7 @@ _TRANSLATIONS: dict[str, dict[str, str]] = {
|
|
|
62
62
|
"warn.checkpoint_failed": "チェックポイントの保存に失敗しました: {reason}",
|
|
63
63
|
"warn.continuous_mode_unavailable": "常時記録モードはJupyter/IPython環境でのみ利用可能です",
|
|
64
64
|
"warn.variable_conflict": "変数名の衝突が検出されました: ファイル '{previous_file}' と '{current_file}' で同じ変数名 ({vars}) が使用されています。複数のファイルから同じ変数名を使用する場合は注意してください。",
|
|
65
|
+
"warn.merge_conflict": "マージコンフリクトを検出しました: ブランチ '{branch_name}' で変数 {vars} が共通祖先から両側で異なる値に変更されています。マージ呼び出し時点でセッションに存在する値が採用されます(last-writer-wins)。",
|
|
65
66
|
|
|
66
67
|
# 情報メッセージ
|
|
67
68
|
"info.session_saved": "セッションを保存しました: {file_path} ({size:,} bytes, 形式: {format})",
|
|
@@ -122,7 +123,7 @@ _TRANSLATIONS: dict[str, dict[str, str]] = {
|
|
|
122
123
|
"error.remote_no_url": "Remote '{remote_name}' has no URL",
|
|
123
124
|
"error.remote_repository_not_found": "Remote repository not found: {remote_url}",
|
|
124
125
|
"error.tag_no_commit": "Tag '{tag_name}' has no commit",
|
|
125
|
-
"error.merge_conflict": "Merge conflict: {count} conflicts in branch '{branch_name}'",
|
|
126
|
+
"error.merge_conflict": "Merge conflict: {count} conflicts in branch '{branch_name}' ({vars})",
|
|
126
127
|
|
|
127
128
|
# Warning messages
|
|
128
129
|
"warn.large_variable": "Large variable detected: '{name}' ({size_mb:.1f}MB)",
|
|
@@ -131,6 +132,7 @@ _TRANSLATIONS: dict[str, dict[str, str]] = {
|
|
|
131
132
|
"warn.checkpoint_failed": "Checkpoint save failed: {reason}",
|
|
132
133
|
"warn.continuous_mode_unavailable": "Continuous mode is only available in Jupyter/IPython environment",
|
|
133
134
|
"warn.variable_conflict": "Variable name conflict detected: same variable names ({vars}) used in '{previous_file}' and '{current_file}'. Be careful when using the same variable names from multiple files.",
|
|
135
|
+
"warn.merge_conflict": "Merge conflict detected: variables {vars} were changed differently on both sides since the common ancestor in branch '{branch_name}'. The value currently in the session at merge time will be used (last-writer-wins).",
|
|
134
136
|
"warn.disk_warning": "Disk space warning: {usage_percent:.1f}% used ({free_mb:.1f}MB free)",
|
|
135
137
|
"warn.disk_critical": "Disk space critical: {usage_percent:.1f}% used ({free_mb:.1f}MB free)",
|
|
136
138
|
"warn.memory_warning": "Memory usage warning: {usage_percent:.1f}% used ({used_mb:.1f}MB used, {available_mb:.1f}MB available)",
|
|
@@ -210,7 +212,7 @@ def get_language() -> str:
|
|
|
210
212
|
if _current_language == Language.AUTO:
|
|
211
213
|
return detect_language()
|
|
212
214
|
else:
|
|
213
|
-
return _current_language.value
|
|
215
|
+
return str(_current_language.value)
|
|
214
216
|
|
|
215
217
|
|
|
216
218
|
def set_language(lang: Union[str, Language], save_to_ssm: bool = True) -> None:
|
|
@@ -31,7 +31,6 @@ def _load_session_file(file_path: Union[str, Path], format: Optional[str] = None
|
|
|
31
31
|
if not file_path.is_file():
|
|
32
32
|
raise ValueError(f"'{file_path}' is not a file.")
|
|
33
33
|
|
|
34
|
-
compression: Optional[str] = None
|
|
35
34
|
from .formats import detect_format, load_hdf5, load_json, load_msgpack, load_pickle
|
|
36
35
|
|
|
37
36
|
session: dict[str, Any] = {}
|
|
@@ -0,0 +1,385 @@
|
|
|
1
|
+
"""
|
|
2
|
+
SessionSmith プロセス間ロック (.ssm リポジトリ単位)
|
|
3
|
+
|
|
4
|
+
複数のプロセス(および同一プロセス内の複数スレッド)が同じ `.ssm`
|
|
5
|
+
リポジトリに同時にアクセスしても、履歴(HEAD / ブランチ参照 / コミット /
|
|
6
|
+
オブジェクト)が破損しないようにするための排他ロックを提供します。
|
|
7
|
+
|
|
8
|
+
設計方針
|
|
9
|
+
--------
|
|
10
|
+
1. **クロスプラットフォーム**: POSIX / Windows のどちらでも同じ意味で
|
|
11
|
+
動作する標準ライブラリのプリミティブのみを使用します。
|
|
12
|
+
``os.open(path, os.O_CREAT | os.O_EXCL | os.O_WRONLY)`` は、対象パスに
|
|
13
|
+
ファイルが存在しない場合に限りアトミックに新規作成する、という保証が
|
|
14
|
+
POSIX と Windows の両方の `open()`/`CreateFile()` 実装で成り立つため、
|
|
15
|
+
これをロック取得のプリミティブとして採用しています
|
|
16
|
+
(`fcntl`/`msvcrt` のようなOS別APIを避けられる)。
|
|
17
|
+
|
|
18
|
+
2. **ロックファイル**: `<ssm_path>/.lock` に、保持者の PID と取得時刻を
|
|
19
|
+
JSON で書き込みます。診断(誰が保持しているか)と stale ロックの
|
|
20
|
+
判定に使います。
|
|
21
|
+
|
|
22
|
+
3. **タイムアウト**: 取得できない場合はポーリング(デフォルト 50ms間隔)
|
|
23
|
+
で再試行し、`timeout` 秒(デフォルト 10 秒)を超えると
|
|
24
|
+
`SSMLockError` を送出します。メッセージには対象の `.ssm` パスと、
|
|
25
|
+
分かる場合は現在の保持者 PID を含めます。
|
|
26
|
+
|
|
27
|
+
4. **stale ロックの回収**: ロックファイルが残っていても、それが
|
|
28
|
+
「もう有効でない残骸」と判断できる場合のみ回収(削除して取得し直し)
|
|
29
|
+
します。判定は保持者 PID の生存状態を3値(dead / alive / unknown)で
|
|
30
|
+
見て行います(詳細は ``_reclaim_if_stale`` を参照):
|
|
31
|
+
|
|
32
|
+
- **自プロセスの PID** の残骸 → 回収(前回 release の unlink 失敗や
|
|
33
|
+
PID 再利用による残骸。自己ロックアウトの回避)。
|
|
34
|
+
- **死亡が確認できた保持者**(POSIX: ``os.kill(pid, 0)`` が
|
|
35
|
+
`ProcessLookupError`)→ 回収。
|
|
36
|
+
- **生存が確認できた保持者** → **決して回収しない**。mtime が古くても、
|
|
37
|
+
大きな pull/merge/checkpoint 等で長時間ロックを保持している正当な
|
|
38
|
+
保持者を横取りして `.ssm` を破損させないため、待機/タイムアウトする。
|
|
39
|
+
- **生死が判定できない場合**(PID 不明、Windows で ``os.kill`` を生存
|
|
40
|
+
判定に使わない方針、権限不足 等)→ ``STALE_LOCK_MAX_AGE`` 秒より
|
|
41
|
+
古いロックファイルを stale とみなす **年齢ベースのフォールバック**を
|
|
42
|
+
適用する。この age ベースの回収は「生死判定不能」な場合に **限る**。
|
|
43
|
+
|
|
44
|
+
Windows では `os.kill(pid, 0)` が本来の「シグナル 0 での生存確認」
|
|
45
|
+
としては使えない(`TerminateProcess` に化けてしまう危険がある)ため、
|
|
46
|
+
PID の生死判定は POSIX でのみ行い、Windows は上記の unknown 経路
|
|
47
|
+
(age フォールバック)を使います。
|
|
48
|
+
|
|
49
|
+
5. **再入可能性(同一プロセス内)**: `.ssm` パスごとに 1 つの
|
|
50
|
+
`threading.RLock` をモジュールレベルのレジストリで保持します。
|
|
51
|
+
ある公開メソッド(例: `checkout_branch`)がロックを保持したまま、
|
|
52
|
+
内部で別の公開メソッド(例: `checkout`)を呼び出しても、
|
|
53
|
+
**同じスレッド**であれば `RLock` の再入性によって即座に
|
|
54
|
+
ロックを取得でき、デッドロックしません。
|
|
55
|
+
一方、**別スレッド**(例: バックグラウンドのチェックポイントスレッド)
|
|
56
|
+
が同時に取得しようとした場合は、`RLock` がそのスレッド間できちんと
|
|
57
|
+
排他するため、待機(またはタイムアウト)します。
|
|
58
|
+
|
|
59
|
+
実際の OS レベルのロックファイルは、その `RLock` の
|
|
60
|
+
「そのスレッドにとって最も外側の取得」(再入カウントが 0→1)の
|
|
61
|
+
タイミングでのみ作成し、「最も外側の解放」(1→0)のタイミングでのみ
|
|
62
|
+
削除します。つまり:
|
|
63
|
+
|
|
64
|
+
- プロセス内の複数スレッド間の排他 → `threading.RLock`
|
|
65
|
+
- プロセス間の排他 → `.lock` ファイル(RLock がスレッドを1つに
|
|
66
|
+
絞り込んだ後、そのスレッドだけが `.lock` ファイルを操作する)
|
|
67
|
+
|
|
68
|
+
この二層構造により、"file lock は常に1スレッドしか触らない" ことが
|
|
69
|
+
保証され、OS レベルロックの操作自体をスレッドセーフにする追加の
|
|
70
|
+
ロックは不要です。
|
|
71
|
+
"""
|
|
72
|
+
|
|
73
|
+
import json
|
|
74
|
+
import logging
|
|
75
|
+
import os
|
|
76
|
+
import threading
|
|
77
|
+
import time
|
|
78
|
+
from pathlib import Path
|
|
79
|
+
from typing import Optional, Union
|
|
80
|
+
|
|
81
|
+
from .exceptions import SSMLockError
|
|
82
|
+
|
|
83
|
+
logger = logging.getLogger("SessionSmith.locking")
|
|
84
|
+
logger.addHandler(logging.NullHandler())
|
|
85
|
+
|
|
86
|
+
# ロック取得のデフォルトタイムアウト(秒)
|
|
87
|
+
DEFAULT_TIMEOUT = 10.0
|
|
88
|
+
|
|
89
|
+
# stale ロックとみなす最大経過時間(秒)。
|
|
90
|
+
# 【重要】これは「保持者の生死が判定できない場合のみ」のフォールバック。
|
|
91
|
+
# 生存が確認できた保持者のロックは、どれだけ古くても age では奪わない
|
|
92
|
+
# (正当な長時間保持者の横取り=.ssm 破損を防ぐため)。
|
|
93
|
+
STALE_LOCK_MAX_AGE = 120.0
|
|
94
|
+
|
|
95
|
+
# 取得できなかった場合の再試行間隔(秒)
|
|
96
|
+
_POLL_INTERVAL = 0.05
|
|
97
|
+
|
|
98
|
+
# ロックファイル削除のリトライ回数と基準バックオフ(秒)
|
|
99
|
+
_UNLINK_RETRIES = 3
|
|
100
|
+
_UNLINK_BACKOFF = 0.01
|
|
101
|
+
|
|
102
|
+
LOCK_FILENAME = ".lock"
|
|
103
|
+
|
|
104
|
+
# .ssm パス(正規化済み文字列)ごとの threading.RLock レジストリ。
|
|
105
|
+
# 複数の ProcessLock インスタンス(毎回 `with self._lock():` で新規生成
|
|
106
|
+
# される)が、同じ .ssm パスに対しては必ず同じ RLock を共有するための
|
|
107
|
+
# もの。レジストリ自体への同時書き込みは _registry_guard で保護する。
|
|
108
|
+
_registry_guard = threading.Lock()
|
|
109
|
+
_thread_rlocks: dict[str, threading.RLock] = {}
|
|
110
|
+
_reentry_counts: dict[str, int] = {}
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _get_rlock(key: str) -> threading.RLock:
|
|
114
|
+
"""指定キーに対応する(プロセス内で共有される)RLock を取得する"""
|
|
115
|
+
with _registry_guard:
|
|
116
|
+
rlock = _thread_rlocks.get(key)
|
|
117
|
+
if rlock is None:
|
|
118
|
+
rlock = threading.RLock()
|
|
119
|
+
_thread_rlocks[key] = rlock
|
|
120
|
+
return rlock
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _pid_liveness(pid: Optional[int]) -> str:
|
|
124
|
+
"""
|
|
125
|
+
PID の生存状態を3値で返す: ``"dead"`` / ``"alive"`` / ``"unknown"``。
|
|
126
|
+
|
|
127
|
+
- ``"dead"`` : プロセスが存在しないと**確認できた**(安全に回収してよい)
|
|
128
|
+
- ``"alive"`` : プロセスが存在すると**確認できた**(正当な保持者。回収禁止)
|
|
129
|
+
- ``"unknown"``: 生死を**判定できない**(回収可否は age フォールバックで判断)
|
|
130
|
+
|
|
131
|
+
POSIX でのみ ``os.kill(pid, 0)`` による生存確認を行う。Windows では
|
|
132
|
+
`os.kill` がシグナル配送ではなく `TerminateProcess` にマップされて
|
|
133
|
+
おり、シグナル 0 を安全に「生存確認のみ」に使えないため、判定せず
|
|
134
|
+
``"unknown"`` を返す(呼び出し側が age ベースにフォールバックする)。
|
|
135
|
+
PID が None・0・負値の場合も、安全のため生死判定に使わず ``"unknown"``。
|
|
136
|
+
"""
|
|
137
|
+
if pid is None or os.name == "nt":
|
|
138
|
+
return "unknown"
|
|
139
|
+
if pid <= 0:
|
|
140
|
+
# os.kill(0/-1, sig) はプロセスグループへの送信になり危険なので触らない
|
|
141
|
+
return "unknown"
|
|
142
|
+
try:
|
|
143
|
+
os.kill(pid, 0)
|
|
144
|
+
except ProcessLookupError:
|
|
145
|
+
return "dead"
|
|
146
|
+
except PermissionError:
|
|
147
|
+
# プロセスは存在するが、こちらにシグナルを送る権限がない → 生存確認できた
|
|
148
|
+
return "alive"
|
|
149
|
+
except OSError:
|
|
150
|
+
# その他の理由で判定できない
|
|
151
|
+
return "unknown"
|
|
152
|
+
else:
|
|
153
|
+
return "alive"
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
class ProcessLock:
|
|
157
|
+
"""
|
|
158
|
+
`.ssm` リポジトリ単位のプロセス間・スレッド間排他ロック。
|
|
159
|
+
|
|
160
|
+
使い方::
|
|
161
|
+
|
|
162
|
+
with ProcessLock(ssm_path, timeout=10.0):
|
|
163
|
+
... HEAD / branches / commits / objects を読み書き ...
|
|
164
|
+
|
|
165
|
+
Args:
|
|
166
|
+
ssm_path: `.ssm` ディレクトリのパス
|
|
167
|
+
timeout: 取得を待つ最大秒数(デフォルト 10 秒)
|
|
168
|
+
poll_interval: リトライ間隔(秒)
|
|
169
|
+
"""
|
|
170
|
+
|
|
171
|
+
def __init__(
|
|
172
|
+
self,
|
|
173
|
+
ssm_path: Union[str, Path],
|
|
174
|
+
timeout: float = DEFAULT_TIMEOUT,
|
|
175
|
+
poll_interval: float = _POLL_INTERVAL,
|
|
176
|
+
):
|
|
177
|
+
self.ssm_path = Path(ssm_path)
|
|
178
|
+
self.lock_path = self.ssm_path / LOCK_FILENAME
|
|
179
|
+
self.timeout = timeout
|
|
180
|
+
self.poll_interval = poll_interval
|
|
181
|
+
# シンボリックリンクや相対/絶対パスの違いを吸収し、同じ物理ディレクトリが
|
|
182
|
+
# 常に同じキーにマップされるようにする(存在しなくても resolve() は失敗しない)
|
|
183
|
+
self._key = str(self.ssm_path.resolve())
|
|
184
|
+
self._rlock = _get_rlock(self._key)
|
|
185
|
+
self._rlock_acquired = False
|
|
186
|
+
self._holds_os_lock = False
|
|
187
|
+
|
|
188
|
+
def __enter__(self) -> "ProcessLock":
|
|
189
|
+
deadline = time.monotonic() + self.timeout
|
|
190
|
+
incremented = False
|
|
191
|
+
|
|
192
|
+
# __enter__ 全体を BaseException で保護する。KeyboardInterrupt や
|
|
193
|
+
# SystemExit(ssm.py の signal handler 経由で commit 中などに発生し得る)
|
|
194
|
+
# が取得の途中で飛んでも、__exit__ は呼ばれない。ここで確保済みの
|
|
195
|
+
# RLock / OS ロック / reentry カウントを確実に巻き戻し、部分取得状態を
|
|
196
|
+
# 残さないことで、その .ssm パスへの以降のアクセスが永久に詰まる
|
|
197
|
+
# (RLock リーク)のを防ぐ。
|
|
198
|
+
try:
|
|
199
|
+
remaining = max(0.0, deadline - time.monotonic())
|
|
200
|
+
acquired = self._rlock.acquire(timeout=remaining)
|
|
201
|
+
if not acquired:
|
|
202
|
+
# 同一プロセス内の別スレッドがロックを保持している。
|
|
203
|
+
# ロックファイルが読めない場合の保持者は「不明(None)」とする
|
|
204
|
+
# (待機側自身の PID を既定にすると誤解を招くため)。
|
|
205
|
+
holder = self._read_holder_info()
|
|
206
|
+
raise SSMLockError(
|
|
207
|
+
str(self.ssm_path),
|
|
208
|
+
holder_pid=holder.get("pid") if holder else None,
|
|
209
|
+
timeout=self.timeout,
|
|
210
|
+
)
|
|
211
|
+
self._rlock_acquired = True
|
|
212
|
+
|
|
213
|
+
with _registry_guard:
|
|
214
|
+
count = _reentry_counts.get(self._key, 0)
|
|
215
|
+
|
|
216
|
+
if count == 0:
|
|
217
|
+
# このスレッドにとって最も外側の取得 → OS レベルのロックファイルを取る
|
|
218
|
+
self._acquire_os_lock(deadline)
|
|
219
|
+
self._holds_os_lock = True
|
|
220
|
+
|
|
221
|
+
with _registry_guard:
|
|
222
|
+
_reentry_counts[self._key] = _reentry_counts.get(self._key, 0) + 1
|
|
223
|
+
incremented = True
|
|
224
|
+
|
|
225
|
+
return self
|
|
226
|
+
except BaseException:
|
|
227
|
+
# 途中まで確保したものを、取得と逆順に巻き戻す
|
|
228
|
+
if incremented:
|
|
229
|
+
with _registry_guard:
|
|
230
|
+
_reentry_counts[self._key] = max(
|
|
231
|
+
0, _reentry_counts.get(self._key, 1) - 1
|
|
232
|
+
)
|
|
233
|
+
if self._holds_os_lock:
|
|
234
|
+
self._release_os_lock()
|
|
235
|
+
self._holds_os_lock = False
|
|
236
|
+
if self._rlock_acquired:
|
|
237
|
+
self._rlock_acquired = False
|
|
238
|
+
self._rlock.release()
|
|
239
|
+
raise
|
|
240
|
+
|
|
241
|
+
def __exit__(self, exc_type, exc, tb) -> None:
|
|
242
|
+
try:
|
|
243
|
+
with _registry_guard:
|
|
244
|
+
count = max(0, _reentry_counts.get(self._key, 1) - 1)
|
|
245
|
+
_reentry_counts[self._key] = count
|
|
246
|
+
|
|
247
|
+
if count == 0 and self._holds_os_lock:
|
|
248
|
+
self._release_os_lock()
|
|
249
|
+
self._holds_os_lock = False
|
|
250
|
+
finally:
|
|
251
|
+
if self._rlock_acquired:
|
|
252
|
+
self._rlock_acquired = False
|
|
253
|
+
self._rlock.release()
|
|
254
|
+
return None
|
|
255
|
+
|
|
256
|
+
# ------------------------------------------------------------------
|
|
257
|
+
# OS レベルのロックファイル操作(この時点で呼び出しスレッドは、
|
|
258
|
+
# このプロセス内で当該 .ssm パスに対する唯一の候補者であることが
|
|
259
|
+
# RLock によって保証されている)
|
|
260
|
+
# ------------------------------------------------------------------
|
|
261
|
+
|
|
262
|
+
def _acquire_os_lock(self, deadline: float) -> None:
|
|
263
|
+
while True:
|
|
264
|
+
try:
|
|
265
|
+
fd = os.open(str(self.lock_path), os.O_CREAT | os.O_EXCL | os.O_WRONLY)
|
|
266
|
+
try:
|
|
267
|
+
payload = json.dumps(
|
|
268
|
+
{"pid": os.getpid(), "started_at": time.time()}
|
|
269
|
+
).encode("utf-8")
|
|
270
|
+
os.write(fd, payload)
|
|
271
|
+
finally:
|
|
272
|
+
os.close(fd)
|
|
273
|
+
return
|
|
274
|
+
except FileExistsError:
|
|
275
|
+
if self._reclaim_if_stale():
|
|
276
|
+
# stale ロックを消せたので即座に取得し直す
|
|
277
|
+
continue
|
|
278
|
+
if time.monotonic() >= deadline:
|
|
279
|
+
holder = self._read_holder_info()
|
|
280
|
+
raise SSMLockError(
|
|
281
|
+
str(self.ssm_path),
|
|
282
|
+
holder_pid=holder.get("pid") if holder else None,
|
|
283
|
+
timeout=self.timeout,
|
|
284
|
+
)
|
|
285
|
+
time.sleep(min(self.poll_interval, max(0.0, deadline - time.monotonic())) or self.poll_interval)
|
|
286
|
+
|
|
287
|
+
def _release_os_lock(self) -> None:
|
|
288
|
+
# 一時的な unlink 失敗(AV/バックアップによる一時ロック等)を数回リトライ。
|
|
289
|
+
# 全 OSError を黙って捨てると、自PIDのロックファイルが残った際に
|
|
290
|
+
# 生存判定が alive を返してしまい、次回取得が age 期限まで待たされる
|
|
291
|
+
# (自己ロックアウト)。取得側は own-PID 残骸を回収できるようにして
|
|
292
|
+
# あるが、それでも最終失敗時は必ず warning を出す。
|
|
293
|
+
last_err: Optional[OSError] = None
|
|
294
|
+
for attempt in range(_UNLINK_RETRIES):
|
|
295
|
+
try:
|
|
296
|
+
os.unlink(self.lock_path)
|
|
297
|
+
return
|
|
298
|
+
except FileNotFoundError:
|
|
299
|
+
return
|
|
300
|
+
except OSError as e:
|
|
301
|
+
last_err = e
|
|
302
|
+
time.sleep(_UNLINK_BACKOFF * (attempt + 1))
|
|
303
|
+
logger.warning(
|
|
304
|
+
"Failed to remove lock file %s after %d attempts: %s. "
|
|
305
|
+
"A stale lock file may remain; it will be reclaimed on the next "
|
|
306
|
+
"acquisition (as an own-PID remnant, once the holder is confirmed "
|
|
307
|
+
"dead, or after STALE_LOCK_MAX_AGE).",
|
|
308
|
+
self.lock_path,
|
|
309
|
+
_UNLINK_RETRIES,
|
|
310
|
+
last_err,
|
|
311
|
+
)
|
|
312
|
+
|
|
313
|
+
def _read_holder_info(self) -> Optional[dict]:
|
|
314
|
+
try:
|
|
315
|
+
raw = self.lock_path.read_text(encoding="utf-8")
|
|
316
|
+
data = json.loads(raw)
|
|
317
|
+
except (OSError, ValueError):
|
|
318
|
+
return None
|
|
319
|
+
return data if isinstance(data, dict) else None
|
|
320
|
+
|
|
321
|
+
def _reclaim_if_stale(self) -> bool:
|
|
322
|
+
"""
|
|
323
|
+
ロックファイルが stale(もう有効でない残骸)と判断できれば削除して
|
|
324
|
+
True を返す。有効な保持者のロックは決して奪わない。
|
|
325
|
+
|
|
326
|
+
判定順(重要):
|
|
327
|
+
|
|
328
|
+
1. **保持者 PID == 自プロセスの PID** → 残骸として回収。
|
|
329
|
+
この関数が呼ばれる時点で、呼び出しスレッドは当該 .ssm パスの
|
|
330
|
+
RLock を保持しており(`__enter__` 参照)、かつ reentry カウントが
|
|
331
|
+
0(=このプロセス内でまだ OS ロックを持っていない)。したがって
|
|
332
|
+
同一プロセス内の他スレッドが正当に OS ロックを保持していることは
|
|
333
|
+
あり得ず、自PIDのロックファイルは「前回 release の unlink 失敗で
|
|
334
|
+
残った残骸」か「その PID を再利用した=元の保持者は死んでいる」の
|
|
335
|
+
いずれか。どちらも安全に回収できる(自己ロックアウトの回避)。
|
|
336
|
+
|
|
337
|
+
2. **生存が確認できた保持者(liveness == "alive")** → 回収しない。
|
|
338
|
+
mtime がどれだけ古くても、正当な長時間保持者(大きな
|
|
339
|
+
pull/merge/checkpoint)を横取りして .ssm を破損させてはならない。
|
|
340
|
+
呼び出し側は待機し、必要ならタイムアウトして SSMLockError になる。
|
|
341
|
+
|
|
342
|
+
3. **死んでいると確認できた保持者(liveness == "dead")** → 回収。
|
|
343
|
+
|
|
344
|
+
4. **生死を判定できない(liveness == "unknown")** → age フォールバック。
|
|
345
|
+
mtime が STALE_LOCK_MAX_AGE より古ければ回収する。この age ベースの
|
|
346
|
+
判定は、生存が判定不能な場合(PID不明・Windows・権限不足 等)に
|
|
347
|
+
**限って**適用される。
|
|
348
|
+
"""
|
|
349
|
+
info = self._read_holder_info()
|
|
350
|
+
pid = info.get("pid") if info else None
|
|
351
|
+
|
|
352
|
+
# 1. 自プロセスの残骸ロック(自己ロックアウトの回避)
|
|
353
|
+
if pid is not None and pid == os.getpid():
|
|
354
|
+
return self._force_remove()
|
|
355
|
+
|
|
356
|
+
liveness = _pid_liveness(pid)
|
|
357
|
+
|
|
358
|
+
# 3. 死亡確認済み → 回収
|
|
359
|
+
if liveness == "dead":
|
|
360
|
+
return self._force_remove()
|
|
361
|
+
|
|
362
|
+
# 2. 生存確認済み → 決して age で奪わない
|
|
363
|
+
if liveness == "alive":
|
|
364
|
+
return False
|
|
365
|
+
|
|
366
|
+
# 4. 生死判定不能な場合のみ age フォールバック
|
|
367
|
+
try:
|
|
368
|
+
age = time.time() - self.lock_path.stat().st_mtime
|
|
369
|
+
except OSError:
|
|
370
|
+
# 既に他プロセスが削除済み
|
|
371
|
+
return True
|
|
372
|
+
|
|
373
|
+
if age > STALE_LOCK_MAX_AGE:
|
|
374
|
+
return self._force_remove()
|
|
375
|
+
|
|
376
|
+
return False
|
|
377
|
+
|
|
378
|
+
def _force_remove(self) -> bool:
|
|
379
|
+
try:
|
|
380
|
+
os.unlink(self.lock_path)
|
|
381
|
+
return True
|
|
382
|
+
except FileNotFoundError:
|
|
383
|
+
return True
|
|
384
|
+
except OSError:
|
|
385
|
+
return False
|
|
@@ -31,11 +31,18 @@ from pathlib import Path
|
|
|
31
31
|
from typing import Optional
|
|
32
32
|
from urllib.parse import urlparse
|
|
33
33
|
|
|
34
|
+
from .exceptions import ValidationError
|
|
35
|
+
from .validation import _has_control_chars
|
|
36
|
+
|
|
34
37
|
logger = logging.getLogger("SessionSmith.remote")
|
|
35
38
|
|
|
36
39
|
# 全ファイル一覧を保持するマニフェスト(HTTP のような一覧不可のバックエンド用)
|
|
37
40
|
MANIFEST_NAME = "manifest.json"
|
|
38
41
|
|
|
42
|
+
# remote_add() などで許可するリモート URL のスキーム。
|
|
43
|
+
# スキームなし(ローカルパス)・Windows ドライブレター(例: "C:")も別途許可する。
|
|
44
|
+
SUPPORTED_REMOTE_SCHEMES = frozenset({"file", "s3", "gs", "gcs", "http", "https"})
|
|
45
|
+
|
|
39
46
|
|
|
40
47
|
class RemoteBackendError(Exception):
|
|
41
48
|
"""リモートバックエンドに関するエラー"""
|
|
@@ -239,6 +246,48 @@ class HTTPBackend(RemoteBackend):
|
|
|
239
246
|
return files
|
|
240
247
|
|
|
241
248
|
|
|
249
|
+
def validate_remote_url(url: str) -> str:
|
|
250
|
+
"""
|
|
251
|
+
リモート URL のスキームが対応済みか検証します(``remote_add`` 時の早期検証用)。
|
|
252
|
+
|
|
253
|
+
ローカルパス(スキームなし。Windows ドライブレターを含む)と
|
|
254
|
+
``file://`` / ``s3://`` / ``gs://`` (``gcs://``) / ``http(s)://`` を許可します。
|
|
255
|
+
それ以外のスキーム(``ftp://``, ``javascript:`` など)は拒否します。
|
|
256
|
+
|
|
257
|
+
``get_backend()`` は push/pull 実行時に同様の判定を行い ``RemoteBackendError``
|
|
258
|
+
を送出しますが、こちらは ``remote_add`` の時点で早期に、かつ既存の
|
|
259
|
+
``ValidationError`` で失敗させるためのものです。
|
|
260
|
+
|
|
261
|
+
Args:
|
|
262
|
+
url: リモート URL
|
|
263
|
+
|
|
264
|
+
Returns:
|
|
265
|
+
str: 検証済みの URL(そのまま返す)
|
|
266
|
+
|
|
267
|
+
Raises:
|
|
268
|
+
ValidationError: URL が空、制御文字を含む、または未対応のスキームの場合
|
|
269
|
+
"""
|
|
270
|
+
if not isinstance(url, str) or not url.strip():
|
|
271
|
+
raise ValidationError("url", "Remote URL must not be empty", url)
|
|
272
|
+
|
|
273
|
+
if _has_control_chars(url):
|
|
274
|
+
raise ValidationError("url", "Remote URL must not contain control characters", url)
|
|
275
|
+
|
|
276
|
+
parsed = urlparse(url)
|
|
277
|
+
scheme = parsed.scheme.lower()
|
|
278
|
+
|
|
279
|
+
# スキームなし = ローカルパス、1文字 = Windows ドライブレター(C: など)
|
|
280
|
+
if scheme == "" or len(scheme) == 1 or scheme in SUPPORTED_REMOTE_SCHEMES:
|
|
281
|
+
return url
|
|
282
|
+
|
|
283
|
+
raise ValidationError(
|
|
284
|
+
"url",
|
|
285
|
+
f"Unsupported remote scheme: '{scheme}://' "
|
|
286
|
+
"(supported: local path, file, s3, gs/gcs, http, https)",
|
|
287
|
+
url,
|
|
288
|
+
)
|
|
289
|
+
|
|
290
|
+
|
|
242
291
|
def get_backend(url: str, *, local_ssm_dirname: str = ".ssm") -> RemoteBackend:
|
|
243
292
|
"""
|
|
244
293
|
URL からリモートバックエンドを生成します。
|