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
|
@@ -0,0 +1,471 @@
|
|
|
1
|
+
"""
|
|
2
|
+
interlocking.dispatcher
|
|
3
|
+
==================
|
|
4
|
+
TrainDispatcher と Train の実装。
|
|
5
|
+
|
|
6
|
+
TrainDispatcher が列車編成・Middleware 呼び出し・信号解除を統括する。
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import getpass
|
|
11
|
+
import importlib
|
|
12
|
+
import inspect
|
|
13
|
+
import json
|
|
14
|
+
import logging
|
|
15
|
+
import time
|
|
16
|
+
import uuid
|
|
17
|
+
from typing import (
|
|
18
|
+
Any,
|
|
19
|
+
Callable,
|
|
20
|
+
Dict,
|
|
21
|
+
Optional,
|
|
22
|
+
Set,
|
|
23
|
+
Tuple,
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
from .models import (
|
|
27
|
+
Coupler,
|
|
28
|
+
SignalBlockedError,
|
|
29
|
+
SignalStatus,
|
|
30
|
+
StaleLockError,
|
|
31
|
+
TrainRecord,
|
|
32
|
+
TrainResult,
|
|
33
|
+
)
|
|
34
|
+
from .station import Station
|
|
35
|
+
|
|
36
|
+
logger = logging.getLogger(__name__)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _default_run_by() -> str:
|
|
40
|
+
"""実行ユーザー名の既定値。Lambda など passwd に UID がない環境では "unknown"。"""
|
|
41
|
+
try:
|
|
42
|
+
return getpass.getuser()
|
|
43
|
+
except (KeyError, OSError, ImportError):
|
|
44
|
+
return "unknown"
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _task_signature(func: Callable) -> Tuple[Optional[str], list[str], Set[str]]:
|
|
48
|
+
"""タスク関数のシグネチャを解析する。
|
|
49
|
+
|
|
50
|
+
Returns:
|
|
51
|
+
(Coupler 引数名 or None, Coupler を除く引数名のリスト, そのうち必須の引数名)。
|
|
52
|
+
"""
|
|
53
|
+
argspec = inspect.getfullargspec(func)
|
|
54
|
+
coupler_arg_name = None
|
|
55
|
+
# inspect.get_annotations() を使用して "from __future__ import annotations" にも対応
|
|
56
|
+
try:
|
|
57
|
+
type_hints = inspect.get_annotations(func, eval_str=True)
|
|
58
|
+
for arg_name in argspec.args:
|
|
59
|
+
if type_hints.get(arg_name) is Coupler:
|
|
60
|
+
coupler_arg_name = arg_name
|
|
61
|
+
break
|
|
62
|
+
except Exception:
|
|
63
|
+
# 型ヒントの解決に失敗した場合は無視
|
|
64
|
+
pass
|
|
65
|
+
|
|
66
|
+
names = [a for a in argspec.args if a != coupler_arg_name]
|
|
67
|
+
n_defaults = len(argspec.defaults or ())
|
|
68
|
+
optional = set(argspec.args[len(argspec.args) - n_defaults:])
|
|
69
|
+
required = {a for a in names if a not in optional}
|
|
70
|
+
return coupler_arg_name, names, required
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
class TrainMiddleware:
|
|
74
|
+
"""interlocking のライフサイクル Middleware。デフォルト実装は logging モジュールへの出力。
|
|
75
|
+
|
|
76
|
+
dispatch(train) をオーバーライドして任意の処理を差し込む。
|
|
77
|
+
train.prepare() + train.run() を必ず呼ぶこと。呼ばなかった場合タスクは実行されない。
|
|
78
|
+
|
|
79
|
+
LoggerAdapter により extra={"run_id": ..., "run_by": ...} の指定が不要::
|
|
80
|
+
|
|
81
|
+
class SailsTaskMiddleware(TrainMiddleware):
|
|
82
|
+
def dispatch(self, train: Train) -> None:
|
|
83
|
+
token = context.run_id_var.set(train.current.run_id)
|
|
84
|
+
try:
|
|
85
|
+
super().dispatch(train) # ログ・prepare・run・記録保存を委譲
|
|
86
|
+
finally:
|
|
87
|
+
context.run_id_var.reset(token) # スコープ保証
|
|
88
|
+
|
|
89
|
+
シグナルブロック・メンテナンスウィンドウなどで実行をスキップする場合::
|
|
90
|
+
|
|
91
|
+
class SailsTaskMiddleware(TrainMiddleware):
|
|
92
|
+
def dispatch(self, train: Train) -> None:
|
|
93
|
+
if is_maintenance_mode():
|
|
94
|
+
return # 意図的にスキップ
|
|
95
|
+
super().dispatch(train)
|
|
96
|
+
"""
|
|
97
|
+
|
|
98
|
+
def dispatch(self, train: Train) -> None:
|
|
99
|
+
"""タスクのライフサイクル全体を実行する。
|
|
100
|
+
|
|
101
|
+
Args:
|
|
102
|
+
train: Train インスタンス。train.current で実行情報にアクセスできる。
|
|
103
|
+
|
|
104
|
+
Note:
|
|
105
|
+
train.prepare() / train.run() を呼ばない場合タスクは実行されない(意図的スキップ)。
|
|
106
|
+
"""
|
|
107
|
+
log = logging.LoggerAdapter(
|
|
108
|
+
logger,
|
|
109
|
+
{
|
|
110
|
+
"track_id": train.current.track_id,
|
|
111
|
+
"run_id": train.current.run_id,
|
|
112
|
+
"run_by": train.current.run_by
|
|
113
|
+
},
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
try:
|
|
117
|
+
train.prepare()
|
|
118
|
+
except StaleLockError:
|
|
119
|
+
log.warning(
|
|
120
|
+
"Signal RED (stale): %s has a leftover lock. "
|
|
121
|
+
"Recover with abort, or run with allow_stale_takeover.",
|
|
122
|
+
train.current.track_id,
|
|
123
|
+
)
|
|
124
|
+
return
|
|
125
|
+
except SignalBlockedError:
|
|
126
|
+
log.warning("Signal RED: %s is already in operation.", train.current.track_id)
|
|
127
|
+
return
|
|
128
|
+
|
|
129
|
+
if train.coupler.stale_lock:
|
|
130
|
+
log.warning(
|
|
131
|
+
"Stale lock inherited: %s (prev run_id=%s run_by=%s)",
|
|
132
|
+
train.current.track_id,
|
|
133
|
+
train.coupler.last_stop.run_id,
|
|
134
|
+
train.coupler.last_stop.run_by,
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
log.info("Departure: %s", train.current.track_id)
|
|
138
|
+
try:
|
|
139
|
+
train.run()
|
|
140
|
+
except Exception:
|
|
141
|
+
log.exception("Failed: %s", train.current.track_id)
|
|
142
|
+
|
|
143
|
+
log.info("Arrived: %s elapsed=%.1f sec", train.current.track_id, train.current.elapsed)
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
# registry の値の型: (func, help)
|
|
147
|
+
_TrackEntry = Tuple[Callable, str]
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
class Train:
|
|
151
|
+
"""列車。signal check からユーザー関数実行・記録保存までを封じ込める。
|
|
152
|
+
|
|
153
|
+
Middleware は train.current で run_id / run_by / track_id に常にアクセスできる。
|
|
154
|
+
train.prepare() 呼び出し後、train.coupler が設定される。
|
|
155
|
+
|
|
156
|
+
Args:
|
|
157
|
+
track_id: タスク識別子("line_name:track_name" 形式)。
|
|
158
|
+
current: 今回の実行情報(run_id, run_by など)。prepare() 前から参照可能。
|
|
159
|
+
func: ユーザー関数。
|
|
160
|
+
filtered: ユーザー関数に渡す CLI 引数 dict(Coupler 引数は除外済み)。
|
|
161
|
+
coupler_arg_name: Coupler 型の引数名。None の場合は Coupler 不要。
|
|
162
|
+
station: ストレージバックエンド。
|
|
163
|
+
stale_after: この秒数が経過したロックを stale(前回ロックの残骸)とみなす。
|
|
164
|
+
force: True のとき信号確認をスキップする。
|
|
165
|
+
allow_stale_takeover: True のとき、stale なロックだけを引き継いで実行する。
|
|
166
|
+
"""
|
|
167
|
+
|
|
168
|
+
def __init__(
|
|
169
|
+
self,
|
|
170
|
+
track_id: str,
|
|
171
|
+
current: TrainRecord,
|
|
172
|
+
func: Callable,
|
|
173
|
+
filtered: dict,
|
|
174
|
+
coupler_arg_name: str | None,
|
|
175
|
+
station: Station,
|
|
176
|
+
stale_after: int,
|
|
177
|
+
force: bool,
|
|
178
|
+
allow_stale_takeover: bool = False,
|
|
179
|
+
) -> None:
|
|
180
|
+
self.track_id = track_id
|
|
181
|
+
self.current = current
|
|
182
|
+
self.coupler: Optional[Coupler] = None # prepare() 後に設定される
|
|
183
|
+
self._func = func
|
|
184
|
+
self._filtered = filtered
|
|
185
|
+
self._coupler_arg_name = coupler_arg_name
|
|
186
|
+
self._station = station
|
|
187
|
+
self._stale_after = stale_after
|
|
188
|
+
self._force = force
|
|
189
|
+
self._allow_stale_takeover = allow_stale_takeover
|
|
190
|
+
# depart() が結果判定・信号解除に使う実行状態
|
|
191
|
+
self.signal_acquired = False # 自分がロックを取得した場合のみ True
|
|
192
|
+
self.blocked = False
|
|
193
|
+
self.stale = False # blocked のうち、stale(前回ロックの残骸)が原因の場合 True
|
|
194
|
+
self.started = False
|
|
195
|
+
self.error: Optional[BaseException] = None
|
|
196
|
+
|
|
197
|
+
def prepare(self) -> None:
|
|
198
|
+
"""シグナルチェック・記録ロード・Coupler 構築を行う。
|
|
199
|
+
|
|
200
|
+
Station が報告したロック状態に基づいて、例外の送出と引き継ぎの判断を行う。
|
|
201
|
+
CLEAR 以外はすべて赤信号として扱う(判断できないときは止める)。
|
|
202
|
+
|
|
203
|
+
Raises:
|
|
204
|
+
StaleLockError: stale なロックがあり、allow_stale_takeover=False の場合。
|
|
205
|
+
SignalBlockedError: force=False かつ信号が赤(多重起動)の場合。
|
|
206
|
+
"""
|
|
207
|
+
stale = False
|
|
208
|
+
if not self._force:
|
|
209
|
+
try:
|
|
210
|
+
status = self._station.check_signal(self.track_id, self._stale_after)
|
|
211
|
+
except SignalBlockedError:
|
|
212
|
+
self.blocked = True
|
|
213
|
+
raise
|
|
214
|
+
if status is SignalStatus.STALE:
|
|
215
|
+
if not self._allow_stale_takeover:
|
|
216
|
+
self.blocked = True
|
|
217
|
+
self.stale = True
|
|
218
|
+
raise StaleLockError(self.track_id)
|
|
219
|
+
if not self._station.takeover_signal(self.track_id, self._stale_after):
|
|
220
|
+
self.blocked = True # 引き継ぎの競合に負けた
|
|
221
|
+
raise SignalBlockedError(self.track_id)
|
|
222
|
+
stale = True
|
|
223
|
+
elif status is not SignalStatus.CLEAR:
|
|
224
|
+
self.blocked = True
|
|
225
|
+
raise SignalBlockedError(self.track_id)
|
|
226
|
+
self.signal_acquired = True
|
|
227
|
+
last_stop = self._station.load_record(self.track_id)
|
|
228
|
+
self.coupler = Coupler(current=self.current, last_stop=last_stop, stale_lock=stale)
|
|
229
|
+
|
|
230
|
+
@property
|
|
231
|
+
def params(self) -> dict:
|
|
232
|
+
"""タスク関数に渡す引数(Coupler を除く)のコピー。Middleware から実行引数を観測できる。"""
|
|
233
|
+
return dict(self._filtered)
|
|
234
|
+
|
|
235
|
+
def result(self) -> TrainResult:
|
|
236
|
+
"""現時点の実行状態から TrainResult を組み立てる。"""
|
|
237
|
+
if self.blocked:
|
|
238
|
+
status = "blocked"
|
|
239
|
+
elif not self.started:
|
|
240
|
+
status = "skipped"
|
|
241
|
+
else:
|
|
242
|
+
status = self.current.status
|
|
243
|
+
return TrainResult(
|
|
244
|
+
track_id=self.track_id,
|
|
245
|
+
run_id=self.current.run_id,
|
|
246
|
+
status=status,
|
|
247
|
+
data=self.current.data if status == "success" else None,
|
|
248
|
+
elapsed=self.current.elapsed,
|
|
249
|
+
error=self.error,
|
|
250
|
+
is_stale=self.stale,
|
|
251
|
+
)
|
|
252
|
+
|
|
253
|
+
def _linkable_data(self, result: Any) -> Optional[dict]:
|
|
254
|
+
"""戻り値を次回実行へ連携できる data に変換する。連携できなければ WARNING を出して None を返す。"""
|
|
255
|
+
if result is None:
|
|
256
|
+
return None
|
|
257
|
+
if not isinstance(result, dict):
|
|
258
|
+
logger.warning(
|
|
259
|
+
"Return value not linked: %s returned %s (only dict can be linked to the next run).",
|
|
260
|
+
self.track_id,
|
|
261
|
+
type(result).__name__,
|
|
262
|
+
)
|
|
263
|
+
return None
|
|
264
|
+
try:
|
|
265
|
+
json.dumps(result)
|
|
266
|
+
except (TypeError, ValueError) as e:
|
|
267
|
+
logger.warning(
|
|
268
|
+
"Return value not linked: %s returned a dict that is not JSON serializable (%s).",
|
|
269
|
+
self.track_id,
|
|
270
|
+
e,
|
|
271
|
+
)
|
|
272
|
+
return None
|
|
273
|
+
return result
|
|
274
|
+
|
|
275
|
+
def run(self) -> None:
|
|
276
|
+
"""ユーザー関数を実行し、結果を記録する。"""
|
|
277
|
+
self.started = True
|
|
278
|
+
self.current.started_at = time.time()
|
|
279
|
+
try:
|
|
280
|
+
if self._coupler_arg_name:
|
|
281
|
+
result = self._func(**{self._coupler_arg_name: self.coupler}, **self._filtered)
|
|
282
|
+
else:
|
|
283
|
+
result = self._func(**self._filtered)
|
|
284
|
+
self.current.data = self._linkable_data(result)
|
|
285
|
+
self.current.status = "success"
|
|
286
|
+
self.current.arrived_at = time.time()
|
|
287
|
+
self._station.save_record(self.track_id, self.current)
|
|
288
|
+
except Exception as e:
|
|
289
|
+
self.error = e
|
|
290
|
+
self.current.status = "failed"
|
|
291
|
+
self.current.error_type = type(e).__name__
|
|
292
|
+
self.current.error_message = str(e)
|
|
293
|
+
self.current.arrived_at = time.time()
|
|
294
|
+
self._station.save_record(self.track_id, self.current)
|
|
295
|
+
raise
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
class TrainDispatcher:
|
|
299
|
+
"""全路線の運行を統括する司令塔。
|
|
300
|
+
|
|
301
|
+
タスクの登録(registry)、列車編成、Middleware 呼び出し、信号解除を行う。
|
|
302
|
+
|
|
303
|
+
ライフサイクルをカスタマイズしたい場合は TrainMiddleware のサブクラスを作り
|
|
304
|
+
TrainDispatcher のコンストラクタに渡す。TrainDispatcher 自体はサブクラス化しない。
|
|
305
|
+
|
|
306
|
+
Args:
|
|
307
|
+
station: ストレージバックエンド。
|
|
308
|
+
lines: 路線名 -> "module.path:attr" のマッピング。
|
|
309
|
+
depart() 呼び出し時に該当する路線だけを lazy import し登録する。
|
|
310
|
+
"""
|
|
311
|
+
|
|
312
|
+
def __init__(
|
|
313
|
+
self,
|
|
314
|
+
station: Station,
|
|
315
|
+
lines: Optional[Dict[str, str]] = None,
|
|
316
|
+
middleware: Optional[TrainMiddleware] = None,
|
|
317
|
+
run_id_generator: Optional[Callable[[], str]] = None,
|
|
318
|
+
) -> None:
|
|
319
|
+
self.master = station
|
|
320
|
+
self._lines = lines or {}
|
|
321
|
+
self._loaded_lines: set = set() # ロード済み路線名
|
|
322
|
+
self.middleware = middleware or TrainMiddleware()
|
|
323
|
+
self.run_id_generator = run_id_generator or (lambda: uuid.uuid4().hex)
|
|
324
|
+
self.registry: Dict[str, _TrackEntry] = {}
|
|
325
|
+
|
|
326
|
+
@property
|
|
327
|
+
def lines(self) -> Dict[str, str]:
|
|
328
|
+
"""路線名 -> "module.path:attr" のマッピング(コピー)。"""
|
|
329
|
+
return dict(self._lines)
|
|
330
|
+
|
|
331
|
+
def abort(self, track_id: str, run_by: Optional[str] = None) -> None:
|
|
332
|
+
"""実行中フラグを強制解除し、操作の事実を TrainRecord に記録する。
|
|
333
|
+
|
|
334
|
+
status を "aborted" に更新し、aborted_at / aborted_by を記録する。
|
|
335
|
+
data / run_id などは保持する(次回実行の Coupler.last_data を失わない)。
|
|
336
|
+
error_type / error_message は前回の失敗の情報なのでクリアする。
|
|
337
|
+
|
|
338
|
+
Args:
|
|
339
|
+
track_id: タスク識別子。
|
|
340
|
+
run_by: 操作したユーザー名。省略時は getpass.getuser()。
|
|
341
|
+
"""
|
|
342
|
+
self.master.abort(track_id)
|
|
343
|
+
record = self.master.load_record(track_id)
|
|
344
|
+
record.status = "aborted"
|
|
345
|
+
record.error_type = None
|
|
346
|
+
record.error_message = None
|
|
347
|
+
record.aborted_at = time.time()
|
|
348
|
+
record.aborted_by = run_by or _default_run_by()
|
|
349
|
+
self.master.save_record(track_id, record)
|
|
350
|
+
|
|
351
|
+
def load_line(self, line_name: str) -> None:
|
|
352
|
+
"""指定路線を lazy import して registry に登録する。
|
|
353
|
+
|
|
354
|
+
既にロード済みの場合や lines に存在しない路線名の場合は何もしない。
|
|
355
|
+
"""
|
|
356
|
+
if line_name in self._loaded_lines or line_name not in self._lines:
|
|
357
|
+
return
|
|
358
|
+
self._loaded_lines.add(line_name)
|
|
359
|
+
module_path, attr = self._lines[line_name].rsplit(":", 1)
|
|
360
|
+
module = importlib.import_module(module_path)
|
|
361
|
+
line_obj = getattr(module, attr)
|
|
362
|
+
line_obj.register_to(self, line_name)
|
|
363
|
+
|
|
364
|
+
def _ensure_line_for(self, track_id: str) -> None:
|
|
365
|
+
"""タスク識別子から路線名 prefix を抽出して対応する路線をロードする。"""
|
|
366
|
+
self.load_line(track_id.split(":")[0])
|
|
367
|
+
|
|
368
|
+
def register(self, track_id: str, func: Callable, help: str) -> None:
|
|
369
|
+
"""タスクを registry に登録する。通常は Line.register_to() から呼ばれる。"""
|
|
370
|
+
self.registry[track_id] = (func, help)
|
|
371
|
+
|
|
372
|
+
def task_params(self, track_id: str) -> Tuple[Set[str], Set[str]]:
|
|
373
|
+
"""タスクが受け取れる引数名と、そのうち必須の引数名を返す(Coupler 引数は含まない)。
|
|
374
|
+
|
|
375
|
+
Raises:
|
|
376
|
+
KeyError: 未登録のタスクの場合。
|
|
377
|
+
"""
|
|
378
|
+
self._ensure_line_for(track_id)
|
|
379
|
+
_, names, required = _task_signature(self.registry[track_id][0])
|
|
380
|
+
return set(names), required
|
|
381
|
+
|
|
382
|
+
def depart(
|
|
383
|
+
self,
|
|
384
|
+
track_id: str,
|
|
385
|
+
params: Optional[Dict[str, Any]] = None,
|
|
386
|
+
*,
|
|
387
|
+
force: bool = False,
|
|
388
|
+
allow_stale_takeover: bool = False,
|
|
389
|
+
stale_after: int = 600,
|
|
390
|
+
run_id: Optional[str] = None,
|
|
391
|
+
run_by: Optional[str] = None,
|
|
392
|
+
raise_on_failure: bool = False,
|
|
393
|
+
raise_on_blocked: bool = False,
|
|
394
|
+
) -> TrainResult:
|
|
395
|
+
"""列車を出発させる。
|
|
396
|
+
|
|
397
|
+
Args:
|
|
398
|
+
track_id: タスク識別子("line_name:track_name" 形式)。
|
|
399
|
+
params: タスク関数に渡す引数の dict。関数シグネチャにないキーがあると ValueError。
|
|
400
|
+
force: True のとき信号確認をスキップする。
|
|
401
|
+
allow_stale_takeover: True のとき、stale(前回ロックの残骸)なロックだけを引き継いで実行する。
|
|
402
|
+
False のときは stale も赤信号(blocked)とし、自動では引き継がない。
|
|
403
|
+
stale_after: この秒数が経過したロックを stale とみなす。
|
|
404
|
+
run_id: 実行ID。省略時は run_id_generator() で生成。
|
|
405
|
+
run_by: 実行ユーザー名。省略時は getpass.getuser()(取得できなければ "unknown")。
|
|
406
|
+
raise_on_failure: True のときタスクの例外を信号解除後に再送出する。
|
|
407
|
+
raise_on_blocked: True のとき赤信号(多重起動)を SignalBlockedError(stale の場合は StaleLockError)として送出する。
|
|
408
|
+
|
|
409
|
+
Returns:
|
|
410
|
+
実行結果。既定では失敗・赤信号でも例外を送出せず status に反映する。
|
|
411
|
+
|
|
412
|
+
Raises:
|
|
413
|
+
ValueError: params にタスク関数が受け取れないキーが含まれる場合(ロックは取得しない)。
|
|
414
|
+
Exception: raise_on_failure=True かつタスクが失敗した場合、その例外。
|
|
415
|
+
SignalBlockedError: raise_on_blocked=True かつ赤信号の場合(stale ならサブクラスの StaleLockError)。
|
|
416
|
+
"""
|
|
417
|
+
_run_id = run_id or self.run_id_generator()
|
|
418
|
+
_run_by = run_by or _default_run_by()
|
|
419
|
+
_params = params or {}
|
|
420
|
+
|
|
421
|
+
self._ensure_line_for(track_id)
|
|
422
|
+
|
|
423
|
+
current = TrainRecord(
|
|
424
|
+
track_id=track_id,
|
|
425
|
+
run_id=_run_id,
|
|
426
|
+
run_by=_run_by,
|
|
427
|
+
track_help=self.registry[track_id][1], # help テキスト
|
|
428
|
+
status="running",
|
|
429
|
+
force=force,
|
|
430
|
+
allow_stale_takeover=allow_stale_takeover,
|
|
431
|
+
)
|
|
432
|
+
|
|
433
|
+
func, _ = self.registry[track_id]
|
|
434
|
+
|
|
435
|
+
# Coupler 引数は変数名ではなく型ヒントで判定する。
|
|
436
|
+
coupler_arg_name, arg_names, _ = _task_signature(func)
|
|
437
|
+
|
|
438
|
+
if unknown := _params.keys() - set(arg_names):
|
|
439
|
+
raise ValueError(
|
|
440
|
+
f"{track_id} に存在しない引数です: {sorted(unknown)} (使用可能: {sorted(arg_names)})"
|
|
441
|
+
)
|
|
442
|
+
filtered = dict(_params)
|
|
443
|
+
|
|
444
|
+
train = Train(
|
|
445
|
+
track_id=track_id,
|
|
446
|
+
current=current,
|
|
447
|
+
func=func,
|
|
448
|
+
filtered=filtered,
|
|
449
|
+
coupler_arg_name=coupler_arg_name,
|
|
450
|
+
station=self.master,
|
|
451
|
+
stale_after=stale_after,
|
|
452
|
+
force=force,
|
|
453
|
+
allow_stale_takeover=allow_stale_takeover,
|
|
454
|
+
)
|
|
455
|
+
|
|
456
|
+
try:
|
|
457
|
+
self.middleware.dispatch(train)
|
|
458
|
+
finally:
|
|
459
|
+
# 自分が取得したロックだけを解除する。赤信号で弾かれた起動が
|
|
460
|
+
# 実行中の他プロセスのロックを消さないようにする(S3Station は無条件に削除するため)。
|
|
461
|
+
if train.signal_acquired:
|
|
462
|
+
self.master.clear_signal(track_id)
|
|
463
|
+
|
|
464
|
+
result = train.result()
|
|
465
|
+
if raise_on_blocked and result.status == "blocked":
|
|
466
|
+
if result.is_stale:
|
|
467
|
+
raise StaleLockError(track_id)
|
|
468
|
+
raise SignalBlockedError(track_id)
|
|
469
|
+
if raise_on_failure and train.error is not None:
|
|
470
|
+
raise train.error
|
|
471
|
+
return result
|
interlocking/lambda_.py
ADDED
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
"""
|
|
2
|
+
interlocking.lambda_
|
|
3
|
+
===============
|
|
4
|
+
AWS Lambda から TrainDispatcher を呼び出すためのハンドラーファクトリ。
|
|
5
|
+
|
|
6
|
+
dispatcher はモジュールレベルで 1 度だけ生成し、ウォーム起動で再利用すること。
|
|
7
|
+
Station には S3Station を使うこと(LocalStation の /tmp は実行環境ごとに分離されるため排他にならない)。
|
|
8
|
+
|
|
9
|
+
使用例::
|
|
10
|
+
|
|
11
|
+
# handler.py
|
|
12
|
+
from interlocking import S3Station, TrainDispatcher
|
|
13
|
+
from interlocking.lambda_ import make_lambda_handler
|
|
14
|
+
|
|
15
|
+
dispatcher = TrainDispatcher(
|
|
16
|
+
station=S3Station(bucket="my-bucket", prefix="interlocking/prod"),
|
|
17
|
+
lines={"batch": "myapp.tasks:line"},
|
|
18
|
+
)
|
|
19
|
+
handler = make_lambda_handler(dispatcher)
|
|
20
|
+
|
|
21
|
+
既定のイベント形式(EventBridge の Input Transformer や直接 invoke で指定する)::
|
|
22
|
+
|
|
23
|
+
{"track_id": "batch:daily", "params": {"target_date": "2026-04-01"}}
|
|
24
|
+
|
|
25
|
+
任意キー: ``force`` (bool) / ``allow_stale_takeover`` (bool) / ``stale_after`` (int, 秒) / ``run_by`` (str)
|
|
26
|
+
|
|
27
|
+
未知のキー・型の誤り・未登録のタスク・タスクにない引数・必須引数の不足は、
|
|
28
|
+
タスクを実行せず :class:`InvalidRequestError` (``ValueError``) を送出する。
|
|
29
|
+
"""
|
|
30
|
+
from __future__ import annotations
|
|
31
|
+
|
|
32
|
+
from dataclasses import dataclass, field
|
|
33
|
+
from typing import Any, Callable, Dict, Optional
|
|
34
|
+
|
|
35
|
+
from .dispatcher import TrainDispatcher
|
|
36
|
+
|
|
37
|
+
# 残り実行時間に加える余裕(秒)。強制終了された Lambda のロックを確実に stale と判定できるようにする。
|
|
38
|
+
_STALE_AFTER_MARGIN_SEC = 60
|
|
39
|
+
_DEFAULT_STALE_AFTER_SEC = 600
|
|
40
|
+
_EVENT_KEYS = frozenset({"track_id", "params", "force", "allow_stale_takeover", "stale_after", "run_by"})
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class InvalidRequestError(ValueError):
|
|
44
|
+
"""イベントの形式不正、または dispatcher に対して実行できないリクエスト。再試行しても直らない。"""
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@dataclass
|
|
48
|
+
class LambdaRequest:
|
|
49
|
+
"""Lambda イベントから取り出した depart() の呼び出し内容。
|
|
50
|
+
|
|
51
|
+
Attributes:
|
|
52
|
+
track_id: タスク識別子("line_name:track_name" 形式)。
|
|
53
|
+
params: タスク関数に渡す引数。
|
|
54
|
+
force: True のとき信号確認をスキップする。
|
|
55
|
+
allow_stale_takeover: True のとき、stale なロックだけを引き継いで実行する。
|
|
56
|
+
stale_after: stale とみなす秒数。None のときは Lambda の残り実行時間から算出する。
|
|
57
|
+
run_by: 実行ユーザー名。None のとき ``lambda:<関数名>``。
|
|
58
|
+
"""
|
|
59
|
+
track_id: str
|
|
60
|
+
params: Dict[str, Any] = field(default_factory=dict)
|
|
61
|
+
force: bool = False
|
|
62
|
+
allow_stale_takeover: bool = False
|
|
63
|
+
stale_after: Optional[int] = None
|
|
64
|
+
run_by: Optional[str] = None
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def default_event_mapper(event: Dict[str, Any]) -> LambdaRequest:
|
|
68
|
+
"""既定のイベント形式を検証して LambdaRequest に変換する。
|
|
69
|
+
|
|
70
|
+
Raises:
|
|
71
|
+
InvalidRequestError: 未知のキー・型の誤り・track_id の欠落。
|
|
72
|
+
"""
|
|
73
|
+
if not isinstance(event, dict):
|
|
74
|
+
raise InvalidRequestError(f"イベントは dict で指定してください: {type(event).__name__}")
|
|
75
|
+
if unknown := event.keys() - _EVENT_KEYS:
|
|
76
|
+
raise InvalidRequestError(f"未知のキーです: {sorted(unknown)} (使用可能: {sorted(_EVENT_KEYS)})")
|
|
77
|
+
|
|
78
|
+
track_id = event.get("track_id")
|
|
79
|
+
if not isinstance(track_id, str) or ":" not in track_id:
|
|
80
|
+
raise InvalidRequestError(f"track_id は '路線名:タスク名' 形式の文字列で指定してください: {track_id!r}")
|
|
81
|
+
|
|
82
|
+
params = event.get("params") or {}
|
|
83
|
+
if not isinstance(params, dict):
|
|
84
|
+
raise InvalidRequestError(f"params は dict で指定してください: {type(params).__name__}")
|
|
85
|
+
|
|
86
|
+
force = event.get("force", False)
|
|
87
|
+
if not isinstance(force, bool):
|
|
88
|
+
raise InvalidRequestError(f"force は真偽値で指定してください: {force!r}")
|
|
89
|
+
|
|
90
|
+
allow_stale_takeover = event.get("allow_stale_takeover", False)
|
|
91
|
+
if not isinstance(allow_stale_takeover, bool):
|
|
92
|
+
raise InvalidRequestError(f"allow_stale_takeover は真偽値で指定してください: {allow_stale_takeover!r}")
|
|
93
|
+
|
|
94
|
+
stale_after = event.get("stale_after")
|
|
95
|
+
if stale_after is not None and (isinstance(stale_after, bool) or not isinstance(stale_after, int) or stale_after <= 0):
|
|
96
|
+
raise InvalidRequestError(f"stale_after は正の整数(秒)で指定してください: {stale_after!r}")
|
|
97
|
+
|
|
98
|
+
run_by = event.get("run_by")
|
|
99
|
+
if run_by is not None and not isinstance(run_by, str):
|
|
100
|
+
raise InvalidRequestError(f"run_by は文字列で指定してください: {run_by!r}")
|
|
101
|
+
|
|
102
|
+
return LambdaRequest(
|
|
103
|
+
track_id=track_id,
|
|
104
|
+
params=params,
|
|
105
|
+
force=force,
|
|
106
|
+
allow_stale_takeover=allow_stale_takeover,
|
|
107
|
+
stale_after=stale_after,
|
|
108
|
+
run_by=run_by,
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def validate_request(dispatcher: TrainDispatcher, request: LambdaRequest) -> None:
|
|
113
|
+
"""タスクの存在と引数の過不足を検証する。
|
|
114
|
+
|
|
115
|
+
``TrainDispatcher.depart()`` はシグネチャにない引数を黙って無視するため、
|
|
116
|
+
引数名の誤り(例: ``dry_run`` の綴り間違い)で意図せず既定値のまま実行されることを防ぐ。
|
|
117
|
+
|
|
118
|
+
Raises:
|
|
119
|
+
InvalidRequestError: 未登録のタスク・存在しない引数・必須引数の不足。
|
|
120
|
+
"""
|
|
121
|
+
dispatcher.load_line(request.track_id.split(":", 1)[0])
|
|
122
|
+
if request.track_id not in dispatcher.registry:
|
|
123
|
+
raise InvalidRequestError(f"未登録のタスクです: {request.track_id}")
|
|
124
|
+
|
|
125
|
+
names, required = dispatcher.task_params(request.track_id)
|
|
126
|
+
if unknown := request.params.keys() - names:
|
|
127
|
+
raise InvalidRequestError(
|
|
128
|
+
f"{request.track_id} に存在しない引数です: {sorted(unknown)} (使用可能: {sorted(names)})"
|
|
129
|
+
)
|
|
130
|
+
if missing := required - request.params.keys():
|
|
131
|
+
raise InvalidRequestError(f"{request.track_id} の必須引数が不足しています: {sorted(missing)}")
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def _resolve_stale_after(request: LambdaRequest, context: Any) -> int:
|
|
135
|
+
"""stale_after を決める。Lambda の残り実行時間より長くして、強制終了後のロックを stale と判定できるようにする。
|
|
136
|
+
|
|
137
|
+
stale は自動では引き継がれない。復旧は abort か allow_stale_takeover で行う。
|
|
138
|
+
"""
|
|
139
|
+
if request.stale_after is not None:
|
|
140
|
+
return int(request.stale_after)
|
|
141
|
+
get_remaining = getattr(context, "get_remaining_time_in_millis", None)
|
|
142
|
+
if get_remaining is None:
|
|
143
|
+
return _DEFAULT_STALE_AFTER_SEC
|
|
144
|
+
return int(get_remaining()) // 1000 + _STALE_AFTER_MARGIN_SEC
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def make_lambda_handler(
|
|
148
|
+
dispatcher: TrainDispatcher,
|
|
149
|
+
*,
|
|
150
|
+
event_mapper: Callable[[Dict[str, Any]], LambdaRequest] = default_event_mapper,
|
|
151
|
+
raise_on_failure: bool = True,
|
|
152
|
+
raise_on_blocked: bool = False,
|
|
153
|
+
) -> Callable[[Dict[str, Any], Any], Dict[str, Any]]:
|
|
154
|
+
"""Lambda ハンドラー ``handler(event, context)`` を生成する。
|
|
155
|
+
|
|
156
|
+
run_id には ``context.aws_request_id`` を使うため、CloudWatch Logs と突き合わせられる。
|
|
157
|
+
戻り値は :meth:`interlocking.models.TrainResult.to_dict` の JSON 互換 dict。
|
|
158
|
+
|
|
159
|
+
Args:
|
|
160
|
+
dispatcher: タスクを登録済みの TrainDispatcher。
|
|
161
|
+
event_mapper: イベントを LambdaRequest に変換する関数。
|
|
162
|
+
raise_on_failure: True のときタスク失敗を例外として送出する。
|
|
163
|
+
非同期 invoke のリトライや DLQ を機能させるため既定は True。
|
|
164
|
+
raise_on_blocked: True のとき多重起動(赤信号)を SignalBlockedError として送出する。
|
|
165
|
+
既定は False(戻り値の status="blocked" で返す)。
|
|
166
|
+
"""
|
|
167
|
+
|
|
168
|
+
def handler(event: Dict[str, Any], context: Any) -> Dict[str, Any]:
|
|
169
|
+
request = event_mapper(event)
|
|
170
|
+
validate_request(dispatcher, request)
|
|
171
|
+
function_name = getattr(context, "function_name", None)
|
|
172
|
+
run_by = request.run_by or (f"lambda:{function_name}" if function_name else "lambda")
|
|
173
|
+
result = dispatcher.depart(
|
|
174
|
+
request.track_id,
|
|
175
|
+
request.params,
|
|
176
|
+
force=request.force,
|
|
177
|
+
allow_stale_takeover=request.allow_stale_takeover,
|
|
178
|
+
stale_after=_resolve_stale_after(request, context),
|
|
179
|
+
run_id=getattr(context, "aws_request_id", None),
|
|
180
|
+
run_by=run_by,
|
|
181
|
+
raise_on_failure=raise_on_failure,
|
|
182
|
+
raise_on_blocked=raise_on_blocked,
|
|
183
|
+
)
|
|
184
|
+
return result.to_dict()
|
|
185
|
+
|
|
186
|
+
return handler
|