specimux-cloud 0.1.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.
@@ -0,0 +1,19 @@
1
+ """specimux-cloud: the hosted form of specimux-suite.
2
+
3
+ Four parts, matching docs/DESIGN.md:
4
+
5
+ - ``runapi``: the always-on service the browser, the uploader and hosts
6
+ (mycomap.org, the console) talk to — event store and fan-out, per-run
7
+ dashboard, job control, uploads, results, run sessions.
8
+ - ``console``: a built-in host — login with a service key, job page,
9
+ dashboard links, downloads; talks to the run API over HTTP only.
10
+ - ``engine``: what runs inside a compute job — a wrapper around
11
+ ``specimux-suite`` plus the cloud plugin that forwards events and
12
+ applies commands.
13
+ - ``backends``: storage, command queue, job launcher and control-plane
14
+ store behind small interfaces, with local implementations (a
15
+ directory, memory, subprocesses, SQLite) and AWS ones (S3, SQS,
16
+ Batch, DynamoDB). The run API's logic is the same over both.
17
+ """
18
+
19
+ __version__ = "0.1.0"
@@ -0,0 +1,5 @@
1
+ from .base import (CommandQueue, JobHandle, JobSpec, JobStatus, Launcher,
2
+ Message, ObjectInfo, Storage, Store)
3
+
4
+ __all__ = ["CommandQueue", "JobHandle", "JobSpec", "JobStatus", "Launcher",
5
+ "Message", "ObjectInfo", "Storage", "Store"]
@@ -0,0 +1,487 @@
1
+ """AWS backends: S3, SQS, Batch and DynamoDB behind the same interfaces as
2
+ the local ones, so the run API's logic does not change.
3
+
4
+ Conventions:
5
+
6
+ - One S3 bucket, keys exactly as the local DirectoryStorage lays them out.
7
+ Presigned URLs are S3's own; ETags are S3's (MD5 for single-part PUTs,
8
+ which is what the uploader does today).
9
+ - One SQS standard queue per run, named after the run id, created on
10
+ first use; at-least-once delivery with a visibility timeout, like
11
+ MemoryQueue.
12
+ - Batch jobs named ``<run>-<kind>-<generation>``; ``find_by_name`` lists
13
+ the queue's jobs by that name, which is how a lost submission is
14
+ adopted.
15
+ - One DynamoDB table with a composite key (``pk``, ``sk``): runs
16
+ (``RUN#<id>`` / ``RUN``), a client-token index row, archives, intents,
17
+ commands (``RUN#<id>`` / ``CMD#<id>``) and stage slots
18
+ (``STAGE#<name>`` / ``SLOT#<n>``), all written with condition
19
+ expressions.
20
+ """
21
+
22
+ import json
23
+ import logging
24
+ import time
25
+ import uuid
26
+ from decimal import Decimal
27
+ from pathlib import Path
28
+ from typing import Iterable, Optional
29
+
30
+ from .base import ConflictError, JobHandle, JobSpec, JobStatus, Message, ObjectInfo
31
+
32
+ logger = logging.getLogger(__name__)
33
+
34
+
35
+ def _boto3():
36
+ try:
37
+ import boto3
38
+ return boto3
39
+ except ImportError as e: # pragma: no cover
40
+ raise RuntimeError("AWS backends need boto3: pip install 'specimux-cloud[aws]'") from e
41
+
42
+
43
+ # --- Storage ---
44
+
45
+ class S3Storage:
46
+ def __init__(self, bucket: str, region: Optional[str] = None, client=None):
47
+ self.bucket = bucket
48
+ if client is None:
49
+ # SigV4 with a regional endpoint: the legacy signer produces
50
+ # global-endpoint URLs that S3 answers with a 307 the uploader
51
+ # must not follow (the redirect drops the body on some clients)
52
+ from botocore.config import Config
53
+ client = _boto3().client(
54
+ "s3", region_name=region,
55
+ endpoint_url=f"https://s3.{region}.amazonaws.com" if region else None,
56
+ config=Config(signature_version="s3v4", s3={"addressing_style": "virtual"}))
57
+ self.client = client
58
+
59
+ @staticmethod
60
+ def _etag(raw: str) -> str:
61
+ return (raw or "").strip('"')
62
+
63
+ def put(self, key: str, data: bytes) -> ObjectInfo:
64
+ r = self.client.put_object(Bucket=self.bucket, Key=key, Body=data)
65
+ return ObjectInfo(key, len(data), self._etag(r.get("ETag", "")))
66
+
67
+ def put_file(self, key: str, path: Path) -> ObjectInfo:
68
+ self.client.upload_file(str(path), self.bucket, key)
69
+ return self.head(key)
70
+
71
+ def get(self, key: str) -> bytes:
72
+ return self.client.get_object(Bucket=self.bucket, Key=key)["Body"].read()
73
+
74
+ def download(self, key: str, dest: Path) -> ObjectInfo:
75
+ dest = Path(dest)
76
+ dest.parent.mkdir(parents=True, exist_ok=True)
77
+ self.client.download_file(self.bucket, key, str(dest))
78
+ return self.head(key)
79
+
80
+ def head(self, key: str) -> Optional[ObjectInfo]:
81
+ try:
82
+ r = self.client.head_object(Bucket=self.bucket, Key=key)
83
+ except self.client.exceptions.ClientError as e:
84
+ if e.response.get("Error", {}).get("Code") in ("404", "NoSuchKey", "NotFound"):
85
+ return None
86
+ raise
87
+ return ObjectInfo(key, int(r["ContentLength"]), self._etag(r.get("ETag", "")))
88
+
89
+ def list(self, prefix: str) -> list[ObjectInfo]:
90
+ out = []
91
+ paginator = self.client.get_paginator("list_objects_v2")
92
+ for page in paginator.paginate(Bucket=self.bucket, Prefix=prefix):
93
+ for o in page.get("Contents", []):
94
+ out.append(ObjectInfo(o["Key"], int(o["Size"]), self._etag(o.get("ETag", ""))))
95
+ return out
96
+
97
+ def delete(self, key: str) -> None:
98
+ self.client.delete_object(Bucket=self.bucket, Key=key)
99
+
100
+ def delete_prefix(self, prefix: str) -> int:
101
+ objs = self.list(prefix)
102
+ for i in range(0, len(objs), 1000):
103
+ chunk = objs[i:i + 1000]
104
+ self.client.delete_objects(Bucket=self.bucket,
105
+ Delete={"Objects": [{"Key": o.key} for o in chunk], "Quiet": True})
106
+ return len(objs)
107
+
108
+ def presign_put(self, key: str, expires_s: int = 3600) -> str:
109
+ return self.client.generate_presigned_url("put_object", Params={"Bucket": self.bucket, "Key": key},
110
+ ExpiresIn=expires_s, HttpMethod="PUT")
111
+
112
+ def presign_get(self, key: str, expires_s: int = 3600) -> str:
113
+ return self.client.generate_presigned_url("get_object", Params={"Bucket": self.bucket, "Key": key},
114
+ ExpiresIn=expires_s)
115
+
116
+
117
+ # --- Command queue ---
118
+
119
+ class SqsQueue:
120
+ def __init__(self, prefix: str = "specimux-cloud", region: Optional[str] = None,
121
+ visibility_s: int = 30, client=None):
122
+ self.prefix = prefix
123
+ self.visibility_s = visibility_s
124
+ self.client = client or _boto3().client("sqs", region_name=region)
125
+ self._urls: dict[str, str] = {}
126
+
127
+ def _url(self, run_id: str, create: bool = True) -> Optional[str]:
128
+ url = self._urls.get(run_id)
129
+ if url:
130
+ return url
131
+ name = f"{self.prefix}-{run_id}"
132
+ try:
133
+ url = self.client.get_queue_url(QueueName=name)["QueueUrl"]
134
+ except self.client.exceptions.QueueDoesNotExist:
135
+ if not create:
136
+ return None
137
+ url = self.client.create_queue(QueueName=name, Attributes={
138
+ "VisibilityTimeout": str(self.visibility_s),
139
+ "MessageRetentionPeriod": str(4 * 24 * 3600),
140
+ })["QueueUrl"]
141
+ self._urls[run_id] = url
142
+ return url
143
+
144
+ def send(self, run_id: str, body: dict) -> str:
145
+ r = self.client.send_message(QueueUrl=self._url(run_id), MessageBody=json.dumps(body))
146
+ return r["MessageId"]
147
+
148
+ def receive(self, run_id: str, wait_s: float = 0.0, max_messages: int = 10) -> list[Message]:
149
+ url = self._url(run_id, create=False)
150
+ if url is None:
151
+ return []
152
+ r = self.client.receive_message(QueueUrl=url, MaxNumberOfMessages=max(1, min(10, max_messages)),
153
+ WaitTimeSeconds=int(min(20, max(0, wait_s))))
154
+ out = []
155
+ for m in r.get("Messages", []):
156
+ try:
157
+ body = json.loads(m["Body"])
158
+ except ValueError:
159
+ body = {"raw": m["Body"]}
160
+ # the receipt handle is what deletes the message: it is the id we hand out
161
+ out.append(Message(m["ReceiptHandle"], body))
162
+ return out
163
+
164
+ def ack(self, run_id: str, message_id: str) -> None:
165
+ url = self._url(run_id, create=False)
166
+ if url:
167
+ self.client.delete_message(QueueUrl=url, ReceiptHandle=message_id)
168
+
169
+ def purge(self, run_id: str) -> None:
170
+ url = self._url(run_id, create=False)
171
+ if url:
172
+ self.client.delete_queue(QueueUrl=url)
173
+ self._urls.pop(run_id, None)
174
+
175
+
176
+ # --- Launcher ---
177
+
178
+ class BatchLauncher:
179
+ """AWS Batch. ``job_queues`` and ``job_definitions`` map a job kind
180
+ (``engine``, ``dorado``) to the queue and definition to submit to."""
181
+
182
+ _STATES = {
183
+ "SUBMITTED": "pending", "PENDING": "pending", "RUNNABLE": "pending",
184
+ "STARTING": "pending", "RUNNING": "running",
185
+ "SUCCEEDED": "succeeded", "FAILED": "failed",
186
+ }
187
+
188
+ def __init__(self, job_queues: dict, job_definitions: dict, region: Optional[str] = None, client=None):
189
+ self.job_queues = dict(job_queues)
190
+ self.job_definitions = dict(job_definitions)
191
+ self.client = client or _boto3().client("batch", region_name=region)
192
+
193
+ def submit(self, spec: JobSpec) -> JobHandle:
194
+ r = self.client.submit_job(
195
+ jobName=spec.name,
196
+ jobQueue=self.job_queues[spec.kind],
197
+ jobDefinition=self.job_definitions[spec.kind],
198
+ containerOverrides={
199
+ "environment": [{"name": k, "value": str(v)} for k, v in spec.env.items()],
200
+ **({"command": list(spec.args)} if spec.args else {}),
201
+ **({"resourceRequirements": [
202
+ {"type": "VCPU", "value": str(spec.vcpus)},
203
+ {"type": "MEMORY", "value": str(spec.memory_mib or spec.vcpus * 1900)},
204
+ ]} if spec.vcpus else {}),
205
+ },
206
+ tags={"run_id": spec.run_id, "generation": str(spec.generation), "kind": spec.kind},
207
+ propagateTags=True,
208
+ **({"timeout": {"attemptDurationSeconds": int(spec.timeout_s)}} if spec.timeout_s else {}),
209
+ )
210
+ return JobHandle(r["jobId"], spec.name)
211
+
212
+ def describe(self, job_id: str) -> JobStatus:
213
+ jobs = self.client.describe_jobs(jobs=[job_id]).get("jobs", [])
214
+ if not jobs:
215
+ return JobStatus("unknown", reason="no such job")
216
+ j = jobs[0]
217
+ state = self._STATES.get(j.get("status"), "unknown")
218
+ container = j.get("container") or {}
219
+ code = container.get("exitCode")
220
+ reason = j.get("statusReason") or container.get("reason") or ""
221
+ return JobStatus(state, exit_code=int(code) if code is not None else None, reason=reason)
222
+
223
+ def find_by_name(self, name: str) -> Optional[JobHandle]:
224
+ for queue in set(self.job_queues.values()):
225
+ r = self.client.list_jobs(jobQueue=queue, filters=[{"name": "JOB_NAME", "values": [name]}])
226
+ summaries = r.get("jobSummaryList", [])
227
+ if summaries:
228
+ # the most recent submission of that name
229
+ latest = max(summaries, key=lambda s: s.get("createdAt", 0))
230
+ return JobHandle(latest["jobId"], name)
231
+ return None
232
+
233
+ def cancel(self, job_id: str, reason: str = "") -> None:
234
+ self.client.terminate_job(jobId=job_id, reason=reason or "cancelled by run API")
235
+
236
+
237
+ # --- Store ---
238
+
239
+ def _to_ddb(value):
240
+ """JSON → DynamoDB-safe (floats become Decimal)."""
241
+ return json.loads(json.dumps(value), parse_float=Decimal)
242
+
243
+
244
+ def _from_ddb(value):
245
+ if isinstance(value, list):
246
+ return [_from_ddb(v) for v in value]
247
+ if isinstance(value, dict):
248
+ return {k: _from_ddb(v) for k, v in value.items()}
249
+ if isinstance(value, Decimal):
250
+ return int(value) if value == value.to_integral_value() else float(value)
251
+ return value
252
+
253
+
254
+ class DynamoStore:
255
+ def __init__(self, table: str, region: Optional[str] = None, resource=None):
256
+ self.table_name = table
257
+ self.resource = resource or _boto3().resource("dynamodb", region_name=region)
258
+ self.table = self.resource.Table(table)
259
+
260
+ @staticmethod
261
+ def create_table(table: str, region: Optional[str] = None, client=None) -> None:
262
+ """Create the table (pay per request); for CDK-less setups and tests."""
263
+ client = client or _boto3().client("dynamodb", region_name=region)
264
+ client.create_table(
265
+ TableName=table,
266
+ AttributeDefinitions=[{"AttributeName": "pk", "AttributeType": "S"},
267
+ {"AttributeName": "sk", "AttributeType": "S"}],
268
+ KeySchema=[{"AttributeName": "pk", "KeyType": "HASH"}, {"AttributeName": "sk", "KeyType": "RANGE"}],
269
+ BillingMode="PAY_PER_REQUEST",
270
+ )
271
+ client.get_waiter("table_exists").wait(TableName=table)
272
+
273
+ def _cond_failed(self, e) -> bool:
274
+ return e.response.get("Error", {}).get("Code") == "ConditionalCheckFailedException"
275
+
276
+ # runs
277
+ def create_run(self, run: dict, client_token: Optional[str] = None) -> dict:
278
+ if client_token:
279
+ existing = self.table.get_item(Key={"pk": f"TOKEN#{client_token}", "sk": "TOKEN"}).get("Item")
280
+ if existing:
281
+ return self.get_run(existing["run_id"])
282
+ now = time.time()
283
+ run = {**run, "created": run.get("created", now), "updated": now}
284
+ self.table.put_item(Item={"pk": f"RUN#{run['id']}", "sk": "RUN", "kind": "run",
285
+ "user_id": run.get("user_id"), "host": run.get("host"),
286
+ "state": run.get("state"),
287
+ "created": _to_ddb(run["created"]), "doc": _to_ddb(run)},
288
+ ConditionExpression="attribute_not_exists(pk)")
289
+ if client_token:
290
+ try:
291
+ self.table.put_item(Item={"pk": f"TOKEN#{client_token}", "sk": "TOKEN", "run_id": run["id"]},
292
+ ConditionExpression="attribute_not_exists(pk)")
293
+ except self.table.meta.client.exceptions.ConditionalCheckFailedException:
294
+ # lost a race with an identical create: hand back the winner
295
+ self.table.delete_item(Key={"pk": f"RUN#{run['id']}", "sk": "RUN"})
296
+ return self.create_run(run, client_token)
297
+ return run
298
+
299
+ def get_run(self, run_id: str) -> Optional[dict]:
300
+ item = self.table.get_item(Key={"pk": f"RUN#{run_id}", "sk": "RUN"}).get("Item")
301
+ return _from_ddb(item["doc"]) if item else None
302
+
303
+ def update_run(self, run_id: str, updates: dict, expected_state: Optional[Iterable[str]] = None) -> dict:
304
+ for attempt in range(5):
305
+ run = self.get_run(run_id)
306
+ if run is None:
307
+ raise ConflictError(f"no run {run_id}")
308
+ if expected_state is not None and run.get("state") not in set(expected_state):
309
+ raise ConflictError(f"run {run_id} is {run.get('state')}, expected {list(expected_state)}")
310
+ new = {**run, **(updates(dict(run)) if callable(updates) else updates), "updated": time.time()}
311
+ try:
312
+ # optimistic: the stored doc must still be the one we read
313
+ self.table.put_item(
314
+ Item={"pk": f"RUN#{run_id}", "sk": "RUN", "kind": "run", "user_id": new.get("user_id"),
315
+ "host": new.get("host"), "state": new.get("state"),
316
+ "created": _to_ddb(new.get("created")), "doc": _to_ddb(new)},
317
+ ConditionExpression="attribute_exists(pk) AND #d.updated = :u",
318
+ ExpressionAttributeNames={"#d": "doc"},
319
+ ExpressionAttributeValues={":u": _to_ddb(run.get("updated"))},
320
+ )
321
+ return new
322
+ except self.table.meta.client.exceptions.ConditionalCheckFailedException:
323
+ continue # someone else wrote first: re-read and retry
324
+ raise ConflictError(f"run {run_id}: too much contention")
325
+
326
+ def list_runs(self, user_id: Optional[str] = None, states: Optional[Iterable[str]] = None,
327
+ host: Optional[str] = None) -> list[dict]:
328
+ from boto3.dynamodb.conditions import Attr
329
+ expr = Attr("kind").eq("run")
330
+ if user_id is not None:
331
+ expr = expr & Attr("user_id").eq(user_id)
332
+ if host is not None:
333
+ expr = expr & Attr("host").eq(host)
334
+ items = []
335
+ kwargs = {"FilterExpression": expr}
336
+ while True:
337
+ r = self.table.scan(**kwargs)
338
+ items.extend(r.get("Items", []))
339
+ if "LastEvaluatedKey" not in r:
340
+ break
341
+ kwargs["ExclusiveStartKey"] = r["LastEvaluatedKey"]
342
+ runs = sorted((_from_ddb(i["doc"]) for i in items), key=lambda r: r.get("created", 0))
343
+ if states is not None:
344
+ wanted = set(states)
345
+ runs = [r for r in runs if r.get("state") in wanted]
346
+ return runs
347
+
348
+ def delete_run(self, run_id: str) -> None:
349
+ from boto3.dynamodb.conditions import Key
350
+ r = self.table.query(KeyConditionExpression=Key("pk").eq(f"RUN#{run_id}"))
351
+ with self.table.batch_writer() as batch:
352
+ for item in r.get("Items", []):
353
+ batch.delete_item(Key={"pk": item["pk"], "sk": item["sk"]})
354
+
355
+ # hosts
356
+ def put_host(self, host: dict) -> dict:
357
+ self.table.put_item(Item={"pk": f"HOST#{host['id']}", "sk": "HOST", "kind": "host",
358
+ "doc": _to_ddb(host)})
359
+ return host
360
+
361
+ def get_host(self, host_id: str) -> Optional[dict]:
362
+ item = self.table.get_item(Key={"pk": f"HOST#{host_id}", "sk": "HOST"}).get("Item")
363
+ return _from_ddb(item["doc"]) if item else None
364
+
365
+ def list_hosts(self) -> list[dict]:
366
+ from boto3.dynamodb.conditions import Attr
367
+ items, kwargs = [], {"FilterExpression": Attr("kind").eq("host")}
368
+ while True:
369
+ r = self.table.scan(**kwargs)
370
+ items.extend(r.get("Items", []))
371
+ if "LastEvaluatedKey" not in r:
372
+ break
373
+ kwargs["ExclusiveStartKey"] = r["LastEvaluatedKey"]
374
+ return sorted((_from_ddb(i["doc"]) for i in items), key=lambda h: h["id"])
375
+
376
+ # archives
377
+ def put_archive(self, archive: dict) -> dict:
378
+ self.table.put_item(Item={"pk": f"ARCHIVE#{archive['id']}", "sk": "ARCHIVE", "kind": "archive",
379
+ "user_id": archive.get("user_id"), "doc": _to_ddb(archive)})
380
+ return archive
381
+
382
+ def get_archive(self, archive_id: str) -> Optional[dict]:
383
+ item = self.table.get_item(Key={"pk": f"ARCHIVE#{archive_id}", "sk": "ARCHIVE"}).get("Item")
384
+ return _from_ddb(item["doc"]) if item else None
385
+
386
+ # intents
387
+ def open_intent(self, run_id: str, kind: str, payload: dict) -> str:
388
+ iid = uuid.uuid4().hex
389
+ self.table.put_item(Item={"pk": "INTENTS", "sk": f"OPEN#{iid}", "kind": "intent", "id": iid,
390
+ "run_id": run_id, "intent_kind": kind, "payload": _to_ddb(payload),
391
+ "opened": _to_ddb(time.time())})
392
+ return iid
393
+
394
+ def resolve_intent(self, intent_id: str, result: dict) -> None:
395
+ item = self.table.get_item(Key={"pk": "INTENTS", "sk": f"OPEN#{intent_id}"}).get("Item")
396
+ if not item:
397
+ return
398
+ self.table.put_item(Item={**item, "pk": f"RUN#{item['run_id']}", "sk": f"INTENT#{intent_id}",
399
+ "result": _to_ddb(result), "resolved": _to_ddb(time.time())})
400
+ self.table.delete_item(Key={"pk": "INTENTS", "sk": f"OPEN#{intent_id}"})
401
+
402
+ def list_open_intents(self, run_id: Optional[str] = None) -> list[dict]:
403
+ from boto3.dynamodb.conditions import Key
404
+ r = self.table.query(KeyConditionExpression=Key("pk").eq("INTENTS"))
405
+ out = []
406
+ for item in sorted(r.get("Items", []), key=lambda i: i.get("opened", 0)):
407
+ if run_id is not None and item.get("run_id") != run_id:
408
+ continue
409
+ out.append({"id": item["id"], "run_id": item["run_id"], "kind": item["intent_kind"],
410
+ "payload": _from_ddb(item.get("payload", {})), "opened": _from_ddb(item.get("opened"))})
411
+ return out
412
+
413
+ # commands
414
+ def put_command(self, run_id: str, command: dict) -> dict:
415
+ now = time.time()
416
+ doc = {**command, "run_id": run_id, "outcome": "pending", "created": now}
417
+ try:
418
+ self.table.put_item(Item={"pk": f"RUN#{run_id}", "sk": f"CMD#{command['id']}", "kind": "command",
419
+ "outcome": "pending", "created": _to_ddb(now), "doc": _to_ddb(doc)},
420
+ ConditionExpression="attribute_not_exists(pk)")
421
+ except self.table.meta.client.exceptions.ConditionalCheckFailedException:
422
+ pass
423
+ return doc
424
+
425
+ def get_command(self, run_id: str, command_id: str) -> Optional[dict]:
426
+ item = self.table.get_item(Key={"pk": f"RUN#{run_id}", "sk": f"CMD#{command_id}"}).get("Item")
427
+ if not item:
428
+ return None
429
+ return {**_from_ddb(item["doc"]), "outcome": item.get("outcome"), "reason": item.get("reason")}
430
+
431
+ def mark_command(self, run_id: str, command_id: str, outcome: str, reason: Optional[str] = None) -> None:
432
+ try:
433
+ self.table.update_item(Key={"pk": f"RUN#{run_id}", "sk": f"CMD#{command_id}"},
434
+ UpdateExpression="SET outcome = :o, reason = :r, updated = :u",
435
+ ConditionExpression="attribute_exists(pk)",
436
+ ExpressionAttributeValues={":o": outcome, ":r": reason, ":u": _to_ddb(time.time())})
437
+ except self.table.meta.client.exceptions.ConditionalCheckFailedException:
438
+ pass
439
+
440
+ def list_commands(self, run_id: str, pending_only: bool = False) -> list[dict]:
441
+ from boto3.dynamodb.conditions import Key
442
+ r = self.table.query(KeyConditionExpression=Key("pk").eq(f"RUN#{run_id}") & Key("sk").begins_with("CMD#"))
443
+ cmds = [{**_from_ddb(i["doc"]), "outcome": i.get("outcome"), "reason": i.get("reason")}
444
+ for i in sorted(r.get("Items", []), key=lambda i: i.get("created", 0))]
445
+ if pending_only:
446
+ cmds = [c for c in cmds if c["outcome"] == "pending"]
447
+ return cmds
448
+
449
+ # reservations: one item per taken slot, SLOT#0 .. SLOT#<slots-1>
450
+ def _slot_items(self, stage: str) -> list[dict]:
451
+ from boto3.dynamodb.conditions import Key
452
+ r = self.table.query(KeyConditionExpression=Key("pk").eq(f"STAGE#{stage}") & Key("sk").begins_with("SLOT#"),
453
+ ConsistentRead=True)
454
+ return sorted(r.get("Items", []), key=lambda i: int(i["sk"][5:]))
455
+
456
+ def reserve_stage(self, stage: str, run_id: str, slots: int = 1) -> bool:
457
+ errors = self.table.meta.client.exceptions
458
+ for _ in range(3):
459
+ items = self._slot_items(stage)
460
+ if any(i.get("run_id") == run_id for i in items):
461
+ return True
462
+ taken = {int(i["sk"][5:]) for i in items}
463
+ free = [n for n in range(slots) if n not in taken]
464
+ if not free:
465
+ return False
466
+ try:
467
+ self.table.put_item(Item={"pk": f"STAGE#{stage}", "sk": f"SLOT#{free[0]}", "run_id": run_id,
468
+ "since": _to_ddb(time.time())},
469
+ ConditionExpression="attribute_not_exists(pk)")
470
+ return True
471
+ except errors.ConditionalCheckFailedException:
472
+ continue # another run took that slot first; look again
473
+ return False
474
+
475
+ def release_stage(self, stage: str, run_id: str) -> None:
476
+ for item in self._slot_items(stage):
477
+ if item.get("run_id") != run_id:
478
+ continue
479
+ try:
480
+ self.table.delete_item(Key={"pk": item["pk"], "sk": item["sk"]},
481
+ ConditionExpression="run_id = :r",
482
+ ExpressionAttributeValues={":r": run_id})
483
+ except self.table.meta.client.exceptions.ConditionalCheckFailedException:
484
+ pass
485
+
486
+ def stage_holders(self, stage: str) -> list[str]:
487
+ return [i["run_id"] for i in self._slot_items(stage)]