interlocking 0.0.1__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.
- interlocking/__init__.py +49 -0
- interlocking/cli.py +264 -0
- interlocking/dispatcher.py +471 -0
- interlocking/lambda_.py +186 -0
- interlocking/line.py +78 -0
- interlocking/models.py +191 -0
- interlocking/station.py +428 -0
- interlocking-0.0.1.dist-info/METADATA +82 -0
- interlocking-0.0.1.dist-info/RECORD +12 -0
- interlocking-0.0.1.dist-info/WHEEL +4 -0
- interlocking-0.0.1.dist-info/entry_points.txt +2 -0
- interlocking-0.0.1.dist-info/licenses/LICENSE +21 -0
interlocking/station.py
ADDED
|
@@ -0,0 +1,428 @@
|
|
|
1
|
+
"""
|
|
2
|
+
interlocking.station
|
|
3
|
+
======================
|
|
4
|
+
Station の抽象基底クラスと LocalStation の実装。
|
|
5
|
+
|
|
6
|
+
ストレージバックエンドの差し替えは Station を継承して実現する。
|
|
7
|
+
現在提供する実装:
|
|
8
|
+
- LocalStation: Windows/Linux ローカルファイルロック(単一ホスト専用)
|
|
9
|
+
- S3Station: S3 Conditional Write による分散ロック(複数ホスト対応)
|
|
10
|
+
"""
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import json
|
|
14
|
+
import logging
|
|
15
|
+
import os
|
|
16
|
+
import sys
|
|
17
|
+
import time
|
|
18
|
+
import uuid
|
|
19
|
+
from abc import ABC, abstractmethod
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
from typing import Dict
|
|
22
|
+
|
|
23
|
+
from .models import SignalStatus, TrainRecord
|
|
24
|
+
|
|
25
|
+
logger = logging.getLogger(__name__)
|
|
26
|
+
|
|
27
|
+
# 確認中にロックが入れ替わった場合のやり直し回数。確定できなければ止める側に倒す。
|
|
28
|
+
_MAX_ATTEMPTS = 5
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class Station(ABC):
|
|
32
|
+
"""ストレージバックエンドの抽象基底クラス。
|
|
33
|
+
|
|
34
|
+
信号(排他ロック)と駅の記録(JSON)の読み書きを担う。
|
|
35
|
+
サブクラスで Local / S3 などを実装する。
|
|
36
|
+
|
|
37
|
+
Station はロック状態の報告だけを責務とする。引き継ぎを許可するか、
|
|
38
|
+
例外を送出するかといった判断は Train が行う。
|
|
39
|
+
"""
|
|
40
|
+
|
|
41
|
+
@abstractmethod
|
|
42
|
+
def check_signal(self, track_id: str, stale_after: int = 600) -> SignalStatus:
|
|
43
|
+
"""信号の状態を確認し、青信号ならロックを取得する。
|
|
44
|
+
|
|
45
|
+
確認と取得は原子的に行う(分散環境で競合しないため)。
|
|
46
|
+
|
|
47
|
+
Args:
|
|
48
|
+
track_id: タスク識別子("line_name:track_name" 形式)。
|
|
49
|
+
stale_after: この秒数が経過したロックを stale(前回ロックの残骸)とみなす。
|
|
50
|
+
時間で判定しない実装(OS ロック等)は無視してよい。
|
|
51
|
+
|
|
52
|
+
Returns:
|
|
53
|
+
CLEAR: ロックを取得した。
|
|
54
|
+
BLOCKED: 他の列車が運行中。ロックは取得していない。
|
|
55
|
+
STALE: 前回ロックの残骸。ロックは取得していない(引き継ぎは takeover_signal)。
|
|
56
|
+
"""
|
|
57
|
+
|
|
58
|
+
@abstractmethod
|
|
59
|
+
def takeover_signal(self, track_id: str, stale_after: int = 600) -> bool:
|
|
60
|
+
"""stale なロックを引き継いで取得する。
|
|
61
|
+
|
|
62
|
+
複数のプロセスが同時に引き継ごうとしても、取得できるのは 1 つだけ。
|
|
63
|
+
|
|
64
|
+
Returns:
|
|
65
|
+
True: 引き継いでロックを取得した。
|
|
66
|
+
False: 取得できなかった(他のプロセスが先に取得した、または stale ではなくなった)。
|
|
67
|
+
"""
|
|
68
|
+
|
|
69
|
+
@abstractmethod
|
|
70
|
+
def clear_signal(self, track_id: str) -> None:
|
|
71
|
+
"""信号を解放する(Unlock)。"""
|
|
72
|
+
|
|
73
|
+
@abstractmethod
|
|
74
|
+
def abort(self, track_id: str) -> None:
|
|
75
|
+
"""信号を強制解除する。
|
|
76
|
+
|
|
77
|
+
対象タスクのプロセスが終了済みであることを確認してから実行すること。
|
|
78
|
+
プロセスが生きている状態では効果がない場合がある(実装依存)。
|
|
79
|
+
"""
|
|
80
|
+
|
|
81
|
+
@abstractmethod
|
|
82
|
+
def load_record(self, track_id: str) -> TrainRecord:
|
|
83
|
+
"""列車の運行記録を読み込む。
|
|
84
|
+
|
|
85
|
+
ファイルが存在しない場合(始発)は run_id=None の空の TrainRecord を返す。
|
|
86
|
+
"""
|
|
87
|
+
|
|
88
|
+
@abstractmethod
|
|
89
|
+
def save_record(self, track_id: str, record: TrainRecord) -> None:
|
|
90
|
+
"""列車の運行記録を保存する。"""
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
class LocalStation(Station):
|
|
94
|
+
"""Windows/Linux ローカル環境の駅。
|
|
95
|
+
|
|
96
|
+
OS のファイルロックで排他制御を行う。
|
|
97
|
+
単一ホスト専用。複数ホストからの同一タスク実行を防ぐには
|
|
98
|
+
S3Station を使用すること。
|
|
99
|
+
|
|
100
|
+
Args:
|
|
101
|
+
base_path: シグナルファイルと記録ファイルを格納するディレクトリ。
|
|
102
|
+
"""
|
|
103
|
+
|
|
104
|
+
def __init__(
|
|
105
|
+
self,
|
|
106
|
+
base_path: str = ".interlocking",
|
|
107
|
+
) -> None:
|
|
108
|
+
self.base_path = Path(base_path)
|
|
109
|
+
self.base_path.mkdir(parents=True, exist_ok=True)
|
|
110
|
+
self._signals: Dict[str, int] = {} # track_id -> ファイルディスクリプタ
|
|
111
|
+
|
|
112
|
+
def _get_paths(self, track_id: str) -> tuple[Path, Path]:
|
|
113
|
+
"""シグナルファイルと記録ファイルのパスを返す。"""
|
|
114
|
+
safe_id = track_id.replace(":", "_").replace("/", "_")
|
|
115
|
+
return (
|
|
116
|
+
self.base_path / f"{safe_id}.signal",
|
|
117
|
+
self.base_path / f"{safe_id}.json",
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
@staticmethod
|
|
121
|
+
def _try_lock(fd: int) -> bool:
|
|
122
|
+
"""fd の OS ロックを非ブロッキングで試みる。取得できたら True。"""
|
|
123
|
+
try:
|
|
124
|
+
if sys.platform == "win32":
|
|
125
|
+
import msvcrt
|
|
126
|
+
msvcrt.locking(fd, msvcrt.LK_NBLCK, 1)
|
|
127
|
+
else:
|
|
128
|
+
import fcntl
|
|
129
|
+
fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
|
130
|
+
except OSError:
|
|
131
|
+
return False
|
|
132
|
+
return True
|
|
133
|
+
|
|
134
|
+
@staticmethod
|
|
135
|
+
def _unlock(fd: int) -> None:
|
|
136
|
+
if sys.platform == "win32":
|
|
137
|
+
import msvcrt
|
|
138
|
+
msvcrt.locking(fd, msvcrt.LK_UNLCK, 1)
|
|
139
|
+
else:
|
|
140
|
+
import fcntl
|
|
141
|
+
fcntl.flock(fd, fcntl.LOCK_UN)
|
|
142
|
+
|
|
143
|
+
@staticmethod
|
|
144
|
+
def _is_same_file(fd: int, path: Path) -> bool:
|
|
145
|
+
"""ロックした fd が、パス上のファイルと同一か。ロック中に削除・再作成されていないことの確認。"""
|
|
146
|
+
try:
|
|
147
|
+
on_path = os.stat(path)
|
|
148
|
+
except FileNotFoundError:
|
|
149
|
+
return False
|
|
150
|
+
held = os.fstat(fd)
|
|
151
|
+
return (held.st_ino, held.st_dev) == (on_path.st_ino, on_path.st_dev)
|
|
152
|
+
|
|
153
|
+
def _probe_existing(self, sig_path: Path) -> SignalStatus | None:
|
|
154
|
+
"""既存のシグナルファイルの状態を調べる(ロックは保持しない)。
|
|
155
|
+
|
|
156
|
+
OS ロックが有効なら実行中(BLOCKED)。ロックが取れるなら、プロセスが
|
|
157
|
+
消失してファイルだけが残っている(STALE)。確認中にファイルが
|
|
158
|
+
入れ替わった場合は None(やり直し)。
|
|
159
|
+
"""
|
|
160
|
+
try:
|
|
161
|
+
fd = os.open(sig_path, os.O_RDWR)
|
|
162
|
+
except FileNotFoundError:
|
|
163
|
+
return None
|
|
164
|
+
except PermissionError:
|
|
165
|
+
return SignalStatus.BLOCKED # 確認できないときは止める側に倒す
|
|
166
|
+
try:
|
|
167
|
+
if not self._try_lock(fd):
|
|
168
|
+
return SignalStatus.BLOCKED
|
|
169
|
+
try:
|
|
170
|
+
return SignalStatus.STALE if self._is_same_file(fd, sig_path) else None
|
|
171
|
+
finally:
|
|
172
|
+
self._unlock(fd)
|
|
173
|
+
finally:
|
|
174
|
+
os.close(fd)
|
|
175
|
+
|
|
176
|
+
def check_signal(self, track_id: str, stale_after: int = 600) -> SignalStatus:
|
|
177
|
+
"""シグナルファイルと OS ロックで、信号の状態を確認する。
|
|
178
|
+
|
|
179
|
+
OS ロックが有効な間は、経過時間によらず BLOCKED(実行中の Busy とみなす)。
|
|
180
|
+
プロセスが消失してファイルだけが残っている場合のみ STALE。
|
|
181
|
+
時間判定は使わないため、stale_after は無視する。
|
|
182
|
+
"""
|
|
183
|
+
sig_path, _ = self._get_paths(track_id)
|
|
184
|
+
for _ in range(_MAX_ATTEMPTS):
|
|
185
|
+
try:
|
|
186
|
+
fd = os.open(sig_path, os.O_RDWR | os.O_CREAT | os.O_EXCL)
|
|
187
|
+
except FileExistsError:
|
|
188
|
+
status = self._probe_existing(sig_path)
|
|
189
|
+
if status is None:
|
|
190
|
+
continue # 確認中にファイルが入れ替わった → やり直し
|
|
191
|
+
return status
|
|
192
|
+
if self._try_lock(fd):
|
|
193
|
+
self._signals[track_id] = fd
|
|
194
|
+
return SignalStatus.CLEAR
|
|
195
|
+
os.close(fd)
|
|
196
|
+
return SignalStatus.BLOCKED
|
|
197
|
+
return SignalStatus.BLOCKED # 確定できないときは止める側に倒す
|
|
198
|
+
|
|
199
|
+
def takeover_signal(self, track_id: str, stale_after: int = 600) -> bool:
|
|
200
|
+
"""プロセスが消失して残ったシグナルファイルのロックを取得する。
|
|
201
|
+
|
|
202
|
+
OS ロックが取れた場合のみ成功する(実行中のプロセスのロックは奪えない)。
|
|
203
|
+
"""
|
|
204
|
+
sig_path, _ = self._get_paths(track_id)
|
|
205
|
+
try:
|
|
206
|
+
fd = os.open(sig_path, os.O_RDWR | os.O_CREAT)
|
|
207
|
+
except OSError:
|
|
208
|
+
return False
|
|
209
|
+
if not self._try_lock(fd):
|
|
210
|
+
os.close(fd)
|
|
211
|
+
return False
|
|
212
|
+
if not self._is_same_file(fd, sig_path):
|
|
213
|
+
self._unlock(fd)
|
|
214
|
+
os.close(fd)
|
|
215
|
+
return False
|
|
216
|
+
self._signals[track_id] = fd
|
|
217
|
+
return True
|
|
218
|
+
|
|
219
|
+
def clear_signal(self, track_id: str) -> None:
|
|
220
|
+
"""ロックを解除してファイルディスクリプタを閉じ、シグナルファイルを削除する。"""
|
|
221
|
+
fd = self._signals.pop(track_id, None)
|
|
222
|
+
if fd is None:
|
|
223
|
+
return
|
|
224
|
+
try:
|
|
225
|
+
self._unlock(fd)
|
|
226
|
+
finally:
|
|
227
|
+
os.close(fd)
|
|
228
|
+
sig_path, _ = self._get_paths(track_id)
|
|
229
|
+
try:
|
|
230
|
+
sig_path.unlink()
|
|
231
|
+
except OSError:
|
|
232
|
+
pass # 削除できなかった場合は残骸として STALE と判定される(止める側に倒す)
|
|
233
|
+
|
|
234
|
+
def abort(self, track_id: str) -> None:
|
|
235
|
+
"""シグナルファイルを削除する。
|
|
236
|
+
|
|
237
|
+
対象プロセスが既に終了していれば成功する。
|
|
238
|
+
プロセスが生きている場合、削除に失敗する可能性がある(Windows)、
|
|
239
|
+
またはファイルは削除できてもロックが残る(Linux)。
|
|
240
|
+
|
|
241
|
+
想定フロー:
|
|
242
|
+
1. タスクプロセスの異常終了を確認
|
|
243
|
+
2. プロセスが完全に終了していることを確認
|
|
244
|
+
3. この abort を実行してシグナルファイルを削除
|
|
245
|
+
"""
|
|
246
|
+
self.clear_signal(track_id) # fd が残っていれば解放
|
|
247
|
+
# fd がなくても強制削除を試みる
|
|
248
|
+
sig_path, _ = self._get_paths(track_id)
|
|
249
|
+
if sig_path.exists():
|
|
250
|
+
sig_path.unlink()
|
|
251
|
+
|
|
252
|
+
def load_record(self, track_id: str) -> TrainRecord:
|
|
253
|
+
"""記録ファイルを読み込む。存在しない場合(始発)は空の TrainRecord を返す。"""
|
|
254
|
+
_, record_path = self._get_paths(track_id)
|
|
255
|
+
if record_path.exists():
|
|
256
|
+
with open(record_path, "r", encoding="utf-8") as f:
|
|
257
|
+
try:
|
|
258
|
+
return TrainRecord.from_dict(json.load(f))
|
|
259
|
+
except json.JSONDecodeError:
|
|
260
|
+
raise ValueError(f"記録ファイルの読み込みに失敗: {record_path}")
|
|
261
|
+
return TrainRecord(track_id=track_id) # run_id=None → 始発
|
|
262
|
+
|
|
263
|
+
def save_record(self, track_id: str, record: TrainRecord) -> None:
|
|
264
|
+
"""記録ファイルに保存する。"""
|
|
265
|
+
_, record_path = self._get_paths(track_id)
|
|
266
|
+
# 書き込み前にシリアライズし、失敗したときに記録ファイルを空にしない。
|
|
267
|
+
payload = json.dumps(record.to_dict(), ensure_ascii=False, indent=2)
|
|
268
|
+
with open(record_path, "w", encoding="utf-8") as f:
|
|
269
|
+
f.write(payload)
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
class S3Station(Station):
|
|
273
|
+
"""S3 を使った分散環境対応の駅。
|
|
274
|
+
|
|
275
|
+
シグナル(排他ロック)に S3 Conditional Write(IfNoneMatch='*')を使用する。
|
|
276
|
+
同一オブジェクトが存在しない場合のみ PUT が成功するため
|
|
277
|
+
複数ホストからの同時実行を防ぐことができる。
|
|
278
|
+
|
|
279
|
+
必要な S3 バケット権限:
|
|
280
|
+
s3:PutObject, s3:DeleteObject, s3:GetObject
|
|
281
|
+
|
|
282
|
+
Args:
|
|
283
|
+
bucket: S3 バケット名。
|
|
284
|
+
prefix: オブジェクトキーのプレフィックス(末尾スラッシュなし)。
|
|
285
|
+
boto3_client: boto3 の S3 クライアント。None の場合はデフォルト認証で生成。
|
|
286
|
+
|
|
287
|
+
使用例::
|
|
288
|
+
|
|
289
|
+
import boto3
|
|
290
|
+
station = S3Station(
|
|
291
|
+
bucket="my-bucket",
|
|
292
|
+
prefix="interlocking/prod",
|
|
293
|
+
boto3_client=boto3.client("s3", region_name="ap-northeast-1"),
|
|
294
|
+
)
|
|
295
|
+
"""
|
|
296
|
+
|
|
297
|
+
def __init__(
|
|
298
|
+
self,
|
|
299
|
+
bucket: str,
|
|
300
|
+
prefix: str = "interlocking",
|
|
301
|
+
boto3_client=None,
|
|
302
|
+
) -> None:
|
|
303
|
+
self.bucket = bucket
|
|
304
|
+
self.prefix = prefix.rstrip("/")
|
|
305
|
+
if boto3_client is not None:
|
|
306
|
+
self._s3 = boto3_client
|
|
307
|
+
else:
|
|
308
|
+
import boto3 as _boto3
|
|
309
|
+
self._s3 = _boto3.client("s3")
|
|
310
|
+
|
|
311
|
+
def _signal_key(self, track_id: str) -> str:
|
|
312
|
+
safe_id = track_id.replace(":", "_").replace("/", "_")
|
|
313
|
+
return f"{self.prefix}/{safe_id}.signal"
|
|
314
|
+
|
|
315
|
+
def _record_key(self, track_id: str) -> str:
|
|
316
|
+
safe_id = track_id.replace(":", "_").replace("/", "_")
|
|
317
|
+
return f"{self.prefix}/{safe_id}.json"
|
|
318
|
+
|
|
319
|
+
def _put_signal(self, key: str, **condition) -> bool:
|
|
320
|
+
"""条件付き PUT でシグナルオブジェクトを書く。競合に負けたら False。
|
|
321
|
+
|
|
322
|
+
本文は毎回ユニークにする。空の本文だと ETag(MD5)が常に同じになり、
|
|
323
|
+
IfMatch の比較が何も区別しなくなる。
|
|
324
|
+
"""
|
|
325
|
+
from botocore.exceptions import ClientError
|
|
326
|
+
try:
|
|
327
|
+
self._s3.put_object(
|
|
328
|
+
Bucket=self.bucket,
|
|
329
|
+
Key=key,
|
|
330
|
+
Body=json.dumps({"token": uuid.uuid4().hex}).encode("utf-8"),
|
|
331
|
+
ContentType="application/json",
|
|
332
|
+
**condition,
|
|
333
|
+
)
|
|
334
|
+
return True
|
|
335
|
+
except ClientError as e:
|
|
336
|
+
if e.response["Error"]["Code"] in (
|
|
337
|
+
"PreconditionFailed", "ConditionalRequestConflict", "NoSuchKey",
|
|
338
|
+
):
|
|
339
|
+
return False
|
|
340
|
+
raise
|
|
341
|
+
|
|
342
|
+
def check_signal(self, track_id: str, stale_after: int = 600) -> SignalStatus:
|
|
343
|
+
"""S3 Conditional Write でシグナルオブジェクトを排他生成する。
|
|
344
|
+
|
|
345
|
+
IfNoneMatch='*' により、オブジェクトが存在しない場合のみ PUT が成功する
|
|
346
|
+
(CLEAR)。既に存在する場合は、最終更新からの経過時間で判定する。
|
|
347
|
+
|
|
348
|
+
- stale_after 未満: BLOCKED(実行中)。
|
|
349
|
+
- stale_after 以上: STALE(前回ロックの残骸)。ロックは取得しない。
|
|
350
|
+
"""
|
|
351
|
+
from botocore.exceptions import ClientError
|
|
352
|
+
key = self._signal_key(track_id)
|
|
353
|
+
for _ in range(_MAX_ATTEMPTS):
|
|
354
|
+
if self._put_signal(key, IfNoneMatch="*"):
|
|
355
|
+
return SignalStatus.CLEAR
|
|
356
|
+
try:
|
|
357
|
+
response = self._s3.head_object(Bucket=self.bucket, Key=key)
|
|
358
|
+
except ClientError as e:
|
|
359
|
+
if e.response["Error"]["Code"] in ("NoSuchKey", "404"):
|
|
360
|
+
continue # 確認中に解放された → 取得をやり直す
|
|
361
|
+
raise
|
|
362
|
+
elapsed = time.time() - response["LastModified"].timestamp()
|
|
363
|
+
return SignalStatus.STALE if elapsed >= stale_after else SignalStatus.BLOCKED
|
|
364
|
+
return SignalStatus.BLOCKED # 確定できないときは止める側に倒す
|
|
365
|
+
|
|
366
|
+
def takeover_signal(self, track_id: str, stale_after: int = 600) -> bool:
|
|
367
|
+
"""stale なシグナルオブジェクトを、ETag を条件に上書きして引き継ぐ。
|
|
368
|
+
|
|
369
|
+
IfMatch=ETag の条件付き PUT なので、複数プロセスが同時に引き継ごうとしても
|
|
370
|
+
成功するのは 1 つだけ。削除してから再取得する方式と違い、他のプロセスが
|
|
371
|
+
取得した新しいロックを消すことがない。
|
|
372
|
+
"""
|
|
373
|
+
from botocore.exceptions import ClientError
|
|
374
|
+
key = self._signal_key(track_id)
|
|
375
|
+
try:
|
|
376
|
+
response = self._s3.head_object(Bucket=self.bucket, Key=key)
|
|
377
|
+
except ClientError as e:
|
|
378
|
+
if e.response["Error"]["Code"] in ("NoSuchKey", "404"):
|
|
379
|
+
return self._put_signal(key, IfNoneMatch="*") # 既に解放済み
|
|
380
|
+
raise
|
|
381
|
+
elapsed = time.time() - response["LastModified"].timestamp()
|
|
382
|
+
if elapsed < stale_after:
|
|
383
|
+
return False # 更新されて stale ではなくなった
|
|
384
|
+
return self._put_signal(key, IfMatch=response["ETag"])
|
|
385
|
+
|
|
386
|
+
def clear_signal(self, track_id: str) -> None:
|
|
387
|
+
"""シグナルオブジェクトを削除してロックを解放する。"""
|
|
388
|
+
from botocore.exceptions import ClientError
|
|
389
|
+
try:
|
|
390
|
+
self._s3.delete_object(
|
|
391
|
+
Bucket=self.bucket,
|
|
392
|
+
Key=self._signal_key(track_id),
|
|
393
|
+
)
|
|
394
|
+
except ClientError:
|
|
395
|
+
pass # 既に削除済みの場合は無視する
|
|
396
|
+
|
|
397
|
+
def abort(self, track_id: str) -> None:
|
|
398
|
+
"""シグナルオブジェクトを削除する。
|
|
399
|
+
|
|
400
|
+
S3版では他ホストで実行中のタスクも強制停止できる。
|
|
401
|
+
タスクプロセス側で signal を確認せずに実行を続けることがあるため、
|
|
402
|
+
使用には注意が必要(プロセスの終了を確認してから実行することを推奨)。
|
|
403
|
+
"""
|
|
404
|
+
self.clear_signal(track_id)
|
|
405
|
+
|
|
406
|
+
def load_record(self, track_id: str) -> TrainRecord:
|
|
407
|
+
"""記録オブジェクトを読み込む。存在しない場合(始発)は空の TrainRecord を返す。"""
|
|
408
|
+
from botocore.exceptions import ClientError
|
|
409
|
+
try:
|
|
410
|
+
response = self._s3.get_object(
|
|
411
|
+
Bucket=self.bucket,
|
|
412
|
+
Key=self._record_key(track_id),
|
|
413
|
+
)
|
|
414
|
+
data = json.loads(response["Body"].read().decode("utf-8"))
|
|
415
|
+
return TrainRecord.from_dict(data)
|
|
416
|
+
except ClientError as e:
|
|
417
|
+
if e.response["Error"]["Code"] == "NoSuchKey":
|
|
418
|
+
return TrainRecord(track_id=track_id) # run_id=None → 始発
|
|
419
|
+
raise
|
|
420
|
+
|
|
421
|
+
def save_record(self, track_id: str, record: TrainRecord) -> None:
|
|
422
|
+
"""記録オブジェクトを保存する。"""
|
|
423
|
+
self._s3.put_object(
|
|
424
|
+
Bucket=self.bucket,
|
|
425
|
+
Key=self._record_key(track_id),
|
|
426
|
+
Body=json.dumps(record.to_dict(), ensure_ascii=False, indent=2).encode("utf-8"),
|
|
427
|
+
ContentType="application/json",
|
|
428
|
+
)
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: interlocking
|
|
3
|
+
Version: 0.0.1
|
|
4
|
+
Summary: A lightweight task dispatcher with explicit control.
|
|
5
|
+
Keywords: task-runner,batch,exclusive-lock,cli,mutex,distributed-lock
|
|
6
|
+
Author: alpine
|
|
7
|
+
License-Expression: MIT
|
|
8
|
+
License-File: LICENSE
|
|
9
|
+
Classifier: Development Status :: 4 - Beta
|
|
10
|
+
Classifier: Intended Audience :: Developers
|
|
11
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
12
|
+
Classifier: Topic :: System :: Distributed Computing
|
|
13
|
+
Classifier: Topic :: Utilities
|
|
14
|
+
Requires-Dist: boto3>1.40.0,<2 ; extra == 'aws'
|
|
15
|
+
Requires-Python: >=3.11
|
|
16
|
+
Provides-Extra: aws
|
|
17
|
+
Description-Content-Type: text/markdown
|
|
18
|
+
|
|
19
|
+
# Interlocking
|
|
20
|
+
|
|
21
|
+
> One track, one train.
|
|
22
|
+
> Easy to run. Explicit to control.
|
|
23
|
+
|
|
24
|
+
interlocking は、分散環境におけるバックグラウンドタスクの「運行管理」フレームワークです。
|
|
25
|
+
鉄道メタファーにより、バッチタスクのライフサイクル(排他制御・実行記録・CLI 統合)を統一された方法で管理します。
|
|
26
|
+
|
|
27
|
+
## 特長
|
|
28
|
+
|
|
29
|
+
- **排他制御**: 同じタスクを同時に走らせない。
|
|
30
|
+
- **実行記録**: 直近の実行結果を記録し、次回実行に引き継ぐ(`Coupler`)。
|
|
31
|
+
- **CLI 統合**: 型ヒントからコマンドラインを組み立て、結果を終了コードで返す。
|
|
32
|
+
- **差し込み**: `Station`(ストレージ)や `TrainMiddleware` で、振る舞いを拡張できる。
|
|
33
|
+
|
|
34
|
+
## 最小構成
|
|
35
|
+
|
|
36
|
+
```python
|
|
37
|
+
# tasks.py
|
|
38
|
+
from typing import Annotated
|
|
39
|
+
from interlocking import Line, Coupler
|
|
40
|
+
|
|
41
|
+
line = Line()
|
|
42
|
+
|
|
43
|
+
@line.track(name="daily", help="日次処理")
|
|
44
|
+
def daily_task(c: Coupler, target_date: Annotated[str, "対象日付 (YYYY-MM-DD)"]) -> dict:
|
|
45
|
+
last_id = c.last_data.get("last_id", 0)
|
|
46
|
+
# ... 処理 ...
|
|
47
|
+
return {"last_id": 9999}
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
```python
|
|
51
|
+
# main.py
|
|
52
|
+
import sys
|
|
53
|
+
|
|
54
|
+
from interlocking import TrainDispatcher, LocalStation, InterlockingCLI
|
|
55
|
+
|
|
56
|
+
dispatcher = TrainDispatcher(
|
|
57
|
+
station=LocalStation(base_path=".interlocking"),
|
|
58
|
+
lines={"batch": "tasks:line"},
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
cli = InterlockingCLI(dispatcher=dispatcher, prog="my-task", description="バッチタスク")
|
|
62
|
+
sys.exit(cli.execute(cli.parse_args())) # 結果を終了コードで伝える
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
```bash
|
|
66
|
+
python main.py batch daily start --target-date 2026-04-01
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
詳しくは [docs/user/interlocking.md](docs/user/interlocking.md) を参照してください。
|
|
70
|
+
|
|
71
|
+
## 開発
|
|
72
|
+
|
|
73
|
+
```bash
|
|
74
|
+
uv sync
|
|
75
|
+
uv run pytest
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
設計方針は [.github/copilot-instructions.md](.github/copilot-instructions.md) の Design Principles、決定事項は [docs/dev/adr/](docs/dev/adr/) を参照してください。
|
|
79
|
+
|
|
80
|
+
## ライセンス
|
|
81
|
+
|
|
82
|
+
MIT
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
interlocking/__init__.py,sha256=ZS0ZBqZu_XX9xnOgTF4Pw0CqvtLKkXEuLwOLSs29bFw,1074
|
|
2
|
+
interlocking/cli.py,sha256=JjMxLI5mPgnCc2CDk6ODOqWkVFApwSM-V9gyQaxN8K0,11084
|
|
3
|
+
interlocking/dispatcher.py,sha256=BUhs4cr7VumvJWV475fGqZUxtmYLMptPHCocrBVtmZY,19149
|
|
4
|
+
interlocking/lambda_.py,sha256=gcBllml6N57lGaA7Y4YoBJOrNd7qHsgNu3Aj-HA6yM8,8553
|
|
5
|
+
interlocking/line.py,sha256=-lf2bx7w9XMgbXPoKYOGampEY2VtXB96JyX-qZq4e1E,2756
|
|
6
|
+
interlocking/models.py,sha256=HESGIva7x6hfcZ7cZYrIVn7XY9VBWZ6_ehS95rtILiY,7847
|
|
7
|
+
interlocking/station.py,sha256=MUPhTKlNc72kIJXcBQhaB-8a08l14iCuiQOXwAomVkM,17994
|
|
8
|
+
interlocking-0.0.1.dist-info/licenses/LICENSE,sha256=IkU_8deAc2SO0UoX4mX_Op7eee_2Aqc68joPEIeywVs,1063
|
|
9
|
+
interlocking-0.0.1.dist-info/WHEEL,sha256=si2p8vW03RnhyBnhWl4tj-ObJa78SpE-c9HsEtblGR0,80
|
|
10
|
+
interlocking-0.0.1.dist-info/entry_points.txt,sha256=i2oN8Ae25gU-XoxtDndkB7we3AiSvh8-LPe0e6_tgrM,19
|
|
11
|
+
interlocking-0.0.1.dist-info/METADATA,sha256=gYUG7lAmNo1CD7GYKZzkV-dDrFlhx3p15k9JEkzzfEo,2599
|
|
12
|
+
interlocking-0.0.1.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 alpine
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|