firecube 0.1.4__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 (197) hide show
  1. firecube/__init__.py +23 -0
  2. firecube/__main__.py +20 -0
  3. firecube/_version.py +46 -0
  4. firecube/cli/_audience.py +135 -0
  5. firecube/cli/_command_schemas.py +112 -0
  6. firecube/cli/_ctx.py +85 -0
  7. firecube/cli/_errors.py +115 -0
  8. firecube/cli/_formatter.py +195 -0
  9. firecube/cli/_product.py +47 -0
  10. firecube/cli/_rename_hints.py +123 -0
  11. firecube/cli/_shared_options.py +247 -0
  12. firecube/cli/_slot_env.py +102 -0
  13. firecube/cli/_slot_planning.py +95 -0
  14. firecube/cli/_typed_options.py +181 -0
  15. firecube/cli/_uri_policy.py +110 -0
  16. firecube/cli/advise.py +321 -0
  17. firecube/cli/archive.py +870 -0
  18. firecube/cli/catalog.py +196 -0
  19. firecube/cli/chunks/__init__.py +74 -0
  20. firecube/cli/chunks/_claims.py +229 -0
  21. firecube/cli/chunks/_common.py +84 -0
  22. firecube/cli/chunks/_delete.py +376 -0
  23. firecube/cli/chunks/_list.py +334 -0
  24. firecube/cli/chunks/_manager.py +161 -0
  25. firecube/cli/chunks/_runs.py +294 -0
  26. firecube/cli/chunks/_snapshots.py +165 -0
  27. firecube/cli/completion.py +73 -0
  28. firecube/cli/main.py +866 -0
  29. firecube/cli/parquet.py +289 -0
  30. firecube/cli/plugins/__init__.py +28 -0
  31. firecube/cli/plugins/commands.py +262 -0
  32. firecube/cli/plugins/introspect.py +304 -0
  33. firecube/cli/plugins/mgmt.py +198 -0
  34. firecube/cli/plugins/registry.py +132 -0
  35. firecube/cli/zarr.py +1043 -0
  36. firecube/core/__init__.py +14 -0
  37. firecube/core/api.py +88 -0
  38. firecube/core/cf/__init__.py +23 -0
  39. firecube/core/cf/check_ids.py +126 -0
  40. firecube/core/cf/report.py +57 -0
  41. firecube/core/cf/validator.py +313 -0
  42. firecube/core/config.py +307 -0
  43. firecube/core/controlplane/__init__.py +47 -0
  44. firecube/core/controlplane/_event_processor.py +136 -0
  45. firecube/core/controlplane/_helpers.py +114 -0
  46. firecube/core/controlplane/_projection.py +253 -0
  47. firecube/core/controlplane/_snapshot.py +215 -0
  48. firecube/core/controlplane/_wal_reader.py +402 -0
  49. firecube/core/controlplane/_wal_writer.py +589 -0
  50. firecube/core/controlplane/claims.py +294 -0
  51. firecube/core/controlplane/deletion.py +704 -0
  52. firecube/core/controlplane/events.py +219 -0
  53. firecube/core/controlplane/manager.py +990 -0
  54. firecube/core/controlplane/metrics.py +97 -0
  55. firecube/core/controlplane/repo.py +804 -0
  56. firecube/core/controlplane/repo_utils.py +73 -0
  57. firecube/core/controlplane/time_dim.py +111 -0
  58. firecube/core/controlplane/types.py +445 -0
  59. firecube/core/credentials.py +36 -0
  60. firecube/core/duckdb/__init__.py +27 -0
  61. firecube/core/duckdb/bridge.py +82 -0
  62. firecube/core/duckdb/io.py +30 -0
  63. firecube/core/errors.py +74 -0
  64. firecube/core/filesystem/__init__.py +60 -0
  65. firecube/core/filesystem/_obstore_compat.py +60 -0
  66. firecube/core/filesystem/fsspec_backend.py +308 -0
  67. firecube/core/filesystem/instrumentation.py +260 -0
  68. firecube/core/filesystem/obstore_backend.py +406 -0
  69. firecube/core/filesystem/ops.py +412 -0
  70. firecube/core/filesystem/protocol.py +130 -0
  71. firecube/core/filesystem/store_factory.py +149 -0
  72. firecube/core/formats/__init__.py +46 -0
  73. firecube/core/formats/_iso.py +84 -0
  74. firecube/core/formats/discovery.py +165 -0
  75. firecube/core/formats/hdf5.py +137 -0
  76. firecube/core/formats/netcdf.py +226 -0
  77. firecube/core/formats/zip.py +192 -0
  78. firecube/core/intake.py +530 -0
  79. firecube/core/observability/__init__.py +72 -0
  80. firecube/core/observability/_state.py +24 -0
  81. firecube/core/observability/logging.py +147 -0
  82. firecube/core/observability/metrics.py +230 -0
  83. firecube/core/observability/telemetry/__init__.py +87 -0
  84. firecube/core/observability/telemetry/pushgateway.py +339 -0
  85. firecube/core/observability/telemetry/sinks.py +155 -0
  86. firecube/core/observability/tracing.py +126 -0
  87. firecube/core/product/__init__.py +45 -0
  88. firecube/core/product/identity.py +76 -0
  89. firecube/core/product/resolver.py +121 -0
  90. firecube/core/product/target.py +120 -0
  91. firecube/core/runtime.py +187 -0
  92. firecube/core/slot_index.py +205 -0
  93. firecube/core/storage/__init__.py +45 -0
  94. firecube/core/storage/binding.py +39 -0
  95. firecube/core/storage/cache_key.py +40 -0
  96. firecube/core/storage/completion.py +245 -0
  97. firecube/core/storage/driver_config.py +125 -0
  98. firecube/core/storage/results.py +30 -0
  99. firecube/core/storage/session.py +431 -0
  100. firecube/core/storage/transfer.py +142 -0
  101. firecube/core/storage/uri.py +173 -0
  102. firecube/core/tensogram/__init__.py +37 -0
  103. firecube/core/tensogram/_compat.py +36 -0
  104. firecube/core/tensogram/controlplane_codec.py +230 -0
  105. firecube/core/tensogram/converter.py +390 -0
  106. firecube/core/tensogram/metadata.py +274 -0
  107. firecube/core/tensogram/restore.py +303 -0
  108. firecube/core/tensogram/schema.py +93 -0
  109. firecube/core/uris.py +182 -0
  110. firecube/core/workspaces.py +68 -0
  111. firecube/core/zarr/__init__.py +69 -0
  112. firecube/core/zarr/_reserved_attrs.py +47 -0
  113. firecube/core/zarr/_reserved_root_attrs.py +74 -0
  114. firecube/core/zarr/io.py +88 -0
  115. firecube/core/zarr/layers.py +184 -0
  116. firecube/core/zarr/multires.py +178 -0
  117. firecube/core/zarr/region_writer.py +782 -0
  118. firecube/core/zarr/scrub.py +200 -0
  119. firecube/core/zarr/state.py +258 -0
  120. firecube/core/zarr/time_decode.py +71 -0
  121. firecube/core/zarr/validation.py +622 -0
  122. firecube/ingestor/__init__.py +27 -0
  123. firecube/ingestor/api.py +215 -0
  124. firecube/ingestor/config/__init__.py +19 -0
  125. firecube/ingestor/config/coercion.py +157 -0
  126. firecube/ingestor/config/engine.py +188 -0
  127. firecube/ingestor/contracts/__init__.py +14 -0
  128. firecube/ingestor/contracts/interfaces.py +235 -0
  129. firecube/ingestor/devtools/_templates/__init__.py +14 -0
  130. firecube/ingestor/devtools/_templates/ingestor_base.py.tpl +72 -0
  131. firecube/ingestor/devtools/_templates/ingestor_direct_zarr.py.tpl +66 -0
  132. firecube/ingestor/devtools/_templates/ingestor_parquet.py.tpl +59 -0
  133. firecube/ingestor/devtools/_templates/ingestor_zarr.py.tpl +55 -0
  134. firecube/ingestor/devtools/_templates/readme.md.tpl +40 -0
  135. firecube/ingestor/devtools/scaffolding.py +229 -0
  136. firecube/ingestor/errors.py +79 -0
  137. firecube/ingestor/extensions/_binning.py +152 -0
  138. firecube/ingestor/extensions/duck.py +153 -0
  139. firecube/ingestor/extensions/grid.py +287 -0
  140. firecube/ingestor/extensions/healpix.py +330 -0
  141. firecube/ingestor/registry/__init__.py +14 -0
  142. firecube/ingestor/registry/loader.py +144 -0
  143. firecube/ingestor/registry/metadata.py +97 -0
  144. firecube/ingestor/registry/version_compat.py +95 -0
  145. firecube/ingestor/runtime/__init__.py +14 -0
  146. firecube/ingestor/runtime/aggregation.py +112 -0
  147. firecube/ingestor/runtime/base.py +836 -0
  148. firecube/ingestor/runtime/base_hooks.py +129 -0
  149. firecube/ingestor/runtime/batching.py +161 -0
  150. firecube/ingestor/runtime/configure.py +195 -0
  151. firecube/ingestor/runtime/coverage.py +177 -0
  152. firecube/ingestor/runtime/engine.py +793 -0
  153. firecube/ingestor/runtime/parallel_evidence.py +75 -0
  154. firecube/ingestor/runtime/parallel_execution_state.py +28 -0
  155. firecube/ingestor/runtime/parallel_gate.py +164 -0
  156. firecube/ingestor/runtime/parallel_run_id.py +48 -0
  157. firecube/ingestor/runtime/preflight.py +40 -0
  158. firecube/ingestor/runtime/recording.py +425 -0
  159. firecube/ingestor/runtime/resume_guard.py +556 -0
  160. firecube/ingestor/runtime/resume_types.py +65 -0
  161. firecube/ingestor/runtime/telemetry.py +171 -0
  162. firecube/ingestor/runtime/tensogram/__init__.py +14 -0
  163. firecube/ingestor/runtime/tensogram/strategy.py +164 -0
  164. firecube/ingestor/runtime/workspace.py +220 -0
  165. firecube/ingestor/runtime/zarr/__init__.py +34 -0
  166. firecube/ingestor/runtime/zarr/append.py +466 -0
  167. firecube/ingestor/runtime/zarr/append_services.py +650 -0
  168. firecube/ingestor/runtime/zarr/batch_runner.py +190 -0
  169. firecube/ingestor/runtime/zarr/contracts.py +58 -0
  170. firecube/ingestor/runtime/zarr/existing_cube_check.py +282 -0
  171. firecube/ingestor/runtime/zarr/resume_cache.py +92 -0
  172. firecube/ingestor/runtime/zarr/staged_metadata.py +249 -0
  173. firecube/ingestor/runtime/zarr/strategies/__init__.py +15 -0
  174. firecube/ingestor/runtime/zarr/strategies/append.py +172 -0
  175. firecube/ingestor/runtime/zarr/strategies/indexed_region.py +426 -0
  176. firecube/ingestor/runtime/zarr/write.py +212 -0
  177. firecube/ingestor/runtime/zarr/write_context.py +139 -0
  178. firecube/ingestor/templates/__init__.py +19 -0
  179. firecube/ingestor/templates/config.py +118 -0
  180. firecube/ingestor/templates/direct_zarr.py +790 -0
  181. firecube/ingestor/templates/generic.py +462 -0
  182. firecube/ingestor/templates/generic_tensogram.py +170 -0
  183. firecube/ingestor/types/__init__.py +19 -0
  184. firecube/ingestor/types/config.py +89 -0
  185. firecube/ingestor/types/context.py +409 -0
  186. firecube/ingestor/types/manifest.py +53 -0
  187. firecube/ingestor/types/planned_range.py +152 -0
  188. firecube/ingestor/types/result_metrics.py +386 -0
  189. firecube/ingestor/utils/__init__.py +14 -0
  190. firecube/ingestor/utils/duckdb_utils.py +235 -0
  191. firecube/ingestor/validation.py +55 -0
  192. firecube-0.1.4.dist-info/METADATA +242 -0
  193. firecube-0.1.4.dist-info/RECORD +197 -0
  194. firecube-0.1.4.dist-info/WHEEL +4 -0
  195. firecube-0.1.4.dist-info/entry_points.txt +2 -0
  196. firecube-0.1.4.dist-info/licenses/AUTHORS.md +5 -0
  197. firecube-0.1.4.dist-info/licenses/LICENSE +201 -0
@@ -0,0 +1,402 @@
1
+ # Copyright 2025-2026 EUMETSAT
2
+ #
3
+ # Licensed under the Apache License, Version 2.0 (the "License");
4
+ # you may not use this file except in compliance with the License.
5
+ # You may obtain a copy of the License at
6
+ #
7
+ # http://www.apache.org/licenses/LICENSE-2.0
8
+ #
9
+ # Unless required by applicable law or agreed to in writing, software
10
+ # distributed under the License is distributed on an "AS IS" BASIS,
11
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ # See the License for the specific language governing permissions and
13
+ # limitations under the License.
14
+
15
+ """WAL (Write-Ahead Log) reader for the control plane.
16
+
17
+ Reads and parses WAL event segments stored under .firecube/runs/<run_id>/.
18
+ Each run directory contains:
19
+ - run.json: run metadata (status, timestamps, configuration)
20
+ - events-<timestamp>.jsonl: append-only event segments
21
+
22
+ Segment structure:
23
+ - One JSON object per line (newline-delimited JSON)
24
+ - Each object has schema_version, event_type, record, timestamp, meta
25
+
26
+ Torn tail recovery:
27
+ - If the last line of a segment is incomplete (no trailing newline),
28
+ it's treated as a torn write from a crash. The partial line is discarded.
29
+ - Multiple torn tails in a single run are treated as corruption (manual repair).
30
+ - A torn tail in a non-terminal segment with valid later segments is acceptable
31
+ only if all later events are terminal (run_completed/failed/abandoned).
32
+
33
+ Thread safety:
34
+ - All read operations are safe for concurrent use (no mutable state modified).
35
+ - Multiple WalReader instances can read the same product simultaneously.
36
+ """
37
+
38
+ from __future__ import annotations
39
+
40
+ import json
41
+ import logging
42
+ from datetime import datetime
43
+ from typing import Any
44
+
45
+ from firecube.core.controlplane.metrics import record_wal_corruption, record_wal_torn_tail_recovery
46
+ from firecube.core.controlplane.types import RUNS_DIRNAME, SCHEMA_VERSION
47
+ from firecube.core.errors import ControlPlaneCorruptionError
48
+ from firecube.core.storage.uri import StorageUri
49
+
50
+
51
+ class WalReader:
52
+ """Reads WAL segments and run metadata from the control-plane filesystem.
53
+
54
+ Constructed with already-bound filesystem and resolver from ManifestRepository.
55
+ All methods are read-only and thread-safe.
56
+ """
57
+
58
+ def __init__(
59
+ self,
60
+ *,
61
+ fs: Any,
62
+ resolver: Any,
63
+ log: logging.Logger,
64
+ run_stale_threshold_s: int,
65
+ ) -> None:
66
+ self._fs = fs
67
+ self._resolver = resolver
68
+ self.log = log
69
+ self.run_stale_threshold_s = run_stale_threshold_s
70
+
71
+ @staticmethod
72
+ def _entry_to_uri(entry: Any, base_uri: StorageUri) -> StorageUri:
73
+ name = entry.get("name") if isinstance(entry, dict) else entry
74
+ if isinstance(name, StorageUri):
75
+ return name
76
+ raw = str(name or "")
77
+ if "://" in raw:
78
+ return StorageUri.parse(raw)
79
+ if base_uri.protocol == "file" and raw.startswith("/"):
80
+ return StorageUri.from_local_path(raw)
81
+ path = raw
82
+ if base_uri.authority and path.startswith(f"{base_uri.authority}/"):
83
+ path = path[len(base_uri.authority) + 1 :]
84
+ return StorageUri(protocol=base_uri.protocol, authority=base_uri.authority, path=path)
85
+
86
+ def list_run_segment_paths(self, run_dir: StorageUri) -> list[StorageUri]:
87
+ """List all .jsonl event segment paths in a run directory, sorted."""
88
+ try:
89
+ entries = self._fs.ls(run_dir, detail=False)
90
+ except Exception:
91
+ return []
92
+ paths = [self._entry_to_uri(item, run_dir) for item in entries]
93
+ return sorted(
94
+ (path for path in paths if path.path.endswith(".jsonl")),
95
+ key=lambda p: p.to_str(),
96
+ )
97
+
98
+ @staticmethod
99
+ def _run_id_from_dir(run_dir: StorageUri, runs_dir: StorageUri) -> str:
100
+ """Return the full run-id subpath beneath the runs directory."""
101
+ runs_path = runs_dir.path.rstrip("/")
102
+ run_path = run_dir.path.rstrip("/")
103
+ prefix = f"{runs_path}/"
104
+ if run_path.startswith(prefix):
105
+ return run_path[len(prefix) :]
106
+ return run_path.lstrip("/")
107
+
108
+ @staticmethod
109
+ def _run_id_from_parent(run_dir: StorageUri) -> str:
110
+ """Return a run-id relative to the directory's immediate parent."""
111
+ parent_path = run_dir.parent().path.rstrip("/")
112
+ run_path = run_dir.path.rstrip("/")
113
+ return run_path.removeprefix(f"{parent_path}/").lstrip("/")
114
+
115
+ def path_timestamp(self, path: StorageUri) -> float:
116
+ """Extract filesystem modification timestamp for a path."""
117
+ try:
118
+ info = self._fs.info(path)
119
+ except Exception:
120
+ return 0.0
121
+ for key in ("mtime", "LastModified", "updated", "created"):
122
+ value = info.get(key)
123
+ timestamp = self._coerce_timestamp(value)
124
+ if timestamp is not None:
125
+ return timestamp
126
+ return 0.0
127
+
128
+ def _coerce_timestamp(self, value: Any) -> float | None:
129
+ """Convert various timestamp representations to float epoch seconds."""
130
+ if value is None:
131
+ return None
132
+ if isinstance(value, (int, float)):
133
+ return float(value)
134
+ if isinstance(value, datetime):
135
+ return value.timestamp()
136
+ if isinstance(value, str):
137
+ raw = value.strip()
138
+ if not raw:
139
+ return None
140
+ try:
141
+ return float(raw)
142
+ except ValueError:
143
+ pass
144
+ try:
145
+ if raw.endswith("Z"):
146
+ raw = raw[:-1] + "+00:00"
147
+ return datetime.fromisoformat(raw).timestamp()
148
+ except ValueError:
149
+ return None
150
+ return None
151
+
152
+ def build_orphan_run_entry(
153
+ self,
154
+ *,
155
+ product: str,
156
+ run_id: str,
157
+ run_dir: StorageUri,
158
+ run_uri: str,
159
+ segment_paths: list[StorageUri],
160
+ error: str,
161
+ ) -> dict[str, Any]:
162
+ """Construct a synthetic run entry for runs missing valid run.json metadata."""
163
+ timestamps = [
164
+ ts for ts in [self.path_timestamp(path) for path in segment_paths] if ts > 0.0
165
+ ]
166
+ if not timestamps:
167
+ run_ts = self.path_timestamp(run_dir)
168
+ if run_ts > 0.0:
169
+ timestamps = [run_ts]
170
+ started_at = min(timestamps) if timestamps else 0.0
171
+ updated_at = max(timestamps) if timestamps else 0.0
172
+ return {
173
+ "schema_version": SCHEMA_VERSION,
174
+ "product": product,
175
+ "run_id": run_id,
176
+ "status": "orphaned",
177
+ "parts": len(segment_paths),
178
+ "events": 0,
179
+ "started_at": started_at,
180
+ "updated_at": updated_at,
181
+ "run_uri": run_uri,
182
+ "run_stale_threshold_s": self.run_stale_threshold_s,
183
+ "error": error,
184
+ "run_dir": run_dir.to_str(),
185
+ }
186
+
187
+ def read_run_entry(
188
+ self,
189
+ *,
190
+ product: str,
191
+ run_dir: StorageUri,
192
+ run_uri: str,
193
+ run_id: str | None = None,
194
+ ) -> dict[str, Any] | None:
195
+ """Read and parse a single run's metadata from run.json.
196
+
197
+ Returns None if the run directory has no metadata and no segments.
198
+ Returns an orphan entry if metadata is missing but segments exist.
199
+ """
200
+ run_id = run_id or self._run_id_from_parent(run_dir)
201
+ meta_uri = run_dir.join("run.json")
202
+ segment_paths = self.list_run_segment_paths(run_dir)
203
+
204
+ if self._fs.exists(meta_uri):
205
+ try:
206
+ with self._fs.open(meta_uri, "r") as handle:
207
+ payload = json.load(handle)
208
+ except Exception:
209
+ if not segment_paths:
210
+ raise
211
+ return self.build_orphan_run_entry(
212
+ product=product,
213
+ run_id=run_id,
214
+ run_dir=run_dir,
215
+ run_uri=run_uri,
216
+ segment_paths=segment_paths,
217
+ error="unreadable_run_meta",
218
+ )
219
+ payload["run_dir"] = run_dir.to_str()
220
+ payload["run_uri"] = str(payload.get("run_uri") or run_uri)
221
+ payload["product"] = product
222
+ return payload
223
+
224
+ if segment_paths:
225
+ return self.build_orphan_run_entry(
226
+ product=product,
227
+ run_id=run_id,
228
+ run_dir=run_dir,
229
+ run_uri=run_uri,
230
+ segment_paths=segment_paths,
231
+ error="missing_run_meta",
232
+ )
233
+ return None
234
+
235
+ def list_run_entries(self, product: str) -> list[dict[str, Any]]:
236
+ """Discover all run entries for a product by scanning the runs/ directory."""
237
+ control_path, control_uri = self._resolver(product)
238
+ runs_dir = control_path.join(RUNS_DIRNAME)
239
+ if not self._fs.exists(runs_dir):
240
+ return []
241
+ try:
242
+ entries = self._fs.ls(runs_dir, detail=False)
243
+ except Exception as exc:
244
+ self.log.warning("Failed to list run entries at %s: %s", runs_dir.to_str(), exc)
245
+ return []
246
+ run_entries: list[dict[str, Any]] = []
247
+ for entry in entries:
248
+ run_dir = self._entry_to_uri(entry, runs_dir)
249
+ run_id = self._run_id_from_dir(run_dir, runs_dir)
250
+ run_uri = control_uri.join(
251
+ RUNS_DIRNAME,
252
+ run_id,
253
+ ).to_str()
254
+ try:
255
+ payload = self.read_run_entry(
256
+ product=product,
257
+ run_dir=run_dir,
258
+ run_uri=run_uri,
259
+ run_id=run_id,
260
+ )
261
+ except Exception as exc:
262
+ record_wal_corruption()
263
+ msg = f"Failed to read run metadata for {run_dir.to_str()}: {exc}"
264
+ self.log.error(msg)
265
+ raise ControlPlaneCorruptionError(msg) from exc
266
+ if payload is not None:
267
+ run_entries.append(payload)
268
+ return run_entries
269
+
270
+ def read_run_segment(
271
+ self,
272
+ *,
273
+ path: StorageUri,
274
+ product: str,
275
+ run_id: str,
276
+ allow_torn_tail: bool,
277
+ ) -> tuple[list[dict[str, Any]], bool]:
278
+ """Parse a single WAL segment file, returning (events, recovered_tail).
279
+
280
+ If allow_torn_tail is True and the last line is incomplete (no trailing
281
+ newline), it's silently discarded as a torn write. recovered_tail=True
282
+ indicates this happened.
283
+ """
284
+ try:
285
+ with self._fs.open(path, "r") as handle:
286
+ payload = handle.read()
287
+ except Exception as exc:
288
+ record_wal_corruption()
289
+ msg = f"Failed to read WAL segment {path.to_str()}: {exc}"
290
+ self.log.error(msg)
291
+ raise ControlPlaneCorruptionError(msg) from exc
292
+
293
+ text = str(payload)
294
+ lines = text.splitlines(keepends=True)
295
+ events: list[dict[str, Any]] = []
296
+ recovered_tail = False
297
+
298
+ for line_index, raw_line in enumerate(lines):
299
+ line = raw_line.strip()
300
+ if not line:
301
+ continue
302
+ try:
303
+ event = json.loads(line)
304
+ except json.JSONDecodeError as exc:
305
+ is_last_line = line_index == len(lines) - 1
306
+ if allow_torn_tail and is_last_line and not text.endswith("\n"):
307
+ self.log.warning(
308
+ "Recovered torn WAL tail for product=%s run=%s segment=%s",
309
+ product,
310
+ run_id,
311
+ path.to_str(),
312
+ )
313
+ recovered_tail = True
314
+ record_wal_torn_tail_recovery()
315
+ break
316
+ record_wal_corruption()
317
+ msg = f"Corrupt WAL event in {path.to_str()} at line {line_index + 1}: {exc}"
318
+ self.log.error(msg)
319
+ raise ControlPlaneCorruptionError(msg) from exc
320
+ if event.get("schema_version") != SCHEMA_VERSION:
321
+ record_wal_corruption()
322
+ msg = (
323
+ f"Unsupported event schema version in {path.to_str()}: "
324
+ f"{event.get('schema_version')}"
325
+ )
326
+ self.log.error(msg)
327
+ raise ControlPlaneCorruptionError(msg)
328
+ events.append(event)
329
+ return events, recovered_tail
330
+
331
+ def read_run_events(self, product: str, run_entry: dict[str, Any]) -> list[dict[str, Any]]:
332
+ """Read all events for a run across all segments, handling torn tails.
333
+
334
+ Segments are read in sorted order. Torn tail recovery rules:
335
+ - At most one torn tail is allowed per run
336
+ - A torn tail in the last segment of a non-terminal run is OK (active write)
337
+ - A torn tail in a non-last segment is OK only if all later events are terminal
338
+ - Otherwise, corruption is raised (manual repair needed)
339
+ """
340
+ run_dir = StorageUri.parse(str(run_entry["run_dir"]))
341
+ run_id = str(run_entry.get("run_id", "unknown"))
342
+ try:
343
+ entries = self._fs.ls(run_dir, detail=False)
344
+ except Exception as exc:
345
+ record_wal_corruption()
346
+ msg = f"Failed to list WAL segments for run {run_id}: {exc}"
347
+ self.log.error(msg)
348
+ raise ControlPlaneCorruptionError(msg) from exc
349
+
350
+ segment_paths = [self._entry_to_uri(item, run_dir) for item in entries]
351
+ event_paths = sorted(
352
+ (path for path in segment_paths if path.path.endswith(".jsonl")),
353
+ key=lambda p: p.to_str(),
354
+ )
355
+ events: list[dict[str, Any]] = []
356
+ status = str(run_entry.get("status", ""))
357
+ is_terminal = status in {"complete", "failed", "abandoned"}
358
+ recovered_tail_segment_index: int | None = None
359
+ later_terminal_events: list[dict[str, Any]] = []
360
+
361
+ for path_index, path in enumerate(event_paths):
362
+ segment_events, recovered_tail = self.read_run_segment(
363
+ path=path,
364
+ product=product,
365
+ run_id=run_id,
366
+ allow_torn_tail=True,
367
+ )
368
+ if recovered_tail:
369
+ if recovered_tail_segment_index is not None:
370
+ record_wal_corruption()
371
+ msg = f"Multiple torn WAL tails detected for run {run_id}; manual repair required."
372
+ self.log.error(msg)
373
+ raise ControlPlaneCorruptionError(msg)
374
+ recovered_tail_segment_index = path_index
375
+ events.extend(segment_events)
376
+ if (
377
+ recovered_tail_segment_index is not None
378
+ and path_index > recovered_tail_segment_index
379
+ ):
380
+ later_terminal_events.extend(segment_events)
381
+
382
+ if recovered_tail_segment_index is None:
383
+ return events
384
+
385
+ last_segment_index = len(event_paths) - 1
386
+ if recovered_tail_segment_index == last_segment_index and not is_terminal:
387
+ return events
388
+
389
+ if (
390
+ recovered_tail_segment_index < last_segment_index
391
+ and later_terminal_events
392
+ and all(
393
+ event.get("event_type") in {"run_completed", "run_failed", "run_abandoned"}
394
+ for event in later_terminal_events
395
+ )
396
+ ):
397
+ return events
398
+
399
+ record_wal_corruption()
400
+ msg = f"Run {run_id} has a torn WAL tail in a non-recoverable position."
401
+ self.log.error(msg)
402
+ raise ControlPlaneCorruptionError(msg)