flashruntime 0.3.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.
- flashml_workloads/__init__.py +7 -0
- flashml_workloads/fedavg_driver.py +569 -0
- flashml_workloads/fedavg_weights.py +223 -0
- flashml_workloads/fedavg_worker.py +166 -0
- flashml_workloads/kmeans_driver.py +134 -0
- flashml_workloads/kmeans_shard.py +69 -0
- flashml_workloads/sgd_trainer.py +127 -0
- flashml_workloads/sharded_kmeans.py +323 -0
- flashml_workloads/sklearn_trial.py +89 -0
- flashruntime/__init__.py +125 -0
- flashruntime/artifacts/__init__.py +25 -0
- flashruntime/artifacts/store.py +228 -0
- flashruntime/backends/__init__.py +26 -0
- flashruntime/backends/base.py +63 -0
- flashruntime/backends/kuberay.py +465 -0
- flashruntime/checkpoint/__init__.py +20 -0
- flashruntime/checkpoint/catalog.py +198 -0
- flashruntime/checkpoint/local.py +109 -0
- flashruntime/checkpoint/store.py +86 -0
- flashruntime/integrations/__init__.py +5 -0
- flashruntime/integrations/huggingface.py +59 -0
- flashruntime/integrations/pytorch.py +52 -0
- flashruntime/integrations/sklearn.py +42 -0
- flashruntime/launchers/__init__.py +130 -0
- flashruntime/launchers/local.py +126 -0
- flashruntime/leases/__init__.py +27 -0
- flashruntime/leases/manager.py +365 -0
- flashruntime/leases/sqlite_store.py +169 -0
- flashruntime/leases/store.py +103 -0
- flashruntime/monitor/__init__.py +7 -0
- flashruntime/monitor/sampler.py +232 -0
- flashruntime/planner/__init__.py +56 -0
- flashruntime/planner/candidates.py +597 -0
- flashruntime/planner/catalog.py +129 -0
- flashruntime/planner/comm.py +95 -0
- flashruntime/planner/explain.py +109 -0
- flashruntime/planner/memory.py +166 -0
- flashruntime/planner/resolve.py +120 -0
- flashruntime/planner/selector.py +169 -0
- flashruntime/planner/timecost.py +81 -0
- flashruntime/profiling/__init__.py +113 -0
- flashruntime/protocol/__init__.py +18 -0
- flashruntime/protocol/plan_v1alpha1.py +320 -0
- flashruntime/protocol/v1alpha1.py +465 -0
- flashruntime/providers/__init__.py +138 -0
- flashruntime/py.typed +0 -0
- flashruntime/recipes/__init__.py +135 -0
- flashruntime/recipes/command.py +166 -0
- flashruntime/recovery/__init__.py +21 -0
- flashruntime/recovery/policy.py +170 -0
- flashruntime/recovery/signals.py +135 -0
- flashruntime/recovery/taxonomy.py +91 -0
- flashruntime/scheduler/__init__.py +170 -0
- flashruntime/sdk.py +402 -0
- flashruntime/service/__init__.py +3 -0
- flashruntime/service/app.py +391 -0
- flashruntime/service/auth.py +180 -0
- flashruntime/service/checkpoints.py +90 -0
- flashruntime/service/cli.py +167 -0
- flashruntime/service/dashboard.py +193 -0
- flashruntime/service/ledger.py +101 -0
- flashruntime/service/modea.py +821 -0
- flashruntime/strategies/__init__.py +156 -0
- flashruntime/strategies/command.py +56 -0
- flashruntime/torch/__init__.py +274 -0
- flashruntime/viewer/__init__.py +20 -0
- flashruntime/viewer/_docs/benchmarks.html +771 -0
- flashruntime/viewer/_docs/concepts/architecture.html +302 -0
- flashruntime/viewer/_docs/get-started.html +263 -0
- flashruntime/viewer/_docs/guides/federated-averaging.html +363 -0
- flashruntime/viewer/_docs/guides/huggingface.html +223 -0
- flashruntime/viewer/_docs/guides/jobspec-and-isolation.html +271 -0
- flashruntime/viewer/_docs/guides/pytorch.html +313 -0
- flashruntime/viewer/_docs/guides/sklearn.html +232 -0
- flashruntime/viewer/_docs/index.html +251 -0
- flashruntime/viewer/_docs/reference/cli.html +254 -0
- flashruntime/viewer/_docs/reference/integrations.html +240 -0
- flashruntime/viewer/_docs/reference/sdk.html +341 -0
- flashruntime/viewer/_docs/reference/torch-helper.html +244 -0
- flashruntime/viewer/_docs/search-index.json +1 -0
- flashruntime/viewer/_docs/tutorials/convnet.html +571 -0
- flashruntime/viewer/_docs/tutorials/fault-tolerance.html +375 -0
- flashruntime/viewer/_docs/tutorials/sklearn-sweeps.html +278 -0
- flashruntime/viewer/flowmap.py +307 -0
- flashruntime/viewer/page.py +594 -0
- flashruntime/viewer/server.py +134 -0
- flashruntime/viewer/state.py +250 -0
- flashruntime/workloads/__init__.py +6 -0
- flashruntime/workloads/command.py +127 -0
- flashruntime-0.3.0.dist-info/METADATA +365 -0
- flashruntime-0.3.0.dist-info/RECORD +95 -0
- flashruntime-0.3.0.dist-info/WHEEL +5 -0
- flashruntime-0.3.0.dist-info/entry_points.txt +2 -0
- flashruntime-0.3.0.dist-info/licenses/LICENSE +202 -0
- flashruntime-0.3.0.dist-info/top_level.txt +2 -0
|
@@ -0,0 +1,365 @@
|
|
|
1
|
+
"""The Mode A lease state machine — FlashRuntime's core reliability pattern.
|
|
2
|
+
|
|
3
|
+
Work is never pushed to a machine. A machine *claims* a time-limited
|
|
4
|
+
lease on a task, proves liveness with *heartbeats*, and only the first
|
|
5
|
+
attempt to *commit* a valid result wins.
|
|
6
|
+
|
|
7
|
+
A dead worker needs no special handling: its lease passes the deadline, the
|
|
8
|
+
sweep expires it, and the task returns to PENDING for someone else. A worker
|
|
9
|
+
that wakes up late and tries to commit is rejected by the idempotency rule.
|
|
10
|
+
|
|
11
|
+
The manager is a pure state machine: no I/O, no threads, no clock of its
|
|
12
|
+
own. Time is injected (`now` parameter) so tests control it exactly;
|
|
13
|
+
durability is behind `LeaseStore`; every transition is emitted as a typed
|
|
14
|
+
protocol `Event` through a callback — the event ledger, dashboards, and
|
|
15
|
+
metrics all hang off that single stream.
|
|
16
|
+
|
|
17
|
+
State transitions (task):
|
|
18
|
+
|
|
19
|
+
PENDING ──claim──▶ LEASED ──complete (first valid)──▶ COMPLETED
|
|
20
|
+
▲ │
|
|
21
|
+
└──sweep expiry / fail (attempts left)──┘
|
|
22
|
+
LEASED ──fail/expiry with no attempts left──▶ FAILED
|
|
23
|
+
any non-terminal ──cancel──▶ CANCELLED
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
from __future__ import annotations
|
|
27
|
+
|
|
28
|
+
import uuid
|
|
29
|
+
from datetime import datetime, timedelta, timezone
|
|
30
|
+
from typing import Callable
|
|
31
|
+
|
|
32
|
+
from flashruntime.leases.store import InMemoryLeaseStore, LeaseStore, TaskRecord
|
|
33
|
+
from flashruntime.protocol.v1alpha1 import (
|
|
34
|
+
Event,
|
|
35
|
+
EventType,
|
|
36
|
+
Lease,
|
|
37
|
+
TaskSpec,
|
|
38
|
+
TaskState,
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
EventSink = Callable[[Event], None]
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _utcnow() -> datetime:
|
|
45
|
+
return datetime.now(timezone.utc)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class LeaseError(Exception):
|
|
49
|
+
"""A request that the state machine must refuse (wrong holder, terminal
|
|
50
|
+
task, unknown id). Callers translate this to their transport's error."""
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class LeaseManager:
|
|
54
|
+
"""Coordinates tasks, leases, heartbeats, expiry, and idempotent commit.
|
|
55
|
+
|
|
56
|
+
Embeddable anywhere: the FlashRuntime service exposes it over HTTP, but
|
|
57
|
+
a script can drive it in-process (see tests/test_leases.py for the full
|
|
58
|
+
kill-a-worker story in twenty lines).
|
|
59
|
+
"""
|
|
60
|
+
|
|
61
|
+
def __init__(
|
|
62
|
+
self,
|
|
63
|
+
store: LeaseStore | None = None,
|
|
64
|
+
on_event: EventSink | None = None,
|
|
65
|
+
) -> None:
|
|
66
|
+
self._store = store if store is not None else InMemoryLeaseStore()
|
|
67
|
+
self._on_event = on_event
|
|
68
|
+
|
|
69
|
+
# -- job side -----------------------------------------------------------
|
|
70
|
+
|
|
71
|
+
def add_task(self, spec: TaskSpec, now: datetime | None = None) -> None:
|
|
72
|
+
"""Register one unit of claimable work."""
|
|
73
|
+
self._store.add(TaskRecord(spec))
|
|
74
|
+
self._emit(EventType.TASK_CREATED, spec.job_id, spec.task_id, now=now)
|
|
75
|
+
|
|
76
|
+
def cancel_task(self, job_id: str, task_id: str, now: datetime | None = None) -> None:
|
|
77
|
+
record = self._require(job_id, task_id)
|
|
78
|
+
if record.state in (TaskState.COMPLETED, TaskState.FAILED, TaskState.CANCELLED):
|
|
79
|
+
return # terminal states are final; cancel is idempotent
|
|
80
|
+
record.state = TaskState.CANCELLED
|
|
81
|
+
record.active_lease = None
|
|
82
|
+
self._store.save(record)
|
|
83
|
+
|
|
84
|
+
# -- worker side --------------------------------------------------------
|
|
85
|
+
|
|
86
|
+
def claim(
|
|
87
|
+
self,
|
|
88
|
+
node_id: str,
|
|
89
|
+
job_id: str | None = None,
|
|
90
|
+
now: datetime | None = None,
|
|
91
|
+
policy: object | None = None,
|
|
92
|
+
node: dict | None = None,
|
|
93
|
+
) -> Lease | None:
|
|
94
|
+
"""Claim the next PENDING task for `node_id`, or None when nothing is
|
|
95
|
+
claimable. Expired leases are swept first so a claim never starves
|
|
96
|
+
behind a dead worker.
|
|
97
|
+
|
|
98
|
+
`policy`/`node` are the scheduler seam (flashruntime/scheduler):
|
|
99
|
+
the store yields queue-ordered candidates, the policy filters and
|
|
100
|
+
picks. Duck-typed (`choose(pending_specs, node) -> TaskSpec|None`)
|
|
101
|
+
so this package gains no scheduler import. Without a policy,
|
|
102
|
+
behavior is bit-identical to the original FIFO claim."""
|
|
103
|
+
now = now or _utcnow()
|
|
104
|
+
self.sweep(now=now)
|
|
105
|
+
if policy is None:
|
|
106
|
+
record = self._store.next_pending(job_id)
|
|
107
|
+
else:
|
|
108
|
+
pending = [r for r in self._store.all(job_id) if r.state == TaskState.PENDING]
|
|
109
|
+
chosen = policy.choose([r.spec for r in pending], node or {"node_id": node_id})
|
|
110
|
+
record = None
|
|
111
|
+
if chosen is not None:
|
|
112
|
+
# default None: a policy that returns a foreign/unknown spec
|
|
113
|
+
# falls through to the `record is None` path below, not StopIteration.
|
|
114
|
+
# Match on both fields: task_id alone is positional within a
|
|
115
|
+
# job (task-000), so two jobs can collide on it.
|
|
116
|
+
record = next(
|
|
117
|
+
(
|
|
118
|
+
r
|
|
119
|
+
for r in pending
|
|
120
|
+
if r.spec.task_id == chosen.task_id
|
|
121
|
+
and r.spec.job_id == chosen.job_id
|
|
122
|
+
),
|
|
123
|
+
None,
|
|
124
|
+
)
|
|
125
|
+
if record is None:
|
|
126
|
+
return None
|
|
127
|
+
record.attempts_used += 1
|
|
128
|
+
lease = Lease(
|
|
129
|
+
lease_id=f"ls-{uuid.uuid4().hex[:12]}",
|
|
130
|
+
task_id=record.spec.task_id,
|
|
131
|
+
job_id=record.spec.job_id,
|
|
132
|
+
node_id=node_id,
|
|
133
|
+
attempt_number=record.attempts_used,
|
|
134
|
+
deadline=now + timedelta(seconds=record.spec.lease_seconds),
|
|
135
|
+
payload=record.spec.payload,
|
|
136
|
+
)
|
|
137
|
+
record.state = TaskState.LEASED
|
|
138
|
+
record.active_lease = lease
|
|
139
|
+
record.lease_history[lease.lease_id] = lease
|
|
140
|
+
self._store.save(record)
|
|
141
|
+
self._emit(
|
|
142
|
+
EventType.LEASE_CLAIMED,
|
|
143
|
+
lease.job_id,
|
|
144
|
+
lease.task_id,
|
|
145
|
+
node_id=node_id,
|
|
146
|
+
detail=f"attempt {lease.attempt_number}, deadline {lease.deadline.isoformat()}",
|
|
147
|
+
now=now,
|
|
148
|
+
)
|
|
149
|
+
return lease
|
|
150
|
+
|
|
151
|
+
def heartbeat(self, lease_id: str, now: datetime | None = None) -> Lease:
|
|
152
|
+
"""Renew a live lease (extends the deadline by the task's lease
|
|
153
|
+
window). Refused for unknown/expired/superseded leases — a worker
|
|
154
|
+
whose heartbeat is refused must stop working on the task."""
|
|
155
|
+
now = now or _utcnow()
|
|
156
|
+
record, lease = self._require_live_lease(lease_id, now)
|
|
157
|
+
renewed = lease.model_copy(
|
|
158
|
+
update={"deadline": now + timedelta(seconds=record.spec.lease_seconds)}
|
|
159
|
+
)
|
|
160
|
+
record.active_lease = renewed
|
|
161
|
+
self._store.save(record)
|
|
162
|
+
self._emit(EventType.LEASE_RENEWED, lease.job_id, lease.task_id, node_id=lease.node_id, now=now)
|
|
163
|
+
return renewed
|
|
164
|
+
|
|
165
|
+
def complete(
|
|
166
|
+
self,
|
|
167
|
+
lease_id: str,
|
|
168
|
+
output_sha256: str,
|
|
169
|
+
now: datetime | None = None,
|
|
170
|
+
) -> bool:
|
|
171
|
+
"""Commit the result of a leased attempt.
|
|
172
|
+
|
|
173
|
+
Returns True when the commit is *accepted*. The idempotency rule:
|
|
174
|
+
exactly one accepted output per task, ever. A late commit from an
|
|
175
|
+
expired or superseded lease returns False (rejected) — and is
|
|
176
|
+
recorded, because rejected duplicates are recovery evidence, not
|
|
177
|
+
noise. Raises LeaseError only for leases that never existed.
|
|
178
|
+
"""
|
|
179
|
+
now = now or _utcnow()
|
|
180
|
+
record, lease = self._find_lease(lease_id)
|
|
181
|
+
if record.state == TaskState.COMPLETED or not self._is_live(record, lease, now):
|
|
182
|
+
self._emit(
|
|
183
|
+
EventType.TASK_COMMIT_REJECTED,
|
|
184
|
+
lease.job_id,
|
|
185
|
+
lease.task_id,
|
|
186
|
+
node_id=lease.node_id,
|
|
187
|
+
detail=f"late/duplicate commit from attempt {lease.attempt_number} rejected",
|
|
188
|
+
now=now,
|
|
189
|
+
)
|
|
190
|
+
return False
|
|
191
|
+
record.state = TaskState.COMPLETED
|
|
192
|
+
record.accepted_attempt_id = lease.lease_id
|
|
193
|
+
record.active_lease = None
|
|
194
|
+
self._store.save(record)
|
|
195
|
+
self._emit(
|
|
196
|
+
EventType.TASK_COMMIT_ACCEPTED,
|
|
197
|
+
lease.job_id,
|
|
198
|
+
lease.task_id,
|
|
199
|
+
node_id=lease.node_id,
|
|
200
|
+
detail=f"attempt {lease.attempt_number} accepted, sha256={output_sha256[:12]}…",
|
|
201
|
+
now=now,
|
|
202
|
+
)
|
|
203
|
+
return True
|
|
204
|
+
|
|
205
|
+
def fail(self, lease_id: str, reason: str, now: datetime | None = None) -> None:
|
|
206
|
+
"""A worker reports its attempt failed. The task requeues while
|
|
207
|
+
attempts remain, otherwise it is FAILED for good."""
|
|
208
|
+
now = now or _utcnow()
|
|
209
|
+
record, lease = self._require_live_lease(lease_id, now)
|
|
210
|
+
self._emit(
|
|
211
|
+
EventType.TASK_ATTEMPT_FAILED,
|
|
212
|
+
lease.job_id,
|
|
213
|
+
lease.task_id,
|
|
214
|
+
node_id=lease.node_id,
|
|
215
|
+
detail=reason,
|
|
216
|
+
now=now,
|
|
217
|
+
)
|
|
218
|
+
self._release(record, now, requeue_event=EventType.TASK_REQUEUED)
|
|
219
|
+
|
|
220
|
+
# -- coordinator side ---------------------------------------------------
|
|
221
|
+
|
|
222
|
+
def sweep(self, now: datetime | None = None) -> int:
|
|
223
|
+
"""Expire every lease past its deadline; requeue (or exhaust) the
|
|
224
|
+
tasks. Returns how many leases were expired. Call on a timer — or
|
|
225
|
+
rely on `claim`, which sweeps first."""
|
|
226
|
+
now = now or _utcnow()
|
|
227
|
+
expired = 0
|
|
228
|
+
for record in self._store.leased():
|
|
229
|
+
lease = record.active_lease
|
|
230
|
+
if lease is None or lease.deadline > now:
|
|
231
|
+
continue
|
|
232
|
+
expired += 1
|
|
233
|
+
self._emit(
|
|
234
|
+
EventType.LEASE_EXPIRED,
|
|
235
|
+
lease.job_id,
|
|
236
|
+
lease.task_id,
|
|
237
|
+
node_id=lease.node_id,
|
|
238
|
+
detail=f"attempt {lease.attempt_number} heartbeat deadline passed",
|
|
239
|
+
now=now,
|
|
240
|
+
)
|
|
241
|
+
self._release(record, now, requeue_event=EventType.TASK_REQUEUED)
|
|
242
|
+
return expired
|
|
243
|
+
|
|
244
|
+
def job_state(self, job_id: str) -> dict[str, int]:
|
|
245
|
+
"""Task counts by state — the number the dashboard shows."""
|
|
246
|
+
counts: dict[str, int] = {}
|
|
247
|
+
for record in self._store.all(job_id):
|
|
248
|
+
counts[record.state.value] = counts.get(record.state.value, 0) + 1
|
|
249
|
+
return counts
|
|
250
|
+
|
|
251
|
+
def records(self, job_id: str | None = None) -> list[TaskRecord]:
|
|
252
|
+
"""Read-only view of task records (for status endpoints/dashboards)."""
|
|
253
|
+
return self._store.all(job_id)
|
|
254
|
+
|
|
255
|
+
def lease_info(self, lease_id: str) -> Lease | None:
|
|
256
|
+
"""Look up any lease ever issued (live or superseded), or None."""
|
|
257
|
+
try:
|
|
258
|
+
_, lease = self._find_lease(lease_id)
|
|
259
|
+
except LeaseError:
|
|
260
|
+
return None
|
|
261
|
+
return lease
|
|
262
|
+
|
|
263
|
+
def live_leases_for_node(
|
|
264
|
+
self, node_id: str, now: datetime | None = None
|
|
265
|
+
) -> set[tuple[str, str]]:
|
|
266
|
+
"""(job_id, task_id) this node may currently write to.
|
|
267
|
+
|
|
268
|
+
Delegates liveness to `_is_live` rather than re-testing `deadline`
|
|
269
|
+
itself: that predicate also requires the lease to still be the active
|
|
270
|
+
one and the record to still be LEASED, so a task whose result was
|
|
271
|
+
already accepted stops being writable. Two copies of this rule would
|
|
272
|
+
drift, and the drift would be a silent authorization hole.
|
|
273
|
+
"""
|
|
274
|
+
now = now if now is not None else _utcnow()
|
|
275
|
+
scope: set[tuple[str, str]] = set()
|
|
276
|
+
for record in self._store.leased():
|
|
277
|
+
lease = record.active_lease
|
|
278
|
+
if lease is None or lease.node_id != node_id:
|
|
279
|
+
continue
|
|
280
|
+
# At this call site, `_is_live`'s `current.lease_id ==
|
|
281
|
+
# lease.lease_id` check is always true (we pass in
|
|
282
|
+
# `record.active_lease` itself) and its `record.state ==
|
|
283
|
+
# LEASED` check is already guaranteed by `_store.leased()`
|
|
284
|
+
# above — so today this reduces to the deadline comparison.
|
|
285
|
+
# Keep the delegation anyway: `leased()`'s filtering is a
|
|
286
|
+
# store-level contract that could change, and `_is_live` is
|
|
287
|
+
# the single canonical definition of "this lease is still
|
|
288
|
+
# good". Two copies of an authorization rule drift apart,
|
|
289
|
+
# and the drift would be a silent hole.
|
|
290
|
+
if not self._is_live(record, lease, now):
|
|
291
|
+
continue
|
|
292
|
+
scope.add((lease.job_id, lease.task_id))
|
|
293
|
+
return scope
|
|
294
|
+
|
|
295
|
+
# -- internals ----------------------------------------------------------
|
|
296
|
+
|
|
297
|
+
def _release(self, record: TaskRecord, now: datetime, requeue_event: EventType) -> None:
|
|
298
|
+
record.active_lease = None
|
|
299
|
+
if record.attempts_used >= record.spec.max_attempts:
|
|
300
|
+
record.state = TaskState.FAILED
|
|
301
|
+
self._emit(
|
|
302
|
+
EventType.TASK_EXHAUSTED,
|
|
303
|
+
record.spec.job_id,
|
|
304
|
+
record.spec.task_id,
|
|
305
|
+
detail=f"all {record.spec.max_attempts} attempts used",
|
|
306
|
+
now=now,
|
|
307
|
+
)
|
|
308
|
+
else:
|
|
309
|
+
record.state = TaskState.PENDING
|
|
310
|
+
self._emit(requeue_event, record.spec.job_id, record.spec.task_id, now=now)
|
|
311
|
+
self._store.save(record)
|
|
312
|
+
|
|
313
|
+
def _is_live(self, record: TaskRecord, lease: Lease, now: datetime) -> bool:
|
|
314
|
+
current = record.active_lease
|
|
315
|
+
return (
|
|
316
|
+
current is not None
|
|
317
|
+
and current.lease_id == lease.lease_id
|
|
318
|
+
and current.deadline > now
|
|
319
|
+
and record.state == TaskState.LEASED
|
|
320
|
+
)
|
|
321
|
+
|
|
322
|
+
def _require(self, job_id: str, task_id: str) -> TaskRecord:
|
|
323
|
+
record = self._store.get(job_id, task_id)
|
|
324
|
+
if record is None:
|
|
325
|
+
raise LeaseError(f"unknown task {task_id} in job {job_id}")
|
|
326
|
+
return record
|
|
327
|
+
|
|
328
|
+
def _find_lease(self, lease_id: str) -> tuple[TaskRecord, Lease]:
|
|
329
|
+
# Every issued lease stays in its task's history, so a superseded or
|
|
330
|
+
# long-dead lease is still *identifiable* — its commit gets rejected
|
|
331
|
+
# with evidence instead of erroring as unknown.
|
|
332
|
+
for record in self._store.all():
|
|
333
|
+
lease = record.lease_history.get(lease_id)
|
|
334
|
+
if lease is not None:
|
|
335
|
+
return record, lease
|
|
336
|
+
raise LeaseError(f"unknown lease {lease_id}")
|
|
337
|
+
|
|
338
|
+
def _require_live_lease(self, lease_id: str, now: datetime) -> tuple[TaskRecord, Lease]:
|
|
339
|
+
record, lease = self._find_lease(lease_id)
|
|
340
|
+
if not self._is_live(record, lease, now):
|
|
341
|
+
raise LeaseError(f"lease {lease_id} is no longer live")
|
|
342
|
+
return record, lease
|
|
343
|
+
|
|
344
|
+
def _emit(
|
|
345
|
+
self,
|
|
346
|
+
event_type: EventType,
|
|
347
|
+
job_id: str,
|
|
348
|
+
task_id: str,
|
|
349
|
+
*,
|
|
350
|
+
node_id: str | None = None,
|
|
351
|
+
detail: str = "",
|
|
352
|
+
now: datetime | None = None,
|
|
353
|
+
) -> None:
|
|
354
|
+
if self._on_event is None:
|
|
355
|
+
return
|
|
356
|
+
self._on_event(
|
|
357
|
+
Event(
|
|
358
|
+
type=event_type,
|
|
359
|
+
job_id=job_id,
|
|
360
|
+
source="flashruntime.leases",
|
|
361
|
+
message=detail or event_type.value,
|
|
362
|
+
data={"task_id": task_id, **({"node_id": node_id} if node_id else {})},
|
|
363
|
+
timestamp=now or _utcnow(),
|
|
364
|
+
)
|
|
365
|
+
)
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
"""SQLite-backed LeaseStore: the coordinator's lease table survives restarts.
|
|
2
|
+
|
|
3
|
+
Same semantics as InMemoryLeaseStore — the store keeps live TaskRecord
|
|
4
|
+
objects in an insertion-ordered cache (so the manager's in-place mutations
|
|
5
|
+
behave identically) and persists every record on `save()`. A new instance
|
|
6
|
+
on the same file rehydrates the full state: specs, task states, attempt
|
|
7
|
+
counts, the active lease, and the complete lease history — which is what
|
|
8
|
+
lets a lease issued *before* a coordinator restart still be renewed,
|
|
9
|
+
committed, or rejected *after* it.
|
|
10
|
+
|
|
11
|
+
Single-writer by design (the service's event loop); `check_same_thread` is
|
|
12
|
+
disabled only so test fixtures may construct/inspect across threads.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import json
|
|
18
|
+
import sqlite3
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
|
|
21
|
+
from flashruntime.leases.store import TaskRecord
|
|
22
|
+
from flashruntime.protocol.v1alpha1 import Lease, TaskSpec, TaskState
|
|
23
|
+
|
|
24
|
+
_SCHEMA = """
|
|
25
|
+
CREATE TABLE IF NOT EXISTS lease_tasks (
|
|
26
|
+
task_id TEXT NOT NULL,
|
|
27
|
+
job_id TEXT NOT NULL,
|
|
28
|
+
spec_json TEXT NOT NULL,
|
|
29
|
+
state TEXT NOT NULL,
|
|
30
|
+
attempts_used INTEGER NOT NULL,
|
|
31
|
+
active_lease_json TEXT,
|
|
32
|
+
accepted_attempt_id TEXT,
|
|
33
|
+
lease_history_json TEXT NOT NULL,
|
|
34
|
+
seq INTEGER,
|
|
35
|
+
PRIMARY KEY (job_id, task_id)
|
|
36
|
+
);
|
|
37
|
+
CREATE INDEX IF NOT EXISTS idx_lease_tasks_job ON lease_tasks (job_id);
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
_COLUMNS = (
|
|
41
|
+
"task_id, job_id, spec_json, state, attempts_used, active_lease_json,"
|
|
42
|
+
" accepted_attempt_id, lease_history_json, seq"
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class SqliteLeaseStore:
|
|
47
|
+
def __init__(self, path: str | Path):
|
|
48
|
+
Path(path).parent.mkdir(parents=True, exist_ok=True)
|
|
49
|
+
self._conn = sqlite3.connect(str(path), check_same_thread=False)
|
|
50
|
+
self._migrate() # legacy single-PK table → composite
|
|
51
|
+
self._conn.executescript(_SCHEMA)
|
|
52
|
+
self._cache: dict[tuple[str, str], TaskRecord] = {}
|
|
53
|
+
self._seq = 0
|
|
54
|
+
self._load()
|
|
55
|
+
|
|
56
|
+
# -- schema migration -----------------------------------------------------
|
|
57
|
+
|
|
58
|
+
def _migrate(self) -> None:
|
|
59
|
+
"""Upgrade a pre-composite-key database in place.
|
|
60
|
+
|
|
61
|
+
SQLite cannot alter a primary key, so the table is rebuilt and the
|
|
62
|
+
rows copied — never dropped. In-flight leases must survive the
|
|
63
|
+
upgrade; that durability is the reason this store exists.
|
|
64
|
+
"""
|
|
65
|
+
info = self._conn.execute("PRAGMA table_info(lease_tasks)").fetchall()
|
|
66
|
+
if not info:
|
|
67
|
+
return # fresh database; _SCHEMA already creates the right table
|
|
68
|
+
pk_cols = sorted(col[1] for col in info if col[5] > 0)
|
|
69
|
+
if pk_cols == ["job_id", "task_id"]:
|
|
70
|
+
return # already migrated
|
|
71
|
+
self._conn.executescript(
|
|
72
|
+
"BEGIN;"
|
|
73
|
+
"ALTER TABLE lease_tasks RENAME TO lease_tasks_legacy;"
|
|
74
|
+
+ _SCHEMA.replace("IF NOT EXISTS lease_tasks", "lease_tasks")
|
|
75
|
+
+ f"INSERT INTO lease_tasks ({_COLUMNS})"
|
|
76
|
+
f" SELECT {_COLUMNS} FROM lease_tasks_legacy;"
|
|
77
|
+
"DROP TABLE lease_tasks_legacy;"
|
|
78
|
+
"COMMIT;"
|
|
79
|
+
)
|
|
80
|
+
self._conn.commit()
|
|
81
|
+
|
|
82
|
+
# -- rehydration ---------------------------------------------------------
|
|
83
|
+
|
|
84
|
+
def _load(self) -> None:
|
|
85
|
+
rows = self._conn.execute(
|
|
86
|
+
"SELECT spec_json, state, attempts_used, active_lease_json,"
|
|
87
|
+
" accepted_attempt_id, lease_history_json, seq"
|
|
88
|
+
" FROM lease_tasks ORDER BY seq"
|
|
89
|
+
).fetchall()
|
|
90
|
+
for spec_json, state, attempts, lease_json, accepted, history_json, seq in rows:
|
|
91
|
+
record = TaskRecord(TaskSpec.model_validate_json(spec_json))
|
|
92
|
+
record.state = TaskState(state)
|
|
93
|
+
record.attempts_used = attempts
|
|
94
|
+
record.active_lease = Lease.model_validate_json(lease_json) if lease_json else None
|
|
95
|
+
record.accepted_attempt_id = accepted
|
|
96
|
+
record.lease_history = {
|
|
97
|
+
lid: Lease.model_validate(raw)
|
|
98
|
+
for lid, raw in json.loads(history_json).items()
|
|
99
|
+
}
|
|
100
|
+
self._cache[(record.spec.job_id, record.spec.task_id)] = record
|
|
101
|
+
self._seq = max(self._seq, seq or 0)
|
|
102
|
+
|
|
103
|
+
# -- LeaseStore protocol -------------------------------------------------
|
|
104
|
+
|
|
105
|
+
def add(self, record: TaskRecord) -> None:
|
|
106
|
+
key = (record.spec.job_id, record.spec.task_id)
|
|
107
|
+
if key in self._cache:
|
|
108
|
+
raise ValueError(
|
|
109
|
+
f"task {record.spec.task_id} already exists in job {record.spec.job_id}"
|
|
110
|
+
)
|
|
111
|
+
self._cache[key] = record
|
|
112
|
+
self._seq += 1
|
|
113
|
+
self._persist(record, self._seq)
|
|
114
|
+
|
|
115
|
+
def save(self, record: TaskRecord) -> None:
|
|
116
|
+
self._persist(record, None)
|
|
117
|
+
|
|
118
|
+
def get(self, job_id: str, task_id: str) -> TaskRecord | None:
|
|
119
|
+
return self._cache.get((job_id, task_id))
|
|
120
|
+
|
|
121
|
+
def next_pending(self, job_id: str | None = None) -> TaskRecord | None:
|
|
122
|
+
for record in self._cache.values():
|
|
123
|
+
if record.state == TaskState.PENDING and (
|
|
124
|
+
job_id is None or record.spec.job_id == job_id
|
|
125
|
+
):
|
|
126
|
+
return record
|
|
127
|
+
return None
|
|
128
|
+
|
|
129
|
+
def leased(self) -> list[TaskRecord]:
|
|
130
|
+
return [r for r in self._cache.values() if r.state == TaskState.LEASED]
|
|
131
|
+
|
|
132
|
+
def all(self, job_id: str | None = None) -> list[TaskRecord]:
|
|
133
|
+
return [
|
|
134
|
+
r for r in self._cache.values() if job_id is None or r.spec.job_id == job_id
|
|
135
|
+
]
|
|
136
|
+
|
|
137
|
+
# -- persistence ---------------------------------------------------------
|
|
138
|
+
|
|
139
|
+
def _persist(self, record: TaskRecord, seq: int | None) -> None:
|
|
140
|
+
history = json.dumps(
|
|
141
|
+
{lid: json.loads(lease.model_dump_json()) for lid, lease in record.lease_history.items()}
|
|
142
|
+
)
|
|
143
|
+
self._conn.execute(
|
|
144
|
+
"INSERT INTO lease_tasks"
|
|
145
|
+
" (task_id, job_id, spec_json, state, attempts_used, active_lease_json,"
|
|
146
|
+
" accepted_attempt_id, lease_history_json, seq)"
|
|
147
|
+
" VALUES (?, ?, ?, ?, ?, ?, ?, ?,"
|
|
148
|
+
" COALESCE(?, (SELECT seq FROM lease_tasks"
|
|
149
|
+
" WHERE job_id = ? AND task_id = ?)))"
|
|
150
|
+
" ON CONFLICT(job_id, task_id) DO UPDATE SET"
|
|
151
|
+
" state=excluded.state, attempts_used=excluded.attempts_used,"
|
|
152
|
+
" active_lease_json=excluded.active_lease_json,"
|
|
153
|
+
" accepted_attempt_id=excluded.accepted_attempt_id,"
|
|
154
|
+
" lease_history_json=excluded.lease_history_json",
|
|
155
|
+
(
|
|
156
|
+
record.spec.task_id,
|
|
157
|
+
record.spec.job_id,
|
|
158
|
+
record.spec.model_dump_json(),
|
|
159
|
+
record.state.value,
|
|
160
|
+
record.attempts_used,
|
|
161
|
+
record.active_lease.model_dump_json() if record.active_lease else None,
|
|
162
|
+
record.accepted_attempt_id,
|
|
163
|
+
history,
|
|
164
|
+
seq,
|
|
165
|
+
record.spec.job_id,
|
|
166
|
+
record.spec.task_id,
|
|
167
|
+
),
|
|
168
|
+
)
|
|
169
|
+
self._conn.commit()
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
"""Storage interface for the lease manager, plus the in-memory reference
|
|
2
|
+
implementation.
|
|
3
|
+
|
|
4
|
+
The `LeaseStore` protocol is the seam between the pure state machine
|
|
5
|
+
(`manager.py`) and durability: the in-memory store backs unit tests and
|
|
6
|
+
embedded use; the FlashRuntime service wires a SQLite/Postgres store behind
|
|
7
|
+
the same five methods. Keeping the interface this small is deliberate — the
|
|
8
|
+
*semantics* (who may claim, when a commit wins) live entirely in the
|
|
9
|
+
manager, never in a store, so every backend behaves identically.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
from typing import Protocol
|
|
15
|
+
|
|
16
|
+
from flashruntime.protocol.v1alpha1 import Lease, TaskSpec, TaskState
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class TaskRecord:
|
|
20
|
+
"""Mutable server-side state of one task (not wire-visible).
|
|
21
|
+
|
|
22
|
+
`lease_history` keeps every lease ever issued for the task so a late
|
|
23
|
+
commit from a long-dead worker can be *identified and rejected* rather
|
|
24
|
+
than erroring as unknown — rejected duplicates are recovery evidence.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
__slots__ = ("spec", "state", "attempts_used", "active_lease", "accepted_attempt_id", "lease_history")
|
|
28
|
+
|
|
29
|
+
def __init__(self, spec: TaskSpec):
|
|
30
|
+
self.spec = spec
|
|
31
|
+
self.state = TaskState.PENDING
|
|
32
|
+
self.attempts_used = 0
|
|
33
|
+
self.active_lease: Lease | None = None
|
|
34
|
+
self.accepted_attempt_id: str | None = None
|
|
35
|
+
self.lease_history: dict[str, Lease] = {}
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class LeaseStore(Protocol):
|
|
39
|
+
"""Minimal persistence contract for tasks and their leases.
|
|
40
|
+
|
|
41
|
+
The manager mutates TaskRecords in memory and calls `save(record)` after
|
|
42
|
+
every state transition — durable stores persist there; the in-memory
|
|
43
|
+
store's live references make it a no-op."""
|
|
44
|
+
|
|
45
|
+
def add(self, record: TaskRecord) -> None: ...
|
|
46
|
+
|
|
47
|
+
def save(self, record: TaskRecord) -> None: ...
|
|
48
|
+
|
|
49
|
+
def get(self, job_id: str, task_id: str) -> TaskRecord | None: ...
|
|
50
|
+
|
|
51
|
+
def next_pending(self, job_id: str | None = None) -> TaskRecord | None:
|
|
52
|
+
"""Any PENDING task (optionally scoped to a job), or None. Must be
|
|
53
|
+
deterministic for a given state (e.g. insertion order)."""
|
|
54
|
+
...
|
|
55
|
+
|
|
56
|
+
def leased(self) -> list[TaskRecord]:
|
|
57
|
+
"""All tasks currently in LEASED state (for the expiry sweep)."""
|
|
58
|
+
...
|
|
59
|
+
|
|
60
|
+
def all(self, job_id: str | None = None) -> list[TaskRecord]: ...
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class InMemoryLeaseStore:
|
|
64
|
+
"""Reference implementation: insertion-ordered dict, no persistence.
|
|
65
|
+
|
|
66
|
+
Keyed by (job_id, task_id): task ids are positional within a job
|
|
67
|
+
(`task-000`), so two jobs routinely produce the same task_id.
|
|
68
|
+
"""
|
|
69
|
+
|
|
70
|
+
def __init__(self) -> None:
|
|
71
|
+
self._tasks: dict[tuple[str, str], TaskRecord] = {}
|
|
72
|
+
|
|
73
|
+
def add(self, record: TaskRecord) -> None:
|
|
74
|
+
key = (record.spec.job_id, record.spec.task_id)
|
|
75
|
+
if key in self._tasks:
|
|
76
|
+
raise ValueError(
|
|
77
|
+
f"task {record.spec.task_id} already exists in job {record.spec.job_id}"
|
|
78
|
+
)
|
|
79
|
+
self._tasks[key] = record
|
|
80
|
+
|
|
81
|
+
def save(self, record: TaskRecord) -> None:
|
|
82
|
+
pass # live references — mutations are already visible
|
|
83
|
+
|
|
84
|
+
def get(self, job_id: str, task_id: str) -> TaskRecord | None:
|
|
85
|
+
return self._tasks.get((job_id, task_id))
|
|
86
|
+
|
|
87
|
+
def next_pending(self, job_id: str | None = None) -> TaskRecord | None:
|
|
88
|
+
for record in self._tasks.values():
|
|
89
|
+
if record.state == TaskState.PENDING and (
|
|
90
|
+
job_id is None or record.spec.job_id == job_id
|
|
91
|
+
):
|
|
92
|
+
return record
|
|
93
|
+
return None
|
|
94
|
+
|
|
95
|
+
def leased(self) -> list[TaskRecord]:
|
|
96
|
+
return [r for r in self._tasks.values() if r.state == TaskState.LEASED]
|
|
97
|
+
|
|
98
|
+
def all(self, job_id: str | None = None) -> list[TaskRecord]:
|
|
99
|
+
return [
|
|
100
|
+
r
|
|
101
|
+
for r in self._tasks.values()
|
|
102
|
+
if job_id is None or r.spec.job_id == job_id
|
|
103
|
+
]
|