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.
Files changed (95) hide show
  1. flashml_workloads/__init__.py +7 -0
  2. flashml_workloads/fedavg_driver.py +569 -0
  3. flashml_workloads/fedavg_weights.py +223 -0
  4. flashml_workloads/fedavg_worker.py +166 -0
  5. flashml_workloads/kmeans_driver.py +134 -0
  6. flashml_workloads/kmeans_shard.py +69 -0
  7. flashml_workloads/sgd_trainer.py +127 -0
  8. flashml_workloads/sharded_kmeans.py +323 -0
  9. flashml_workloads/sklearn_trial.py +89 -0
  10. flashruntime/__init__.py +125 -0
  11. flashruntime/artifacts/__init__.py +25 -0
  12. flashruntime/artifacts/store.py +228 -0
  13. flashruntime/backends/__init__.py +26 -0
  14. flashruntime/backends/base.py +63 -0
  15. flashruntime/backends/kuberay.py +465 -0
  16. flashruntime/checkpoint/__init__.py +20 -0
  17. flashruntime/checkpoint/catalog.py +198 -0
  18. flashruntime/checkpoint/local.py +109 -0
  19. flashruntime/checkpoint/store.py +86 -0
  20. flashruntime/integrations/__init__.py +5 -0
  21. flashruntime/integrations/huggingface.py +59 -0
  22. flashruntime/integrations/pytorch.py +52 -0
  23. flashruntime/integrations/sklearn.py +42 -0
  24. flashruntime/launchers/__init__.py +130 -0
  25. flashruntime/launchers/local.py +126 -0
  26. flashruntime/leases/__init__.py +27 -0
  27. flashruntime/leases/manager.py +365 -0
  28. flashruntime/leases/sqlite_store.py +169 -0
  29. flashruntime/leases/store.py +103 -0
  30. flashruntime/monitor/__init__.py +7 -0
  31. flashruntime/monitor/sampler.py +232 -0
  32. flashruntime/planner/__init__.py +56 -0
  33. flashruntime/planner/candidates.py +597 -0
  34. flashruntime/planner/catalog.py +129 -0
  35. flashruntime/planner/comm.py +95 -0
  36. flashruntime/planner/explain.py +109 -0
  37. flashruntime/planner/memory.py +166 -0
  38. flashruntime/planner/resolve.py +120 -0
  39. flashruntime/planner/selector.py +169 -0
  40. flashruntime/planner/timecost.py +81 -0
  41. flashruntime/profiling/__init__.py +113 -0
  42. flashruntime/protocol/__init__.py +18 -0
  43. flashruntime/protocol/plan_v1alpha1.py +320 -0
  44. flashruntime/protocol/v1alpha1.py +465 -0
  45. flashruntime/providers/__init__.py +138 -0
  46. flashruntime/py.typed +0 -0
  47. flashruntime/recipes/__init__.py +135 -0
  48. flashruntime/recipes/command.py +166 -0
  49. flashruntime/recovery/__init__.py +21 -0
  50. flashruntime/recovery/policy.py +170 -0
  51. flashruntime/recovery/signals.py +135 -0
  52. flashruntime/recovery/taxonomy.py +91 -0
  53. flashruntime/scheduler/__init__.py +170 -0
  54. flashruntime/sdk.py +402 -0
  55. flashruntime/service/__init__.py +3 -0
  56. flashruntime/service/app.py +391 -0
  57. flashruntime/service/auth.py +180 -0
  58. flashruntime/service/checkpoints.py +90 -0
  59. flashruntime/service/cli.py +167 -0
  60. flashruntime/service/dashboard.py +193 -0
  61. flashruntime/service/ledger.py +101 -0
  62. flashruntime/service/modea.py +821 -0
  63. flashruntime/strategies/__init__.py +156 -0
  64. flashruntime/strategies/command.py +56 -0
  65. flashruntime/torch/__init__.py +274 -0
  66. flashruntime/viewer/__init__.py +20 -0
  67. flashruntime/viewer/_docs/benchmarks.html +771 -0
  68. flashruntime/viewer/_docs/concepts/architecture.html +302 -0
  69. flashruntime/viewer/_docs/get-started.html +263 -0
  70. flashruntime/viewer/_docs/guides/federated-averaging.html +363 -0
  71. flashruntime/viewer/_docs/guides/huggingface.html +223 -0
  72. flashruntime/viewer/_docs/guides/jobspec-and-isolation.html +271 -0
  73. flashruntime/viewer/_docs/guides/pytorch.html +313 -0
  74. flashruntime/viewer/_docs/guides/sklearn.html +232 -0
  75. flashruntime/viewer/_docs/index.html +251 -0
  76. flashruntime/viewer/_docs/reference/cli.html +254 -0
  77. flashruntime/viewer/_docs/reference/integrations.html +240 -0
  78. flashruntime/viewer/_docs/reference/sdk.html +341 -0
  79. flashruntime/viewer/_docs/reference/torch-helper.html +244 -0
  80. flashruntime/viewer/_docs/search-index.json +1 -0
  81. flashruntime/viewer/_docs/tutorials/convnet.html +571 -0
  82. flashruntime/viewer/_docs/tutorials/fault-tolerance.html +375 -0
  83. flashruntime/viewer/_docs/tutorials/sklearn-sweeps.html +278 -0
  84. flashruntime/viewer/flowmap.py +307 -0
  85. flashruntime/viewer/page.py +594 -0
  86. flashruntime/viewer/server.py +134 -0
  87. flashruntime/viewer/state.py +250 -0
  88. flashruntime/workloads/__init__.py +6 -0
  89. flashruntime/workloads/command.py +127 -0
  90. flashruntime-0.3.0.dist-info/METADATA +365 -0
  91. flashruntime-0.3.0.dist-info/RECORD +95 -0
  92. flashruntime-0.3.0.dist-info/WHEEL +5 -0
  93. flashruntime-0.3.0.dist-info/entry_points.txt +2 -0
  94. flashruntime-0.3.0.dist-info/licenses/LICENSE +202 -0
  95. 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
+ ]
@@ -0,0 +1,7 @@
1
+ """Run telemetry: the optional-psutil resource sampler the SDK starts per
2
+ launched attempt. Read side: `viewer.state.collect()` tails telemetry.jsonl.
3
+ """
4
+
5
+ from flashruntime.monitor.sampler import ResourceSampler
6
+
7
+ __all__ = ["ResourceSampler"]