launchhelm 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.
- launchhelm/__init__.py +35 -0
- launchhelm/_version.py +5 -0
- launchhelm/client.py +1660 -0
- launchhelm/commands.py +231 -0
- launchhelm/env.py +70 -0
- launchhelm/errors.py +62 -0
- launchhelm/models.py +6546 -0
- launchhelm/py.typed +0 -0
- launchhelm/rich.py +807 -0
- launchhelm/runtime.py +1348 -0
- launchhelm/session.py +2824 -0
- launchhelm/store.py +893 -0
- launchhelm/system.py +213 -0
- launchhelm/torch/__init__.py +41 -0
- launchhelm/torch/_checkpoint.py +181 -0
- launchhelm/torch/_controls.py +306 -0
- launchhelm/torch/_dist.py +210 -0
- launchhelm/torch/_trainer.py +1167 -0
- launchhelm/torch/hf.py +172 -0
- launchhelm/torch/lightning.py +179 -0
- launchhelm/transport.py +197 -0
- launchhelm/values.py +325 -0
- launchhelm-0.1.0.dist-info/METADATA +193 -0
- launchhelm-0.1.0.dist-info/RECORD +26 -0
- launchhelm-0.1.0.dist-info/WHEEL +4 -0
- launchhelm-0.1.0.dist-info/licenses/LICENSE +201 -0
launchhelm/runtime.py
ADDED
|
@@ -0,0 +1,1348 @@
|
|
|
1
|
+
"""Cooperative LaunchHelm runtime: telemetry spool plus safe-point commands.
|
|
2
|
+
|
|
3
|
+
Contract:
|
|
4
|
+
* ``log`` admits a point into a bounded in-memory queue (volatile).
|
|
5
|
+
* ``flush(delivered=False)`` waits until the writer has persisted the
|
|
6
|
+
queue to SQLite (local durability).
|
|
7
|
+
* ``flush()`` waits until the uploader has a query-visible receipt.
|
|
8
|
+
* ``close`` persists the local queue but does not promise server delivery.
|
|
9
|
+
* Memory and spool limits are serialized-payload budgets.
|
|
10
|
+
* Command tombstones use a separate journal budget; exhaustion fails
|
|
11
|
+
closed via ``BufferFull``.
|
|
12
|
+
* Unknown effects are never automatically re-executed.
|
|
13
|
+
|
|
14
|
+
Threading:
|
|
15
|
+
Shared mutable state lives behind ``self._lock`` (an ``RLock``).
|
|
16
|
+
``self._space`` is a ``Condition`` on that same lock and is used for
|
|
17
|
+
memory-budget wait/notify. ``StateStore`` has its own ``_mutex``.
|
|
18
|
+
Lock ordering: never acquire ``Runtime._lock`` while holding
|
|
19
|
+
``StateStore._mutex``. ``log`` holds ``Runtime._lock`` only for queue
|
|
20
|
+
admission and never calls the store. Background threads call the store
|
|
21
|
+
without the runtime lock.
|
|
22
|
+
|
|
23
|
+
Protected by ``Runtime._lock``: ``_session`` (epoch / state revision),
|
|
24
|
+
``_fatal``, ``_accepting``, ``_closed``, ``_memory_used``, ``_handlers``,
|
|
25
|
+
``_active``, ``_safe_points``, ``_safe_points_dirty``, ``dropped``,
|
|
26
|
+
``last_transport_error``.
|
|
27
|
+
``queue.Queue`` instances are themselves thread-safe.
|
|
28
|
+
``httpx.Client`` request methods are thread-safe; the runtime issues
|
|
29
|
+
them from the caller, control, and uploader threads.
|
|
30
|
+
|
|
31
|
+
Failure: ``log`` / ``flush`` / ``safe_point`` raise the first fatal
|
|
32
|
+
background error, preferring ``RuntimeFenced`` if a fence is observed.
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
from __future__ import annotations
|
|
36
|
+
|
|
37
|
+
import copy
|
|
38
|
+
import queue
|
|
39
|
+
import re
|
|
40
|
+
import threading
|
|
41
|
+
import time
|
|
42
|
+
import uuid
|
|
43
|
+
import warnings
|
|
44
|
+
from collections.abc import Callable, Sequence
|
|
45
|
+
from pathlib import Path
|
|
46
|
+
from typing import TYPE_CHECKING, Literal, NoReturn
|
|
47
|
+
|
|
48
|
+
from jsonschema import Draft202012Validator, FormatChecker
|
|
49
|
+
from referencing import Registry
|
|
50
|
+
from referencing.exceptions import NoSuchResource
|
|
51
|
+
|
|
52
|
+
from .client import RuntimeClient
|
|
53
|
+
from .errors import BufferFull, LaunchHelmError, RuntimeFenced
|
|
54
|
+
from .models import Capability, CommandContext, Document, Reference, Scope, timestamp
|
|
55
|
+
from .store import OversizePoint, StateStore, TelemetryBatch
|
|
56
|
+
from .values import (
|
|
57
|
+
JSONObject,
|
|
58
|
+
JSONSchema,
|
|
59
|
+
JSONValue,
|
|
60
|
+
encode,
|
|
61
|
+
fingerprint,
|
|
62
|
+
json_object,
|
|
63
|
+
string_object,
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
if TYPE_CHECKING:
|
|
67
|
+
from .rich import PendingBlob
|
|
68
|
+
|
|
69
|
+
OverflowPolicy = Literal["drop_new", "block", "raise"]
|
|
70
|
+
CapabilityHandler = Callable[[JSONValue, CommandContext], JSONValue]
|
|
71
|
+
CapabilityValidator = Callable[[JSONValue], None]
|
|
72
|
+
|
|
73
|
+
MIN_MEMORY_BYTES = 1 << 20
|
|
74
|
+
MAX_VOLATILE_POINT_BYTES = (1 << 19) - 128
|
|
75
|
+
HEARTBEAT_INTERVAL_SECONDS = 15.0
|
|
76
|
+
CONTROL_POLL_SECONDS = 0.25
|
|
77
|
+
CLAIM_WAIT_MS = 19_000
|
|
78
|
+
WRITER_POLL_SECONDS = 0.1
|
|
79
|
+
WRITER_BATCH_MAX = 512
|
|
80
|
+
WRITER_BATCH_BYTES = 1 << 20
|
|
81
|
+
UPLOAD_IDLE_SECONDS = 0.1
|
|
82
|
+
UPLOAD_MAX_BACKOFF_SECONDS = 10.0
|
|
83
|
+
FLUSH_POLL_SECONDS = 0.02
|
|
84
|
+
FENCE_WAIT_SECONDS = 1.0
|
|
85
|
+
DEFAULT_FLUSH_TIMEOUT_SECONDS = 30.0
|
|
86
|
+
DEFAULT_CLOSE_TIMEOUT_SECONDS = 35.0
|
|
87
|
+
CONTROL_MAX_BACKOFF_SECONDS = 10.0
|
|
88
|
+
MAX_SAFE_POINTS = 64
|
|
89
|
+
_SAFE_POINT_NAME = re.compile(r"^[a-z][a-z0-9_.:-]{0,127}$")
|
|
90
|
+
_DECIMAL = re.compile(r"^-?(0|[1-9][0-9]*)(\.[0-9]+)?$")
|
|
91
|
+
|
|
92
|
+
_COORDINATES: JSONSchema = {
|
|
93
|
+
"type": "array",
|
|
94
|
+
"minItems": 1,
|
|
95
|
+
"maxItems": 32,
|
|
96
|
+
"items": {
|
|
97
|
+
"type": "object",
|
|
98
|
+
"required": ["axis", "value"],
|
|
99
|
+
"properties": {
|
|
100
|
+
"axis": {"type": "string", "pattern": "^[a-z][a-z0-9_.:-]{0,127}$"},
|
|
101
|
+
"value": {
|
|
102
|
+
"type": "string",
|
|
103
|
+
"maxLength": 128,
|
|
104
|
+
"pattern": r"^-?(0|[1-9][0-9]*)(\.[0-9]+)?$",
|
|
105
|
+
},
|
|
106
|
+
"unit": {"type": "string", "maxLength": 64},
|
|
107
|
+
},
|
|
108
|
+
"additionalProperties": False,
|
|
109
|
+
},
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _no_remote(uri: str) -> NoReturn:
|
|
114
|
+
# referencing.NoSuchResource is an attrs frozen exception; the runtime
|
|
115
|
+
# stubs do not expose the ``ref`` keyword even though the library API does.
|
|
116
|
+
raise NoSuchResource(ref=uri) # type: ignore[call-arg]
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _validate(schema: JSONSchema, value: object) -> None:
|
|
120
|
+
# Registry stores retrieve= as a private attrs field ``_retrieve``.
|
|
121
|
+
registry = Registry(retrieve=_no_remote) # type: ignore[call-arg]
|
|
122
|
+
Draft202012Validator(schema, registry=registry, format_checker=FormatChecker()).validate(value)
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _require_semantic(schema: JSONValue, what: str) -> None:
|
|
126
|
+
root = schema.get("root") if isinstance(schema, dict) else None
|
|
127
|
+
if not isinstance(root, dict) or not isinstance(root.get("kind"), str):
|
|
128
|
+
raise ValueError(f"{what} must be a semantic schema")
|
|
129
|
+
structure = root.get("structure")
|
|
130
|
+
if isinstance(structure, dict):
|
|
131
|
+
Draft202012Validator.check_schema(structure)
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def _validate_semantic(schema: JSONValue, value: object) -> None:
|
|
135
|
+
root = schema.get("root") if isinstance(schema, dict) else None
|
|
136
|
+
if not isinstance(root, dict):
|
|
137
|
+
raise ValueError("input schema must be a semantic schema")
|
|
138
|
+
_check_semantic(root, value)
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def _check_semantic(node: JSONObject, value: object) -> None:
|
|
142
|
+
kind = node.get("kind")
|
|
143
|
+
if kind == "opaque":
|
|
144
|
+
return
|
|
145
|
+
if kind == "scalar":
|
|
146
|
+
_check_scalar(node, value)
|
|
147
|
+
return
|
|
148
|
+
if kind == "record":
|
|
149
|
+
if not isinstance(value, dict):
|
|
150
|
+
raise ValueError("record value must be an object")
|
|
151
|
+
fields = node.get("fields") or []
|
|
152
|
+
if not isinstance(fields, list):
|
|
153
|
+
raise ValueError("record fields must be a list")
|
|
154
|
+
names: set[str] = set()
|
|
155
|
+
for raw in fields:
|
|
156
|
+
if not isinstance(raw, dict):
|
|
157
|
+
raise ValueError("record field is invalid")
|
|
158
|
+
name = raw.get("name")
|
|
159
|
+
if not isinstance(name, str):
|
|
160
|
+
raise ValueError("record field is invalid")
|
|
161
|
+
names.add(name)
|
|
162
|
+
child = raw.get("type")
|
|
163
|
+
if name not in value:
|
|
164
|
+
if raw.get("optional") is True:
|
|
165
|
+
continue
|
|
166
|
+
raise ValueError(f"field {name} is required")
|
|
167
|
+
if not isinstance(child, dict):
|
|
168
|
+
raise ValueError(f"field {name} has no type")
|
|
169
|
+
_check_semantic(child, value[name])
|
|
170
|
+
for key in value:
|
|
171
|
+
if key not in names:
|
|
172
|
+
raise ValueError(f"field {key} is not in the type")
|
|
173
|
+
return
|
|
174
|
+
if kind == "list":
|
|
175
|
+
if not isinstance(value, list):
|
|
176
|
+
raise ValueError("list value must be an array")
|
|
177
|
+
item = node.get("item")
|
|
178
|
+
if not isinstance(item, dict):
|
|
179
|
+
raise ValueError("list requires an item type")
|
|
180
|
+
for entry in value:
|
|
181
|
+
_check_semantic(item, entry)
|
|
182
|
+
return
|
|
183
|
+
if kind == "enum":
|
|
184
|
+
allowed = node.get("values")
|
|
185
|
+
if not isinstance(allowed, list) or any(not isinstance(item, str) for item in allowed):
|
|
186
|
+
raise ValueError("enum type requires string values")
|
|
187
|
+
if not isinstance(value, str) or value not in allowed:
|
|
188
|
+
raise ValueError("value is not in the enum")
|
|
189
|
+
return
|
|
190
|
+
if kind == "schedule":
|
|
191
|
+
points = value.get("points") if isinstance(value, dict) else None
|
|
192
|
+
if not isinstance(points, list):
|
|
193
|
+
raise ValueError("schedule value must contain points")
|
|
194
|
+
for point in points:
|
|
195
|
+
if not isinstance(point, dict):
|
|
196
|
+
raise ValueError("schedule point must be an object")
|
|
197
|
+
at = point.get("at")
|
|
198
|
+
raw = point.get("value")
|
|
199
|
+
if not isinstance(at, str) or not _DECIMAL.fullmatch(at):
|
|
200
|
+
raise ValueError("point axis must be a decimal")
|
|
201
|
+
if not isinstance(raw, str) or not _DECIMAL.fullmatch(raw):
|
|
202
|
+
raise ValueError("point value must be a decimal")
|
|
203
|
+
return
|
|
204
|
+
raise ValueError(f"unsupported semantic kind {kind!r}")
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def _check_scalar(node: JSONObject, value: object) -> None:
|
|
208
|
+
scalar_kind = node.get("scalar")
|
|
209
|
+
if scalar_kind == "number" and isinstance(value, int | float) and not isinstance(value, bool):
|
|
210
|
+
_check_bounds(node, value)
|
|
211
|
+
return
|
|
212
|
+
if scalar_kind == "integer" and isinstance(value, int) and not isinstance(value, bool):
|
|
213
|
+
_check_bounds(node, value)
|
|
214
|
+
return
|
|
215
|
+
if scalar_kind == "string" and isinstance(value, str):
|
|
216
|
+
return
|
|
217
|
+
if scalar_kind == "boolean" and isinstance(value, bool):
|
|
218
|
+
return
|
|
219
|
+
raise ValueError("value does not match the scalar type")
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def _check_bounds(node: JSONObject, value: int | float) -> None:
|
|
223
|
+
bounds = node.get("bounds")
|
|
224
|
+
if not isinstance(bounds, dict):
|
|
225
|
+
return
|
|
226
|
+
if "min" in bounds and float(value) < float(str(bounds["min"])):
|
|
227
|
+
raise ValueError("value is below the minimum")
|
|
228
|
+
if "max" in bounds and float(value) > float(str(bounds["max"])):
|
|
229
|
+
raise ValueError("value is above the maximum")
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
class Runtime:
|
|
233
|
+
"""Cooperative participant runtime.
|
|
234
|
+
|
|
235
|
+
Contract: see the module docstring for ``log`` / ``flush`` / ``close``
|
|
236
|
+
semantics, budgets, and the command journal. Construct with an explicit
|
|
237
|
+
overflow policy and positive bounded queue budgets.
|
|
238
|
+
|
|
239
|
+
Failure: ``ValueError`` if budgets or overflow are invalid, or if the
|
|
240
|
+
participant resource is the wrong kind. Background fatals surface on
|
|
241
|
+
the next ``log`` / ``flush`` / ``safe_point``.
|
|
242
|
+
"""
|
|
243
|
+
|
|
244
|
+
def __init__(
|
|
245
|
+
self,
|
|
246
|
+
client: RuntimeClient,
|
|
247
|
+
participant_id: str,
|
|
248
|
+
*,
|
|
249
|
+
state_dir: str | Path,
|
|
250
|
+
overflow: OverflowPolicy,
|
|
251
|
+
max_spool_bytes: int = 64 << 20,
|
|
252
|
+
media_spool_bytes: int = 256 << 20,
|
|
253
|
+
memory_records: int = 1024,
|
|
254
|
+
max_memory_bytes: int = 8 << 20,
|
|
255
|
+
safe_points: tuple[str, ...] = (),
|
|
256
|
+
attempt_id: str = "",
|
|
257
|
+
serve_commands: bool = True,
|
|
258
|
+
) -> None:
|
|
259
|
+
if (
|
|
260
|
+
overflow not in {"drop_new", "block", "raise"}
|
|
261
|
+
or memory_records < 1
|
|
262
|
+
or max_memory_bytes < MIN_MEMORY_BYTES
|
|
263
|
+
or media_spool_bytes < 1
|
|
264
|
+
):
|
|
265
|
+
raise ValueError("explicit overflow policy and positive bounded queue budgets required")
|
|
266
|
+
self.client = client
|
|
267
|
+
self.last_result: JSONValue | None = None
|
|
268
|
+
self.participant = client.get_document(participant_id)
|
|
269
|
+
if self.participant.kind != "participant":
|
|
270
|
+
raise ValueError("runtime requires a participant")
|
|
271
|
+
self.scope = self.participant.scope
|
|
272
|
+
self.overflow = overflow
|
|
273
|
+
self.store = StateStore(
|
|
274
|
+
state_dir,
|
|
275
|
+
f"{client.base_url}|{client.organization_id}|{participant_id}",
|
|
276
|
+
max_spool_bytes,
|
|
277
|
+
)
|
|
278
|
+
self._queue: queue.Queue[tuple[str, str, str, bytes]] = queue.Queue(memory_records)
|
|
279
|
+
self._commands: queue.Queue[JSONObject] = queue.Queue(1)
|
|
280
|
+
self._handlers: dict[
|
|
281
|
+
str, tuple[Capability, CapabilityHandler, CapabilityValidator | None, str]
|
|
282
|
+
] = {}
|
|
283
|
+
self._stop = threading.Event()
|
|
284
|
+
self._lock = threading.RLock()
|
|
285
|
+
self._space = threading.Condition(self._lock)
|
|
286
|
+
self._media_space = threading.Condition(self._lock)
|
|
287
|
+
self._media_wake = threading.Event()
|
|
288
|
+
self._media_max = media_spool_bytes
|
|
289
|
+
self._memory_used = 0
|
|
290
|
+
self._max_memory_bytes = max_memory_bytes
|
|
291
|
+
self._active: JSONObject | None = None
|
|
292
|
+
self._fatal: BaseException | None = None
|
|
293
|
+
self._accepting = True
|
|
294
|
+
self._closed = False
|
|
295
|
+
self._ended = False
|
|
296
|
+
self._session: JSONObject = {}
|
|
297
|
+
self._instance_id = str(uuid.uuid4())
|
|
298
|
+
self._safe_points: list[str] = []
|
|
299
|
+
self._safe_points_dirty = False
|
|
300
|
+
self.dropped = 0
|
|
301
|
+
self.last_transport_error: str | None = None
|
|
302
|
+
self._envelope_supported = True
|
|
303
|
+
self._serve_commands = serve_commands
|
|
304
|
+
try:
|
|
305
|
+
for name in safe_points:
|
|
306
|
+
self._add_safe_point(name)
|
|
307
|
+
self._session = dict(
|
|
308
|
+
client.connect(
|
|
309
|
+
participant_id,
|
|
310
|
+
self._instance_id,
|
|
311
|
+
safe_points=tuple(self._safe_points),
|
|
312
|
+
attempt_id=attempt_id,
|
|
313
|
+
)
|
|
314
|
+
)
|
|
315
|
+
self._safe_points_dirty = False
|
|
316
|
+
self._recover()
|
|
317
|
+
except BaseException:
|
|
318
|
+
self.store.close()
|
|
319
|
+
raise
|
|
320
|
+
self._threads = [
|
|
321
|
+
threading.Thread(target=target, daemon=True, name=name)
|
|
322
|
+
for target, name in [
|
|
323
|
+
(self._writer, "launchhelm-spool"),
|
|
324
|
+
(self._uploader, "launchhelm-upload"),
|
|
325
|
+
(self._media_worker, "launchhelm-media"),
|
|
326
|
+
(self._control, "launchhelm-control"),
|
|
327
|
+
]
|
|
328
|
+
]
|
|
329
|
+
for thread in self._threads:
|
|
330
|
+
thread.start()
|
|
331
|
+
|
|
332
|
+
@property
|
|
333
|
+
def session(self) -> JSONObject:
|
|
334
|
+
"""Copy of the current execution session (epoch and state revision).
|
|
335
|
+
|
|
336
|
+
Contract: the copy is consistent with the last connect/heartbeat
|
|
337
|
+
installed under the runtime lock.
|
|
338
|
+
|
|
339
|
+
Failure: none.
|
|
340
|
+
"""
|
|
341
|
+
with self._lock:
|
|
342
|
+
return dict(self._session)
|
|
343
|
+
|
|
344
|
+
def __enter__(self) -> Runtime:
|
|
345
|
+
"""Return ``self`` for ``with Runtime(...)`` usage.
|
|
346
|
+
|
|
347
|
+
Contract: ``__exit__`` calls ``close``.
|
|
348
|
+
|
|
349
|
+
Failure: none.
|
|
350
|
+
"""
|
|
351
|
+
return self
|
|
352
|
+
|
|
353
|
+
def __exit__(self, *args: object) -> None:
|
|
354
|
+
"""Close the runtime, ignoring the block exception.
|
|
355
|
+
|
|
356
|
+
Contract: does not suppress exceptions from the ``with`` body.
|
|
357
|
+
|
|
358
|
+
Failure: propagates ``close`` errors.
|
|
359
|
+
"""
|
|
360
|
+
self.close()
|
|
361
|
+
|
|
362
|
+
def register(
|
|
363
|
+
self,
|
|
364
|
+
capability: Capability,
|
|
365
|
+
handler: CapabilityHandler,
|
|
366
|
+
*,
|
|
367
|
+
validate: CapabilityValidator | None = None,
|
|
368
|
+
) -> Document:
|
|
369
|
+
"""Publish ``capability`` and bind ``handler`` to its resource id.
|
|
370
|
+
|
|
371
|
+
Contract: schemas are checked locally before the server create. The
|
|
372
|
+
handler runs only at a matching ``safe_point``, never automatically
|
|
373
|
+
on restart.
|
|
374
|
+
|
|
375
|
+
Failure: ``jsonschema.SchemaError`` for an invalid schema.
|
|
376
|
+
``LaunchHelmError`` from the server.
|
|
377
|
+
"""
|
|
378
|
+
capability = copy.deepcopy(capability)
|
|
379
|
+
_require_semantic(capability.input_schema, "input_schema")
|
|
380
|
+
_require_semantic(capability.output_schema, "output_schema")
|
|
381
|
+
_require_semantic(capability.presentation, "presentation")
|
|
382
|
+
document = self.client.create_document(
|
|
383
|
+
"capability", self.scope, capability.wire(self.participant.id)
|
|
384
|
+
)
|
|
385
|
+
added = False
|
|
386
|
+
with self._lock:
|
|
387
|
+
self._serve_commands = True
|
|
388
|
+
self._handlers[document.id] = (
|
|
389
|
+
capability,
|
|
390
|
+
handler,
|
|
391
|
+
validate,
|
|
392
|
+
document.revision,
|
|
393
|
+
)
|
|
394
|
+
for name in capability.safe_points:
|
|
395
|
+
added = self._add_safe_point(name) or added
|
|
396
|
+
if added:
|
|
397
|
+
self._advertise_safe_points()
|
|
398
|
+
return document
|
|
399
|
+
|
|
400
|
+
def stream(
|
|
401
|
+
self,
|
|
402
|
+
name: str,
|
|
403
|
+
*,
|
|
404
|
+
value_schema: JSONSchema,
|
|
405
|
+
axes: tuple[str, ...] = ("step",),
|
|
406
|
+
unit: str = "",
|
|
407
|
+
) -> Document:
|
|
408
|
+
"""Create a telemetry stream owned by this participant.
|
|
409
|
+
|
|
410
|
+
Contract: ``value_schema`` is checked locally. Points are admitted
|
|
411
|
+
with ``log``.
|
|
412
|
+
|
|
413
|
+
Failure: ``jsonschema.SchemaError`` for an invalid schema.
|
|
414
|
+
``LaunchHelmError`` from the server.
|
|
415
|
+
"""
|
|
416
|
+
Draft202012Validator.check_schema(value_schema)
|
|
417
|
+
spec: JSONObject = {
|
|
418
|
+
"name": name,
|
|
419
|
+
"participant_id": self.participant.id,
|
|
420
|
+
"value_schema": value_schema,
|
|
421
|
+
"axes": list(axes),
|
|
422
|
+
"labels": {},
|
|
423
|
+
}
|
|
424
|
+
if unit:
|
|
425
|
+
spec["unit"] = unit
|
|
426
|
+
return self.client.create_document("stream", self.scope, spec)
|
|
427
|
+
|
|
428
|
+
def log(
|
|
429
|
+
self,
|
|
430
|
+
stream: Document,
|
|
431
|
+
value: JSONValue,
|
|
432
|
+
*,
|
|
433
|
+
coordinates: list[dict[str, str]],
|
|
434
|
+
observed_at: str | None = None,
|
|
435
|
+
timeout: float | None = None,
|
|
436
|
+
) -> bool:
|
|
437
|
+
"""Admit one telemetry point into the volatile memory queue.
|
|
438
|
+
|
|
439
|
+
Contract: success means the point is in process memory, not durable
|
|
440
|
+
and not query-visible. ``True`` means accepted; ``False`` means
|
|
441
|
+
dropped under ``overflow="drop_new"``. The session epoch is
|
|
442
|
+
snapshotted under the runtime lock at enqueue so a fence cannot
|
|
443
|
+
attach a stale epoch. The point is canonicalized exactly once here
|
|
444
|
+
(``encode``); later stages store and concatenate those bytes.
|
|
445
|
+
|
|
446
|
+
Failure:
|
|
447
|
+
ValueError: stream identity/scope mismatch, or the encoded point
|
|
448
|
+
is too large.
|
|
449
|
+
RuntimeError: the runtime is closed.
|
|
450
|
+
BufferFull: memory budget exhausted with overflow ``raise``, or
|
|
451
|
+
``block`` timed out.
|
|
452
|
+
RuntimeFenced / LaunchHelmError / other fatal: a background
|
|
453
|
+
thread has failed closed.
|
|
454
|
+
"""
|
|
455
|
+
if (
|
|
456
|
+
stream.kind != "stream"
|
|
457
|
+
or stream.spec.get("participant_id") != self.participant.id
|
|
458
|
+
or stream.scope != self.scope
|
|
459
|
+
):
|
|
460
|
+
raise ValueError("stream belongs to another participant or scope")
|
|
461
|
+
coords: list[JSONValue] = [string_object(item) for item in coordinates]
|
|
462
|
+
payload: JSONObject = {
|
|
463
|
+
"observed_at": observed_at or timestamp(),
|
|
464
|
+
"coordinates": coords,
|
|
465
|
+
"value": value,
|
|
466
|
+
}
|
|
467
|
+
raw = encode(payload)
|
|
468
|
+
if len(raw) > MAX_VOLATILE_POINT_BYTES:
|
|
469
|
+
raise ValueError("point is too large; use an artifact")
|
|
470
|
+
deadline = time.monotonic() + timeout if timeout is not None else None
|
|
471
|
+
with self._space:
|
|
472
|
+
while True:
|
|
473
|
+
if not self._accepting:
|
|
474
|
+
raise RuntimeError("runtime is closed")
|
|
475
|
+
if self._fatal is not None:
|
|
476
|
+
raise self._fatal
|
|
477
|
+
epoch = str(self._session["epoch"])
|
|
478
|
+
if (
|
|
479
|
+
not self._queue.full()
|
|
480
|
+
and self._memory_used + len(raw) <= self._max_memory_bytes
|
|
481
|
+
):
|
|
482
|
+
self._queue.put_nowait((stream.id, stream.revision, epoch, raw))
|
|
483
|
+
self._memory_used += len(raw)
|
|
484
|
+
return True
|
|
485
|
+
if self.overflow == "drop_new":
|
|
486
|
+
self._dropped()
|
|
487
|
+
return False
|
|
488
|
+
if self.overflow == "raise":
|
|
489
|
+
raise BufferFull()
|
|
490
|
+
remaining = None if deadline is None else deadline - time.monotonic()
|
|
491
|
+
if remaining is not None and remaining <= 0:
|
|
492
|
+
raise BufferFull()
|
|
493
|
+
self._space.wait(remaining)
|
|
494
|
+
|
|
495
|
+
def log_media(
|
|
496
|
+
self,
|
|
497
|
+
stream: Document,
|
|
498
|
+
value: JSONValue,
|
|
499
|
+
*,
|
|
500
|
+
coordinates: list[dict[str, str]],
|
|
501
|
+
blobs: Sequence[PendingBlob],
|
|
502
|
+
timeout: float | None = None,
|
|
503
|
+
) -> bool:
|
|
504
|
+
"""Admit a point whose media items are still local blobs.
|
|
505
|
+
|
|
506
|
+
Contract: each ``PendingBlob`` in ``blobs`` is written
|
|
507
|
+
content-addressed into the state directory and the encoded point
|
|
508
|
+
is recorded in the media spool — durable locally before this
|
|
509
|
+
returns, so ``flush(delivered=False)`` has nothing further to do.
|
|
510
|
+
The background media worker uploads each blob as a ``media``
|
|
511
|
+
artifact (idempotent on run and content hash) and admits the
|
|
512
|
+
point only after every reference resolves. Pending media bytes
|
|
513
|
+
are bounded by ``media_spool_bytes`` under the runtime overflow
|
|
514
|
+
policy: ``drop_new`` returns ``False``, ``raise`` raises
|
|
515
|
+
``BufferFull``, ``block`` waits. ``True`` means accepted.
|
|
516
|
+
|
|
517
|
+
Failure: same as ``log`` plus ``BufferFull`` for the media budget
|
|
518
|
+
under ``raise``, or ``block`` timing out.
|
|
519
|
+
"""
|
|
520
|
+
if (
|
|
521
|
+
stream.kind != "stream"
|
|
522
|
+
or stream.spec.get("participant_id") != self.participant.id
|
|
523
|
+
or stream.scope != self.scope
|
|
524
|
+
):
|
|
525
|
+
raise ValueError("stream belongs to another participant or scope")
|
|
526
|
+
coords: list[JSONValue] = [string_object(item) for item in coordinates]
|
|
527
|
+
payload: JSONObject = {
|
|
528
|
+
"observed_at": timestamp(),
|
|
529
|
+
"coordinates": coords,
|
|
530
|
+
"value": value,
|
|
531
|
+
}
|
|
532
|
+
raw = encode(payload)
|
|
533
|
+
if len(raw) > MAX_VOLATILE_POINT_BYTES:
|
|
534
|
+
raise ValueError("point is too large; use an artifact")
|
|
535
|
+
sized: dict[str, tuple[str, bytes]] = {}
|
|
536
|
+
for blob in blobs:
|
|
537
|
+
hash = str(blob.hash)
|
|
538
|
+
media_type = str(blob.media_type)
|
|
539
|
+
data = bytes(blob.data)
|
|
540
|
+
sized[hash] = (media_type, data)
|
|
541
|
+
deadline = time.monotonic() + timeout if timeout is not None else None
|
|
542
|
+
with self._media_space:
|
|
543
|
+
while True:
|
|
544
|
+
if not self._accepting:
|
|
545
|
+
raise RuntimeError("runtime is closed")
|
|
546
|
+
if self._fatal is not None:
|
|
547
|
+
raise self._fatal
|
|
548
|
+
epoch = str(self._session["epoch"])
|
|
549
|
+
added = self.store.media_new_bytes(
|
|
550
|
+
[(hash, len(data)) for hash, (_mt, data) in sized.items()]
|
|
551
|
+
)
|
|
552
|
+
if self.store.media_used() + added + len(raw) <= self._media_max:
|
|
553
|
+
break
|
|
554
|
+
if self.overflow == "drop_new":
|
|
555
|
+
self._dropped()
|
|
556
|
+
return False
|
|
557
|
+
if self.overflow == "raise":
|
|
558
|
+
raise BufferFull()
|
|
559
|
+
remaining = None if deadline is None else deadline - time.monotonic()
|
|
560
|
+
if remaining is not None and remaining <= 0:
|
|
561
|
+
raise BufferFull()
|
|
562
|
+
self._media_space.wait(remaining)
|
|
563
|
+
self.store.enqueue_media(
|
|
564
|
+
stream.id,
|
|
565
|
+
stream.revision,
|
|
566
|
+
epoch,
|
|
567
|
+
raw,
|
|
568
|
+
[(hash, media_type, data) for hash, (media_type, data) in sized.items()],
|
|
569
|
+
)
|
|
570
|
+
self._media_wake.set()
|
|
571
|
+
return True
|
|
572
|
+
|
|
573
|
+
def _dropped(self) -> None:
|
|
574
|
+
with self._lock:
|
|
575
|
+
self.dropped += 1
|
|
576
|
+
count = self.dropped
|
|
577
|
+
if count == 1 or count % 100 == 0:
|
|
578
|
+
warnings.warn(
|
|
579
|
+
f"LaunchHelm telemetry overflow: {count} volatile records dropped",
|
|
580
|
+
RuntimeWarning,
|
|
581
|
+
stacklevel=2,
|
|
582
|
+
)
|
|
583
|
+
|
|
584
|
+
def _set_fatal(self, error: BaseException) -> None:
|
|
585
|
+
with self._space:
|
|
586
|
+
if isinstance(self._fatal, RuntimeFenced):
|
|
587
|
+
self._space.notify_all()
|
|
588
|
+
return
|
|
589
|
+
if self._fatal is None or isinstance(error, RuntimeFenced):
|
|
590
|
+
self._fatal = error
|
|
591
|
+
self._space.notify_all()
|
|
592
|
+
|
|
593
|
+
def _set_transport_error(self, code: str | None) -> None:
|
|
594
|
+
with self._lock:
|
|
595
|
+
self.last_transport_error = code
|
|
596
|
+
|
|
597
|
+
def _raise_if_fatal(self) -> None:
|
|
598
|
+
with self._lock:
|
|
599
|
+
if self._fatal is not None:
|
|
600
|
+
raise self._fatal
|
|
601
|
+
|
|
602
|
+
def _add_safe_point(self, name: str) -> bool:
|
|
603
|
+
if not _SAFE_POINT_NAME.fullmatch(name):
|
|
604
|
+
raise ValueError("invalid safe point name")
|
|
605
|
+
with self._lock:
|
|
606
|
+
if name in self._safe_points:
|
|
607
|
+
return False
|
|
608
|
+
if len(self._safe_points) >= MAX_SAFE_POINTS:
|
|
609
|
+
raise ValueError("at most 64 safe points may be declared")
|
|
610
|
+
self._safe_points.append(name)
|
|
611
|
+
self._safe_points_dirty = True
|
|
612
|
+
return True
|
|
613
|
+
|
|
614
|
+
def _advertise_safe_points(self) -> None:
|
|
615
|
+
# The control loop cannot heartbeat while its claim is parked, so a
|
|
616
|
+
# newly declared point would stay invisible to the server until the
|
|
617
|
+
# wait elapsed. Advertise it now; a claim already parked re-reads the
|
|
618
|
+
# session each poll slice and claims the command within that slice.
|
|
619
|
+
with self._lock:
|
|
620
|
+
if self._ended or isinstance(self._fatal, RuntimeFenced):
|
|
621
|
+
return
|
|
622
|
+
points = tuple(self._safe_points)
|
|
623
|
+
epoch = str(self._session["epoch"])
|
|
624
|
+
try:
|
|
625
|
+
session = self.client.heartbeat(self.participant.id, epoch, safe_points=points)
|
|
626
|
+
except LaunchHelmError as error:
|
|
627
|
+
self._set_transport_error(error.code)
|
|
628
|
+
self._heartbeat_fenced(error)
|
|
629
|
+
return
|
|
630
|
+
with self._lock:
|
|
631
|
+
if not isinstance(self._fatal, RuntimeFenced):
|
|
632
|
+
self._session = dict(session)
|
|
633
|
+
self._safe_points_dirty = False
|
|
634
|
+
|
|
635
|
+
def _writer(self) -> None:
|
|
636
|
+
while not self._stop.is_set() or not self._queue.empty():
|
|
637
|
+
try:
|
|
638
|
+
first = self._queue.get(timeout=WRITER_POLL_SECONDS)
|
|
639
|
+
except queue.Empty:
|
|
640
|
+
continue
|
|
641
|
+
batch = [first]
|
|
642
|
+
size = len(first[3])
|
|
643
|
+
while len(batch) < WRITER_BATCH_MAX and size < WRITER_BATCH_BYTES:
|
|
644
|
+
try:
|
|
645
|
+
item = self._queue.get_nowait()
|
|
646
|
+
except queue.Empty:
|
|
647
|
+
break
|
|
648
|
+
batch.append(item)
|
|
649
|
+
size += len(item[3])
|
|
650
|
+
self._persist(batch)
|
|
651
|
+
|
|
652
|
+
def _persist(self, batch: list[tuple[str, str, str, bytes]]) -> None:
|
|
653
|
+
"""Persist a drained batch, applying the per-point overflow contract.
|
|
654
|
+
|
|
655
|
+
``enqueue_many`` admits a prefix inside one transaction. The point
|
|
656
|
+
after it is handled exactly as the serial writer handled a
|
|
657
|
+
``BufferFull`` for that point: ``drop_new`` drops it and moves on;
|
|
658
|
+
``raise`` records a fatal and keeps retrying; ``block`` retries
|
|
659
|
+
until the spool has room; on stop the point is dropped after a
|
|
660
|
+
fatal is set. A point that fails the per-point size cap records a
|
|
661
|
+
fatal and is dropped; the rest of the batch still persists.
|
|
662
|
+
"""
|
|
663
|
+
pending = batch
|
|
664
|
+
while pending:
|
|
665
|
+
try:
|
|
666
|
+
admitted = self.store.enqueue_many(pending)
|
|
667
|
+
except OversizePoint as error:
|
|
668
|
+
self._release(pending[: error.index])
|
|
669
|
+
self._set_fatal(error)
|
|
670
|
+
self._dropped()
|
|
671
|
+
self._release(pending[error.index : error.index + 1])
|
|
672
|
+
pending = pending[error.index + 1 :]
|
|
673
|
+
continue
|
|
674
|
+
except Exception as error:
|
|
675
|
+
self._set_fatal(error)
|
|
676
|
+
self._dropped()
|
|
677
|
+
self._release(pending[:1])
|
|
678
|
+
pending = pending[1:]
|
|
679
|
+
continue
|
|
680
|
+
self._release(pending[:admitted])
|
|
681
|
+
pending = pending[admitted:]
|
|
682
|
+
if not pending:
|
|
683
|
+
return
|
|
684
|
+
if self.overflow == "drop_new":
|
|
685
|
+
self._dropped()
|
|
686
|
+
self._release(pending[:1])
|
|
687
|
+
pending = pending[1:]
|
|
688
|
+
continue
|
|
689
|
+
if self.overflow == "raise":
|
|
690
|
+
self._set_fatal(BufferFull())
|
|
691
|
+
if self._stop.is_set():
|
|
692
|
+
self._set_fatal(BufferFull())
|
|
693
|
+
self._dropped()
|
|
694
|
+
self._release(pending[:1])
|
|
695
|
+
pending = pending[1:]
|
|
696
|
+
continue
|
|
697
|
+
self._stop.wait(WRITER_POLL_SECONDS)
|
|
698
|
+
|
|
699
|
+
def _release(self, items: Sequence[tuple[str, str, str, bytes]]) -> None:
|
|
700
|
+
"""Account for dequeued points: ``task_done`` and memory release.
|
|
701
|
+
|
|
702
|
+
Contract: every item the writer dequeued is released exactly once,
|
|
703
|
+
after it is persisted or dropped, so ``flush(delivered=False)``
|
|
704
|
+
keeps its local-durability contract.
|
|
705
|
+
"""
|
|
706
|
+
freed = 0
|
|
707
|
+
for _stream, _revision, _epoch, raw in items:
|
|
708
|
+
self._queue.task_done()
|
|
709
|
+
freed += len(raw)
|
|
710
|
+
with self._space:
|
|
711
|
+
self._memory_used -= freed
|
|
712
|
+
self._space.notify_all()
|
|
713
|
+
|
|
714
|
+
def _uploader(self) -> None:
|
|
715
|
+
delay = UPLOAD_IDLE_SECONDS
|
|
716
|
+
while not self._stop.is_set():
|
|
717
|
+
try:
|
|
718
|
+
if self._upload_grouped():
|
|
719
|
+
delay = UPLOAD_IDLE_SECONDS
|
|
720
|
+
continue
|
|
721
|
+
batch = self.store.batch(self.participant.id)
|
|
722
|
+
if batch is None:
|
|
723
|
+
self._stop.wait(UPLOAD_IDLE_SECONDS)
|
|
724
|
+
continue
|
|
725
|
+
receipt = self.client.ingest(batch)
|
|
726
|
+
if (
|
|
727
|
+
receipt.get("batch_id") != batch.batch_id
|
|
728
|
+
or receipt.get("durability") != "query_visible"
|
|
729
|
+
):
|
|
730
|
+
raise LaunchHelmError(
|
|
731
|
+
"invalid_receipt", "Server did not confirm batch durability"
|
|
732
|
+
)
|
|
733
|
+
self.store.acknowledge(batch.batch_id)
|
|
734
|
+
self._set_transport_error(None)
|
|
735
|
+
delay = UPLOAD_IDLE_SECONDS
|
|
736
|
+
except LaunchHelmError as error:
|
|
737
|
+
self._set_transport_error(error.code)
|
|
738
|
+
if not error.retryable:
|
|
739
|
+
self._set_fatal(error)
|
|
740
|
+
self._stop.wait(delay)
|
|
741
|
+
delay = min(delay * 2, UPLOAD_MAX_BACKOFF_SECONDS)
|
|
742
|
+
except Exception as error:
|
|
743
|
+
self._set_fatal(error)
|
|
744
|
+
self._stop.wait(1)
|
|
745
|
+
|
|
746
|
+
def _media_worker(self) -> None:
|
|
747
|
+
"""Upload pending media blobs and release their points.
|
|
748
|
+
|
|
749
|
+
Each loop claims one un-uploaded blob; uploads are idempotent on
|
|
750
|
+
``media:<run>:<hash>`` so a crash mid-upload resumes to the same
|
|
751
|
+
artifact, and ``resolve_media`` admits every point whose last
|
|
752
|
+
reference resolved. Resolved-but-budget-blocked points are
|
|
753
|
+
retried through ``flush_ready_media`` each pass.
|
|
754
|
+
"""
|
|
755
|
+
delay = UPLOAD_IDLE_SECONDS
|
|
756
|
+
while not self._stop.is_set():
|
|
757
|
+
try:
|
|
758
|
+
if self._media_once():
|
|
759
|
+
delay = UPLOAD_IDLE_SECONDS
|
|
760
|
+
continue
|
|
761
|
+
self._media_wake.wait(UPLOAD_IDLE_SECONDS)
|
|
762
|
+
self._media_wake.clear()
|
|
763
|
+
except LaunchHelmError as error:
|
|
764
|
+
self._set_transport_error(error.code)
|
|
765
|
+
if not error.retryable:
|
|
766
|
+
self._set_fatal(error)
|
|
767
|
+
self._stop.wait(delay)
|
|
768
|
+
delay = min(delay * 2, UPLOAD_MAX_BACKOFF_SECONDS)
|
|
769
|
+
except Exception as error:
|
|
770
|
+
self._set_fatal(error)
|
|
771
|
+
self._stop.wait(1)
|
|
772
|
+
|
|
773
|
+
def _media_once(self) -> bool:
|
|
774
|
+
"""Make one unit of media progress; True when any work happened."""
|
|
775
|
+
progressed = False
|
|
776
|
+
admitted, freed = self.store.flush_ready_media()
|
|
777
|
+
if admitted > 0:
|
|
778
|
+
progressed = True
|
|
779
|
+
for hash in freed:
|
|
780
|
+
self.store.release_media_blob_file(hash)
|
|
781
|
+
pending = self.store.next_pending_media()
|
|
782
|
+
if pending is None:
|
|
783
|
+
return progressed
|
|
784
|
+
hash, media_type = pending
|
|
785
|
+
upload = getattr(self.client, "upload_file", None)
|
|
786
|
+
if not callable(upload):
|
|
787
|
+
raise LaunchHelmError("protocol", "client cannot upload media")
|
|
788
|
+
document = upload(
|
|
789
|
+
self.store.media_blob_path(hash),
|
|
790
|
+
self.scope,
|
|
791
|
+
idempotency_key=f"media:{self.scope.run_id}:{hash}",
|
|
792
|
+
media_type=media_type,
|
|
793
|
+
purpose="media",
|
|
794
|
+
)
|
|
795
|
+
if not isinstance(document, Document):
|
|
796
|
+
raise LaunchHelmError("protocol", "media upload did not return an artifact document")
|
|
797
|
+
for blob_hash in self.store.resolve_media(hash, document.id):
|
|
798
|
+
self.store.release_media_blob_file(blob_hash)
|
|
799
|
+
with self._media_space:
|
|
800
|
+
self._media_space.notify_all()
|
|
801
|
+
return True
|
|
802
|
+
|
|
803
|
+
def _upload_grouped(self) -> bool:
|
|
804
|
+
"""Upload pending batches through the grouped endpoint.
|
|
805
|
+
|
|
806
|
+
Returns True when the envelope path handled this tick (including a
|
|
807
|
+
discovered fallback to single-batch ingest). Returns False when the
|
|
808
|
+
caller should use the single-batch endpoint: the client lacks
|
|
809
|
+
``ingest_many`` or an earlier probe found the endpoint missing.
|
|
810
|
+
"""
|
|
811
|
+
if not self._envelope_supported:
|
|
812
|
+
return False
|
|
813
|
+
ingest_many = getattr(self.client, "ingest_many", None)
|
|
814
|
+
if not callable(ingest_many):
|
|
815
|
+
self._envelope_supported = False
|
|
816
|
+
return False
|
|
817
|
+
batches = self.store.batches(self.participant.id)
|
|
818
|
+
if not batches:
|
|
819
|
+
self._stop.wait(UPLOAD_IDLE_SECONDS)
|
|
820
|
+
return True
|
|
821
|
+
try:
|
|
822
|
+
response = ingest_many(batches)
|
|
823
|
+
except LaunchHelmError as error:
|
|
824
|
+
if error.code != "not_found" or error.status != 404:
|
|
825
|
+
raise
|
|
826
|
+
# The endpoint may not exist on this server. Distinguish a
|
|
827
|
+
# missing route from a missing organization by sending the first
|
|
828
|
+
# batch through the original endpoint: if it also misses, the
|
|
829
|
+
# envelope failure was not about routing.
|
|
830
|
+
single = self.client.ingest(batches[0])
|
|
831
|
+
if single.get("batch_id") != batches[0].batch_id:
|
|
832
|
+
raise error from None
|
|
833
|
+
if single.get("durability") != "query_visible":
|
|
834
|
+
raise LaunchHelmError(
|
|
835
|
+
"invalid_receipt", "Server did not confirm batch durability"
|
|
836
|
+
) from None
|
|
837
|
+
self.store.acknowledge(batches[0].batch_id)
|
|
838
|
+
self._envelope_supported = False
|
|
839
|
+
return True
|
|
840
|
+
self._reconcile_receipts(batches, response)
|
|
841
|
+
self._set_transport_error(None)
|
|
842
|
+
return True
|
|
843
|
+
|
|
844
|
+
def _reconcile_receipts(self, batches: list[TelemetryBatch], response: JSONObject) -> None:
|
|
845
|
+
"""Apply per-batch outcomes from a grouped ingest response.
|
|
846
|
+
|
|
847
|
+
Contract: every receipt must name a submitted ``batch_id`` and every
|
|
848
|
+
submitted batch must be acknowledged, reported duplicate, or
|
|
849
|
+
rejected. Violations are protocol errors. Accepted and duplicate
|
|
850
|
+
batches leave the spool; rejected batches stay spooled so a fatal
|
|
851
|
+
stop preserves them for inspection, and a retryable rejection is
|
|
852
|
+
re-sent with the next envelope.
|
|
853
|
+
"""
|
|
854
|
+
receipts = response.get("receipts")
|
|
855
|
+
if not isinstance(receipts, list) or len(receipts) != len(batches):
|
|
856
|
+
raise LaunchHelmError("invalid_receipt", "Server did not return one receipt per batch")
|
|
857
|
+
pending = {batch.batch_id: batch for batch in batches}
|
|
858
|
+
first_rejected: LaunchHelmError | None = None
|
|
859
|
+
for entry in receipts:
|
|
860
|
+
receipt = json_object(entry, what="ingestion receipt")
|
|
861
|
+
batch_id = receipt.get("batch_id")
|
|
862
|
+
batch = pending.pop(batch_id, None) if isinstance(batch_id, str) else None
|
|
863
|
+
if batch is None:
|
|
864
|
+
raise LaunchHelmError(
|
|
865
|
+
"invalid_receipt", "Server returned a receipt for an unknown batch"
|
|
866
|
+
)
|
|
867
|
+
status = receipt.get("status")
|
|
868
|
+
if status in ("accepted", "duplicate"):
|
|
869
|
+
if receipt.get("durability") != "query_visible":
|
|
870
|
+
raise LaunchHelmError(
|
|
871
|
+
"invalid_receipt", "Server did not confirm batch durability"
|
|
872
|
+
)
|
|
873
|
+
self.store.acknowledge(batch.batch_id)
|
|
874
|
+
elif status == "rejected":
|
|
875
|
+
if first_rejected is None:
|
|
876
|
+
code = receipt.get("code")
|
|
877
|
+
message = receipt.get("message")
|
|
878
|
+
first_rejected = LaunchHelmError(
|
|
879
|
+
code if isinstance(code, str) else "rejected",
|
|
880
|
+
message if isinstance(message, str) else "Server rejected the batch",
|
|
881
|
+
retryable=bool(receipt.get("retryable")),
|
|
882
|
+
)
|
|
883
|
+
else:
|
|
884
|
+
raise LaunchHelmError(
|
|
885
|
+
"invalid_receipt", "Server returned an unknown receipt status"
|
|
886
|
+
)
|
|
887
|
+
if pending:
|
|
888
|
+
raise LaunchHelmError(
|
|
889
|
+
"invalid_receipt", "Server omitted receipts for submitted batches"
|
|
890
|
+
)
|
|
891
|
+
if first_rejected is not None:
|
|
892
|
+
raise first_rejected
|
|
893
|
+
|
|
894
|
+
def _result(
|
|
895
|
+
self,
|
|
896
|
+
command: JSONObject,
|
|
897
|
+
outcome: str,
|
|
898
|
+
result: JSONValue = None,
|
|
899
|
+
coordinates: list[dict[str, str]] | None = None,
|
|
900
|
+
failure: str = "",
|
|
901
|
+
) -> JSONObject:
|
|
902
|
+
applied: list[JSONValue] = []
|
|
903
|
+
for item in coordinates or []:
|
|
904
|
+
applied.append(string_object(item))
|
|
905
|
+
request = json_object(command.get("request") or {}, what="command request")
|
|
906
|
+
return {
|
|
907
|
+
"lease_id": command["lease_id"],
|
|
908
|
+
"epoch": request["epoch"],
|
|
909
|
+
"outcome": outcome,
|
|
910
|
+
"result": result,
|
|
911
|
+
"applied_at": applied,
|
|
912
|
+
"failure_code": failure,
|
|
913
|
+
}
|
|
914
|
+
|
|
915
|
+
def _publish_pending(self) -> None:
|
|
916
|
+
for command, phase, result in self.store.recoverable():
|
|
917
|
+
if phase != "completed" or result is None:
|
|
918
|
+
continue
|
|
919
|
+
try:
|
|
920
|
+
reported = self.client.report(command, result)
|
|
921
|
+
except LaunchHelmError as error:
|
|
922
|
+
if error.code != "conflict" or result["outcome"] != "rejected":
|
|
923
|
+
raise
|
|
924
|
+
reported = self.client.get_command(str(command["id"]))
|
|
925
|
+
if reported["execution"] not in {"rejected", "cancelled"}:
|
|
926
|
+
raise
|
|
927
|
+
self.store.journal_phase(str(command["id"]), "reported")
|
|
928
|
+
revision = reported.get("resulting_state_revision")
|
|
929
|
+
if revision:
|
|
930
|
+
with self._lock:
|
|
931
|
+
session = dict(self._session)
|
|
932
|
+
session["state_revision"] = revision
|
|
933
|
+
self._session = session
|
|
934
|
+
with self._lock:
|
|
935
|
+
active = self._active
|
|
936
|
+
if active and active["id"] == command["id"]:
|
|
937
|
+
self._active = None
|
|
938
|
+
|
|
939
|
+
def _recover(self) -> None:
|
|
940
|
+
for command, phase, result in self.store.recoverable():
|
|
941
|
+
if phase == "received":
|
|
942
|
+
current = self.client.get_command(str(command["id"]))
|
|
943
|
+
if current["execution"] in {
|
|
944
|
+
"rejected",
|
|
945
|
+
"cancelled",
|
|
946
|
+
"applied",
|
|
947
|
+
"failed",
|
|
948
|
+
}:
|
|
949
|
+
self.store.journal_phase(str(command["id"]), "reported")
|
|
950
|
+
continue
|
|
951
|
+
if result is None:
|
|
952
|
+
outcome = "rejected" if phase == "received" else "outcome_unknown"
|
|
953
|
+
self.store.journal_phase(
|
|
954
|
+
str(command["id"]),
|
|
955
|
+
"completed",
|
|
956
|
+
self._result(command, outcome, failure="runtime_restart"),
|
|
957
|
+
)
|
|
958
|
+
try:
|
|
959
|
+
self._publish_pending()
|
|
960
|
+
except LaunchHelmError:
|
|
961
|
+
self._set_transport_error("reconciliation_required")
|
|
962
|
+
|
|
963
|
+
def end_session(self, *, close_run: str = "never") -> JSONObject | None:
|
|
964
|
+
"""End the participant's live session epoch on the server.
|
|
965
|
+
|
|
966
|
+
Contract: heartbeats and claims stop at once; a heartbeat or claim
|
|
967
|
+
already in flight when the end lands is expected to conflict and is
|
|
968
|
+
not reported as fencing. Returns the server response, or ``None``
|
|
969
|
+
when the client does not implement ``end_session`` — the session
|
|
970
|
+
then simply expires. The epoch sent is the last one the server
|
|
971
|
+
confirmed.
|
|
972
|
+
|
|
973
|
+
Failure: ``LaunchHelmError`` from the server, including ``conflict``
|
|
974
|
+
when the recorded epoch is no longer live.
|
|
975
|
+
"""
|
|
976
|
+
with self._lock:
|
|
977
|
+
self._ended = True
|
|
978
|
+
epoch = str(self._session.get("epoch") or "")
|
|
979
|
+
end = getattr(self.client, "end_session", None)
|
|
980
|
+
if not callable(end) or not epoch:
|
|
981
|
+
return None
|
|
982
|
+
return json_object(
|
|
983
|
+
end(self.participant.id, epoch, close_run=close_run),
|
|
984
|
+
what="session end",
|
|
985
|
+
)
|
|
986
|
+
|
|
987
|
+
def _heartbeat_fenced(self, error: LaunchHelmError) -> None:
|
|
988
|
+
if error.code != "conflict":
|
|
989
|
+
return
|
|
990
|
+
with self._lock:
|
|
991
|
+
ended = self._ended
|
|
992
|
+
if not ended:
|
|
993
|
+
self._set_fatal(RuntimeFenced())
|
|
994
|
+
|
|
995
|
+
def _control(self) -> None:
|
|
996
|
+
heartbeat_at = 0.0
|
|
997
|
+
delay = CONTROL_POLL_SECONDS
|
|
998
|
+
while not self._stop.is_set():
|
|
999
|
+
parked = False
|
|
1000
|
+
try:
|
|
1001
|
+
self._publish_pending()
|
|
1002
|
+
with self._lock:
|
|
1003
|
+
if self._ended:
|
|
1004
|
+
return
|
|
1005
|
+
fenced = isinstance(self._fatal, RuntimeFenced)
|
|
1006
|
+
if fenced:
|
|
1007
|
+
self._stop.wait(FENCE_WAIT_SECONDS)
|
|
1008
|
+
continue
|
|
1009
|
+
heartbeated = False
|
|
1010
|
+
with self._lock:
|
|
1011
|
+
dirty = self._safe_points_dirty
|
|
1012
|
+
points = tuple(self._safe_points)
|
|
1013
|
+
epoch = str(self._session["epoch"])
|
|
1014
|
+
if dirty:
|
|
1015
|
+
try:
|
|
1016
|
+
session = self.client.heartbeat(
|
|
1017
|
+
self.participant.id, epoch, safe_points=points
|
|
1018
|
+
)
|
|
1019
|
+
except LaunchHelmError as error:
|
|
1020
|
+
self._heartbeat_fenced(error)
|
|
1021
|
+
raise
|
|
1022
|
+
with self._lock:
|
|
1023
|
+
if not isinstance(self._fatal, RuntimeFenced):
|
|
1024
|
+
self._session = dict(session)
|
|
1025
|
+
self._safe_points_dirty = False
|
|
1026
|
+
heartbeat_at = time.monotonic() + HEARTBEAT_INTERVAL_SECONDS
|
|
1027
|
+
heartbeated = True
|
|
1028
|
+
elif time.monotonic() >= heartbeat_at:
|
|
1029
|
+
try:
|
|
1030
|
+
session = self.client.heartbeat(self.participant.id, epoch)
|
|
1031
|
+
except LaunchHelmError as error:
|
|
1032
|
+
self._heartbeat_fenced(error)
|
|
1033
|
+
raise
|
|
1034
|
+
with self._lock:
|
|
1035
|
+
if not isinstance(self._fatal, RuntimeFenced):
|
|
1036
|
+
self._session = dict(session)
|
|
1037
|
+
heartbeat_at = time.monotonic() + HEARTBEAT_INTERVAL_SECONDS
|
|
1038
|
+
heartbeated = True
|
|
1039
|
+
if heartbeated:
|
|
1040
|
+
with self._lock:
|
|
1041
|
+
active = self._active
|
|
1042
|
+
if active:
|
|
1043
|
+
try:
|
|
1044
|
+
self.client.renew(active)
|
|
1045
|
+
except LaunchHelmError as error:
|
|
1046
|
+
if error.code != "conflict":
|
|
1047
|
+
raise
|
|
1048
|
+
self._set_transport_error("lease_reconciliation")
|
|
1049
|
+
with self._lock:
|
|
1050
|
+
available = (
|
|
1051
|
+
self._active is None and self._fatal is None and self._serve_commands
|
|
1052
|
+
)
|
|
1053
|
+
epoch = str(self._session["epoch"])
|
|
1054
|
+
if available and self._commands.empty():
|
|
1055
|
+
command = self.client.claim(self.participant.id, epoch, wait_ms=CLAIM_WAIT_MS)
|
|
1056
|
+
parked = True
|
|
1057
|
+
if command:
|
|
1058
|
+
phase = self.store.remember(command)
|
|
1059
|
+
if phase != "received":
|
|
1060
|
+
self._set_transport_error("reconciliation_required")
|
|
1061
|
+
else:
|
|
1062
|
+
with self._lock:
|
|
1063
|
+
self._active = command
|
|
1064
|
+
self._commands.put_nowait(command)
|
|
1065
|
+
delay = 0 if parked else CONTROL_POLL_SECONDS
|
|
1066
|
+
except LaunchHelmError as error:
|
|
1067
|
+
with self._lock:
|
|
1068
|
+
ended = self._ended
|
|
1069
|
+
if self._stop.is_set() or ended:
|
|
1070
|
+
return
|
|
1071
|
+
self._set_transport_error(error.code)
|
|
1072
|
+
if error.code in {"unauthorized", "forbidden"}:
|
|
1073
|
+
self._set_fatal(error)
|
|
1074
|
+
delay = min(max(1, delay * 2), CONTROL_MAX_BACKOFF_SECONDS)
|
|
1075
|
+
except Exception as error:
|
|
1076
|
+
if self._stop.is_set():
|
|
1077
|
+
return
|
|
1078
|
+
self._set_fatal(error)
|
|
1079
|
+
delay = 1
|
|
1080
|
+
if delay:
|
|
1081
|
+
self._stop.wait(delay)
|
|
1082
|
+
|
|
1083
|
+
def package_capture(
|
|
1084
|
+
self,
|
|
1085
|
+
path: str | Path,
|
|
1086
|
+
scope: Scope,
|
|
1087
|
+
*,
|
|
1088
|
+
idempotency_key: str,
|
|
1089
|
+
completeness: str = "partial",
|
|
1090
|
+
provenance: JSONObject | None = None,
|
|
1091
|
+
) -> JSONObject:
|
|
1092
|
+
"""Upload one capture artifact and return the command result body.
|
|
1093
|
+
|
|
1094
|
+
Contract: called from a safe-point handler. The returned object is the
|
|
1095
|
+
capture command result: artifact reference, completeness, and the
|
|
1096
|
+
digest the server must match before commit. ``completeness`` is
|
|
1097
|
+
``partial`` or ``complete``.
|
|
1098
|
+
|
|
1099
|
+
Failure: ``ValueError`` for an unknown completeness. ``LaunchHelmError``
|
|
1100
|
+
when the client cannot upload or the artifact has no sha256.
|
|
1101
|
+
"""
|
|
1102
|
+
if completeness not in {"partial", "complete"}:
|
|
1103
|
+
raise ValueError("completeness must be partial or complete")
|
|
1104
|
+
upload = getattr(self.client, "upload_file", None)
|
|
1105
|
+
if not callable(upload):
|
|
1106
|
+
raise LaunchHelmError("protocol", "runtime client cannot upload artifacts")
|
|
1107
|
+
artifact = upload(path, scope, idempotency_key=idempotency_key, provenance=provenance)
|
|
1108
|
+
if not isinstance(artifact, Document):
|
|
1109
|
+
raise LaunchHelmError("protocol", "upload did not return an artifact document")
|
|
1110
|
+
digest = str(artifact.spec.get("sha256") or "")
|
|
1111
|
+
if len(digest) != 64:
|
|
1112
|
+
raise LaunchHelmError("protocol", "artifact digest missing")
|
|
1113
|
+
return {
|
|
1114
|
+
"artifact": {
|
|
1115
|
+
"id": artifact.id,
|
|
1116
|
+
"kind": "artifact",
|
|
1117
|
+
"revision": artifact.revision,
|
|
1118
|
+
},
|
|
1119
|
+
"completeness": completeness,
|
|
1120
|
+
"sha256": digest,
|
|
1121
|
+
}
|
|
1122
|
+
|
|
1123
|
+
def pending(self, name: str) -> bool:
|
|
1124
|
+
"""Return ``True`` when a claimed command waits locally for ``name``.
|
|
1125
|
+
|
|
1126
|
+
Contract: reads the local claimed-command queue only; no network, no
|
|
1127
|
+
state change. A ``True`` result means the next ``safe_point(name)``
|
|
1128
|
+
call has work. ``False`` means nothing claimed for ``name`` has
|
|
1129
|
+
arrived locally — the control thread may still be claiming.
|
|
1130
|
+
|
|
1131
|
+
Failure: none.
|
|
1132
|
+
"""
|
|
1133
|
+
with self._commands.mutex:
|
|
1134
|
+
for command in self._commands.queue:
|
|
1135
|
+
request = command.get("request")
|
|
1136
|
+
if isinstance(request, dict) and request.get("safe_point") == name:
|
|
1137
|
+
return True
|
|
1138
|
+
return False
|
|
1139
|
+
|
|
1140
|
+
def safe_point(self, name: str, *, coordinates: list[dict[str, str]]) -> int:
|
|
1141
|
+
"""Run at most one claimed command whose safe-point name is ``name``.
|
|
1142
|
+
|
|
1143
|
+
Contract: returns ``1`` if a handler ran, ``0`` if no matching
|
|
1144
|
+
command was waiting or the command was rejected locally. The first
|
|
1145
|
+
call for ``name`` advertises it on a heartbeat immediately (Connect
|
|
1146
|
+
still declares the constructor set) so a parked Claim can deliver
|
|
1147
|
+
commands for that point without waiting out its park. A claimed
|
|
1148
|
+
command whose
|
|
1149
|
+
``safe_point`` does not match ``name`` is a protocol violation: the
|
|
1150
|
+
command is journaled as rejected and the runtime fails closed. A
|
|
1151
|
+
handler exception is recorded as ``outcome_unknown`` and is never
|
|
1152
|
+
retried automatically. Begin failures that are not definitive also
|
|
1153
|
+
become ``outcome_unknown``.
|
|
1154
|
+
|
|
1155
|
+
Failure: ``ValueError`` for invalid or duplicate coordinates, an
|
|
1156
|
+
invalid safe-point name, or a fingerprint mismatch. ``LaunchHelmError``
|
|
1157
|
+
with code ``protocol`` when the claimed command does not match
|
|
1158
|
+
``name``. ``LaunchHelmError`` from a definitive ``begin`` rejection
|
|
1159
|
+
is raised after journaling. Background fatals are raised before
|
|
1160
|
+
touching the command queue.
|
|
1161
|
+
"""
|
|
1162
|
+
_validate(_COORDINATES, coordinates)
|
|
1163
|
+
if len({item["axis"] for item in coordinates}) != len(coordinates):
|
|
1164
|
+
raise ValueError("duplicate coordinate axis")
|
|
1165
|
+
with self._lock:
|
|
1166
|
+
self._serve_commands = True
|
|
1167
|
+
if self._add_safe_point(name):
|
|
1168
|
+
self._advertise_safe_points()
|
|
1169
|
+
self._raise_if_fatal()
|
|
1170
|
+
try:
|
|
1171
|
+
command = self._commands.get_nowait()
|
|
1172
|
+
except queue.Empty:
|
|
1173
|
+
return 0
|
|
1174
|
+
request = json_object(command.get("request") or {}, what="command request")
|
|
1175
|
+
if request.get("safe_point") != name:
|
|
1176
|
+
encoded = self._result(command, "rejected", failure="undeclared_safe_point")
|
|
1177
|
+
self.store.journal_phase(str(command["id"]), "completed", encoded)
|
|
1178
|
+
error = LaunchHelmError(
|
|
1179
|
+
"protocol",
|
|
1180
|
+
f"claimed command safe_point {request.get('safe_point')!r} does not match {name!r}",
|
|
1181
|
+
)
|
|
1182
|
+
self._set_fatal(error)
|
|
1183
|
+
raise error
|
|
1184
|
+
if fingerprint(request) != command["fingerprint"]:
|
|
1185
|
+
self.store.journal_phase(
|
|
1186
|
+
str(command["id"]),
|
|
1187
|
+
"completed",
|
|
1188
|
+
self._result(command, "rejected", failure="fingerprint_mismatch"),
|
|
1189
|
+
)
|
|
1190
|
+
raise ValueError("command fingerprint does not match payload")
|
|
1191
|
+
capability_ref = json_object(request.get("capability") or {}, what="capability ref")
|
|
1192
|
+
with self._lock:
|
|
1193
|
+
registered = self._handlers.get(str(capability_ref.get("id")))
|
|
1194
|
+
if registered is None or capability_ref.get("revision") != registered[3]:
|
|
1195
|
+
self.store.journal_phase(
|
|
1196
|
+
str(command["id"]),
|
|
1197
|
+
"completed",
|
|
1198
|
+
self._result(command, "rejected", failure="handler_unavailable_or_changed"),
|
|
1199
|
+
)
|
|
1200
|
+
return 0
|
|
1201
|
+
capability, handler, validate, _ = registered
|
|
1202
|
+
try:
|
|
1203
|
+
payload = request["input"]
|
|
1204
|
+
_validate_semantic(capability.input_schema, payload)
|
|
1205
|
+
if validate is not None:
|
|
1206
|
+
validate(payload)
|
|
1207
|
+
except Exception:
|
|
1208
|
+
self.store.journal_phase(
|
|
1209
|
+
str(command["id"]),
|
|
1210
|
+
"completed",
|
|
1211
|
+
self._result(command, "rejected", failure="local_validation"),
|
|
1212
|
+
)
|
|
1213
|
+
return 0
|
|
1214
|
+
self.store.journal_phase(str(command["id"]), "starting")
|
|
1215
|
+
try:
|
|
1216
|
+
self.client.begin(command)
|
|
1217
|
+
except LaunchHelmError as error:
|
|
1218
|
+
definitive = error.code in {
|
|
1219
|
+
"validation",
|
|
1220
|
+
"conflict",
|
|
1221
|
+
"not_found",
|
|
1222
|
+
"forbidden",
|
|
1223
|
+
"unauthorized",
|
|
1224
|
+
}
|
|
1225
|
+
self.store.journal_phase(
|
|
1226
|
+
str(command["id"]),
|
|
1227
|
+
"completed",
|
|
1228
|
+
self._result(
|
|
1229
|
+
command,
|
|
1230
|
+
"rejected" if definitive else "outcome_unknown",
|
|
1231
|
+
failure="begin_rejected" if definitive else "begin_uncertain",
|
|
1232
|
+
),
|
|
1233
|
+
)
|
|
1234
|
+
raise
|
|
1235
|
+
self.store.journal_phase(str(command["id"]), "applying")
|
|
1236
|
+
targets_raw = request.get("targets")
|
|
1237
|
+
targets_list = targets_raw if isinstance(targets_raw, list) else []
|
|
1238
|
+
context = CommandContext(
|
|
1239
|
+
str(command["id"]),
|
|
1240
|
+
tuple(
|
|
1241
|
+
Reference(str(item["id"]), str(item["kind"]), str(item.get("revision") or ""))
|
|
1242
|
+
for item in targets_list
|
|
1243
|
+
if isinstance(item, dict)
|
|
1244
|
+
),
|
|
1245
|
+
str(request["epoch"]),
|
|
1246
|
+
tuple(coordinates),
|
|
1247
|
+
)
|
|
1248
|
+
try:
|
|
1249
|
+
result = handler(payload, context)
|
|
1250
|
+
_validate(capability.output_schema, result)
|
|
1251
|
+
self.last_result = result
|
|
1252
|
+
encoded = self._result(command, "applied", result, coordinates)
|
|
1253
|
+
encode(encoded)
|
|
1254
|
+
except Exception as error:
|
|
1255
|
+
encoded = self._result(command, "outcome_unknown", failure=type(error).__name__[:128])
|
|
1256
|
+
self.store.journal_phase(str(command["id"]), "completed", encoded)
|
|
1257
|
+
return 1
|
|
1258
|
+
|
|
1259
|
+
def flush(
|
|
1260
|
+
self, *, timeout: float = DEFAULT_FLUSH_TIMEOUT_SECONDS, delivered: bool = True
|
|
1261
|
+
) -> None:
|
|
1262
|
+
"""Wait until admitted points reach the requested durability.
|
|
1263
|
+
|
|
1264
|
+
Contract: ``delivered=False`` is local SQLite durability (the writer
|
|
1265
|
+
has dequeued every volatile record; pending media is already durable
|
|
1266
|
+
at ``log_media`` so there is nothing further to wait for).
|
|
1267
|
+
``delivered=True`` additionally waits until media uploads resolve
|
|
1268
|
+
and the spool has no unacknowledged points (query-visible).
|
|
1269
|
+
Does not promise delivery after ``close``.
|
|
1270
|
+
|
|
1271
|
+
The media count is probed before the point count: a media point
|
|
1272
|
+
enters ``points`` atomically with its media row's removal, so a
|
|
1273
|
+
``pending_count`` read can pass just before that commit and a
|
|
1274
|
+
``media_pending_count`` read just after — reading them in this
|
|
1275
|
+
order would let ``flush`` return with the point still unsent.
|
|
1276
|
+
Points admitted concurrently with the wait itself are outside the
|
|
1277
|
+
contract; ``stop_accepting`` fences them for callers like
|
|
1278
|
+
``Run.finish`` that need a fixed drain set.
|
|
1279
|
+
|
|
1280
|
+
Failure: raises the background fatal if one is set, including when
|
|
1281
|
+
the queues are empty. ``TimeoutError`` if the deadline expires with
|
|
1282
|
+
work remaining.
|
|
1283
|
+
"""
|
|
1284
|
+
deadline = time.monotonic() + timeout
|
|
1285
|
+
while True:
|
|
1286
|
+
self._raise_if_fatal()
|
|
1287
|
+
if not self._queue.unfinished_tasks and (
|
|
1288
|
+
not delivered
|
|
1289
|
+
or (not self.store.media_pending_count() and not self.store.pending_count())
|
|
1290
|
+
):
|
|
1291
|
+
return
|
|
1292
|
+
if time.monotonic() >= deadline:
|
|
1293
|
+
raise TimeoutError(
|
|
1294
|
+
"LaunchHelm flush timed out; unacknowledged durable records remain in the spool"
|
|
1295
|
+
)
|
|
1296
|
+
time.sleep(FLUSH_POLL_SECONDS)
|
|
1297
|
+
|
|
1298
|
+
def stop_accepting(self) -> None:
|
|
1299
|
+
"""Stop point admission without stopping delivery.
|
|
1300
|
+
|
|
1301
|
+
Contract: idempotent. ``log`` and ``log_media`` calls — including
|
|
1302
|
+
ones already waiting on memory or media budget — raise
|
|
1303
|
+
``RuntimeError`` instead of admitting another point. Uploads,
|
|
1304
|
+
media resolution, heartbeats, and the spool keep running so a
|
|
1305
|
+
following ``flush(delivered=True)`` drains a fixed set; ``close``
|
|
1306
|
+
performs the full stop.
|
|
1307
|
+
|
|
1308
|
+
Failure: none.
|
|
1309
|
+
"""
|
|
1310
|
+
with self._space:
|
|
1311
|
+
self._accepting = False
|
|
1312
|
+
self._space.notify_all()
|
|
1313
|
+
self._media_space.notify_all()
|
|
1314
|
+
self._media_wake.set()
|
|
1315
|
+
|
|
1316
|
+
def close(self, *, timeout: float = DEFAULT_CLOSE_TIMEOUT_SECONDS) -> None:
|
|
1317
|
+
"""Stop admission, persist the volatile queue, and join workers.
|
|
1318
|
+
|
|
1319
|
+
Contract: local durability is attempted via
|
|
1320
|
+
``flush(delivered=False)``. Server delivery is not promised. Idempotent.
|
|
1321
|
+
|
|
1322
|
+
Failure: ``TimeoutError`` if background threads outlive ``timeout``.
|
|
1323
|
+
Re-raises a flush error after joining threads and closing the store.
|
|
1324
|
+
"""
|
|
1325
|
+
with self._space:
|
|
1326
|
+
if self._closed:
|
|
1327
|
+
return
|
|
1328
|
+
self.stop_accepting()
|
|
1329
|
+
failure: BaseException | None = None
|
|
1330
|
+
deadline = time.monotonic() + timeout
|
|
1331
|
+
try:
|
|
1332
|
+
self.flush(timeout=timeout, delivered=False)
|
|
1333
|
+
except BaseException as error:
|
|
1334
|
+
failure = error
|
|
1335
|
+
finally:
|
|
1336
|
+
self._stop.set()
|
|
1337
|
+
abort = getattr(self.client, "abort", None)
|
|
1338
|
+
if callable(abort):
|
|
1339
|
+
abort()
|
|
1340
|
+
for thread in self._threads:
|
|
1341
|
+
thread.join(max(0, deadline - time.monotonic()))
|
|
1342
|
+
if any(thread.is_alive() for thread in self._threads):
|
|
1343
|
+
raise TimeoutError("LaunchHelm background operations have not stopped")
|
|
1344
|
+
self.store.close()
|
|
1345
|
+
with self._lock:
|
|
1346
|
+
self._closed = True
|
|
1347
|
+
if failure is not None:
|
|
1348
|
+
raise failure
|