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.
- firecube/__init__.py +23 -0
- firecube/__main__.py +20 -0
- firecube/_version.py +46 -0
- firecube/cli/_audience.py +135 -0
- firecube/cli/_command_schemas.py +112 -0
- firecube/cli/_ctx.py +85 -0
- firecube/cli/_errors.py +115 -0
- firecube/cli/_formatter.py +195 -0
- firecube/cli/_product.py +47 -0
- firecube/cli/_rename_hints.py +123 -0
- firecube/cli/_shared_options.py +247 -0
- firecube/cli/_slot_env.py +102 -0
- firecube/cli/_slot_planning.py +95 -0
- firecube/cli/_typed_options.py +181 -0
- firecube/cli/_uri_policy.py +110 -0
- firecube/cli/advise.py +321 -0
- firecube/cli/archive.py +870 -0
- firecube/cli/catalog.py +196 -0
- firecube/cli/chunks/__init__.py +74 -0
- firecube/cli/chunks/_claims.py +229 -0
- firecube/cli/chunks/_common.py +84 -0
- firecube/cli/chunks/_delete.py +376 -0
- firecube/cli/chunks/_list.py +334 -0
- firecube/cli/chunks/_manager.py +161 -0
- firecube/cli/chunks/_runs.py +294 -0
- firecube/cli/chunks/_snapshots.py +165 -0
- firecube/cli/completion.py +73 -0
- firecube/cli/main.py +866 -0
- firecube/cli/parquet.py +289 -0
- firecube/cli/plugins/__init__.py +28 -0
- firecube/cli/plugins/commands.py +262 -0
- firecube/cli/plugins/introspect.py +304 -0
- firecube/cli/plugins/mgmt.py +198 -0
- firecube/cli/plugins/registry.py +132 -0
- firecube/cli/zarr.py +1043 -0
- firecube/core/__init__.py +14 -0
- firecube/core/api.py +88 -0
- firecube/core/cf/__init__.py +23 -0
- firecube/core/cf/check_ids.py +126 -0
- firecube/core/cf/report.py +57 -0
- firecube/core/cf/validator.py +313 -0
- firecube/core/config.py +307 -0
- firecube/core/controlplane/__init__.py +47 -0
- firecube/core/controlplane/_event_processor.py +136 -0
- firecube/core/controlplane/_helpers.py +114 -0
- firecube/core/controlplane/_projection.py +253 -0
- firecube/core/controlplane/_snapshot.py +215 -0
- firecube/core/controlplane/_wal_reader.py +402 -0
- firecube/core/controlplane/_wal_writer.py +589 -0
- firecube/core/controlplane/claims.py +294 -0
- firecube/core/controlplane/deletion.py +704 -0
- firecube/core/controlplane/events.py +219 -0
- firecube/core/controlplane/manager.py +990 -0
- firecube/core/controlplane/metrics.py +97 -0
- firecube/core/controlplane/repo.py +804 -0
- firecube/core/controlplane/repo_utils.py +73 -0
- firecube/core/controlplane/time_dim.py +111 -0
- firecube/core/controlplane/types.py +445 -0
- firecube/core/credentials.py +36 -0
- firecube/core/duckdb/__init__.py +27 -0
- firecube/core/duckdb/bridge.py +82 -0
- firecube/core/duckdb/io.py +30 -0
- firecube/core/errors.py +74 -0
- firecube/core/filesystem/__init__.py +60 -0
- firecube/core/filesystem/_obstore_compat.py +60 -0
- firecube/core/filesystem/fsspec_backend.py +308 -0
- firecube/core/filesystem/instrumentation.py +260 -0
- firecube/core/filesystem/obstore_backend.py +406 -0
- firecube/core/filesystem/ops.py +412 -0
- firecube/core/filesystem/protocol.py +130 -0
- firecube/core/filesystem/store_factory.py +149 -0
- firecube/core/formats/__init__.py +46 -0
- firecube/core/formats/_iso.py +84 -0
- firecube/core/formats/discovery.py +165 -0
- firecube/core/formats/hdf5.py +137 -0
- firecube/core/formats/netcdf.py +226 -0
- firecube/core/formats/zip.py +192 -0
- firecube/core/intake.py +530 -0
- firecube/core/observability/__init__.py +72 -0
- firecube/core/observability/_state.py +24 -0
- firecube/core/observability/logging.py +147 -0
- firecube/core/observability/metrics.py +230 -0
- firecube/core/observability/telemetry/__init__.py +87 -0
- firecube/core/observability/telemetry/pushgateway.py +339 -0
- firecube/core/observability/telemetry/sinks.py +155 -0
- firecube/core/observability/tracing.py +126 -0
- firecube/core/product/__init__.py +45 -0
- firecube/core/product/identity.py +76 -0
- firecube/core/product/resolver.py +121 -0
- firecube/core/product/target.py +120 -0
- firecube/core/runtime.py +187 -0
- firecube/core/slot_index.py +205 -0
- firecube/core/storage/__init__.py +45 -0
- firecube/core/storage/binding.py +39 -0
- firecube/core/storage/cache_key.py +40 -0
- firecube/core/storage/completion.py +245 -0
- firecube/core/storage/driver_config.py +125 -0
- firecube/core/storage/results.py +30 -0
- firecube/core/storage/session.py +431 -0
- firecube/core/storage/transfer.py +142 -0
- firecube/core/storage/uri.py +173 -0
- firecube/core/tensogram/__init__.py +37 -0
- firecube/core/tensogram/_compat.py +36 -0
- firecube/core/tensogram/controlplane_codec.py +230 -0
- firecube/core/tensogram/converter.py +390 -0
- firecube/core/tensogram/metadata.py +274 -0
- firecube/core/tensogram/restore.py +303 -0
- firecube/core/tensogram/schema.py +93 -0
- firecube/core/uris.py +182 -0
- firecube/core/workspaces.py +68 -0
- firecube/core/zarr/__init__.py +69 -0
- firecube/core/zarr/_reserved_attrs.py +47 -0
- firecube/core/zarr/_reserved_root_attrs.py +74 -0
- firecube/core/zarr/io.py +88 -0
- firecube/core/zarr/layers.py +184 -0
- firecube/core/zarr/multires.py +178 -0
- firecube/core/zarr/region_writer.py +782 -0
- firecube/core/zarr/scrub.py +200 -0
- firecube/core/zarr/state.py +258 -0
- firecube/core/zarr/time_decode.py +71 -0
- firecube/core/zarr/validation.py +622 -0
- firecube/ingestor/__init__.py +27 -0
- firecube/ingestor/api.py +215 -0
- firecube/ingestor/config/__init__.py +19 -0
- firecube/ingestor/config/coercion.py +157 -0
- firecube/ingestor/config/engine.py +188 -0
- firecube/ingestor/contracts/__init__.py +14 -0
- firecube/ingestor/contracts/interfaces.py +235 -0
- firecube/ingestor/devtools/_templates/__init__.py +14 -0
- firecube/ingestor/devtools/_templates/ingestor_base.py.tpl +72 -0
- firecube/ingestor/devtools/_templates/ingestor_direct_zarr.py.tpl +66 -0
- firecube/ingestor/devtools/_templates/ingestor_parquet.py.tpl +59 -0
- firecube/ingestor/devtools/_templates/ingestor_zarr.py.tpl +55 -0
- firecube/ingestor/devtools/_templates/readme.md.tpl +40 -0
- firecube/ingestor/devtools/scaffolding.py +229 -0
- firecube/ingestor/errors.py +79 -0
- firecube/ingestor/extensions/_binning.py +152 -0
- firecube/ingestor/extensions/duck.py +153 -0
- firecube/ingestor/extensions/grid.py +287 -0
- firecube/ingestor/extensions/healpix.py +330 -0
- firecube/ingestor/registry/__init__.py +14 -0
- firecube/ingestor/registry/loader.py +144 -0
- firecube/ingestor/registry/metadata.py +97 -0
- firecube/ingestor/registry/version_compat.py +95 -0
- firecube/ingestor/runtime/__init__.py +14 -0
- firecube/ingestor/runtime/aggregation.py +112 -0
- firecube/ingestor/runtime/base.py +836 -0
- firecube/ingestor/runtime/base_hooks.py +129 -0
- firecube/ingestor/runtime/batching.py +161 -0
- firecube/ingestor/runtime/configure.py +195 -0
- firecube/ingestor/runtime/coverage.py +177 -0
- firecube/ingestor/runtime/engine.py +793 -0
- firecube/ingestor/runtime/parallel_evidence.py +75 -0
- firecube/ingestor/runtime/parallel_execution_state.py +28 -0
- firecube/ingestor/runtime/parallel_gate.py +164 -0
- firecube/ingestor/runtime/parallel_run_id.py +48 -0
- firecube/ingestor/runtime/preflight.py +40 -0
- firecube/ingestor/runtime/recording.py +425 -0
- firecube/ingestor/runtime/resume_guard.py +556 -0
- firecube/ingestor/runtime/resume_types.py +65 -0
- firecube/ingestor/runtime/telemetry.py +171 -0
- firecube/ingestor/runtime/tensogram/__init__.py +14 -0
- firecube/ingestor/runtime/tensogram/strategy.py +164 -0
- firecube/ingestor/runtime/workspace.py +220 -0
- firecube/ingestor/runtime/zarr/__init__.py +34 -0
- firecube/ingestor/runtime/zarr/append.py +466 -0
- firecube/ingestor/runtime/zarr/append_services.py +650 -0
- firecube/ingestor/runtime/zarr/batch_runner.py +190 -0
- firecube/ingestor/runtime/zarr/contracts.py +58 -0
- firecube/ingestor/runtime/zarr/existing_cube_check.py +282 -0
- firecube/ingestor/runtime/zarr/resume_cache.py +92 -0
- firecube/ingestor/runtime/zarr/staged_metadata.py +249 -0
- firecube/ingestor/runtime/zarr/strategies/__init__.py +15 -0
- firecube/ingestor/runtime/zarr/strategies/append.py +172 -0
- firecube/ingestor/runtime/zarr/strategies/indexed_region.py +426 -0
- firecube/ingestor/runtime/zarr/write.py +212 -0
- firecube/ingestor/runtime/zarr/write_context.py +139 -0
- firecube/ingestor/templates/__init__.py +19 -0
- firecube/ingestor/templates/config.py +118 -0
- firecube/ingestor/templates/direct_zarr.py +790 -0
- firecube/ingestor/templates/generic.py +462 -0
- firecube/ingestor/templates/generic_tensogram.py +170 -0
- firecube/ingestor/types/__init__.py +19 -0
- firecube/ingestor/types/config.py +89 -0
- firecube/ingestor/types/context.py +409 -0
- firecube/ingestor/types/manifest.py +53 -0
- firecube/ingestor/types/planned_range.py +152 -0
- firecube/ingestor/types/result_metrics.py +386 -0
- firecube/ingestor/utils/__init__.py +14 -0
- firecube/ingestor/utils/duckdb_utils.py +235 -0
- firecube/ingestor/validation.py +55 -0
- firecube-0.1.4.dist-info/METADATA +242 -0
- firecube-0.1.4.dist-info/RECORD +197 -0
- firecube-0.1.4.dist-info/WHEEL +4 -0
- firecube-0.1.4.dist-info/entry_points.txt +2 -0
- firecube-0.1.4.dist-info/licenses/AUTHORS.md +5 -0
- 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)
|