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,425 @@
|
|
|
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
|
+
"""Engine-owned boundary for recording ingestion lifecycle into ChunkManager state."""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import logging
|
|
20
|
+
import threading
|
|
21
|
+
from datetime import datetime
|
|
22
|
+
from typing import Any
|
|
23
|
+
|
|
24
|
+
from firecube.core.controlplane import ChunkManager, SpanCoverage
|
|
25
|
+
from firecube.core.errors import ManifestError
|
|
26
|
+
from firecube.ingestor.types.context import (
|
|
27
|
+
IngestResult,
|
|
28
|
+
PipelineBatch,
|
|
29
|
+
PipelineResult,
|
|
30
|
+
PluginContext,
|
|
31
|
+
RuntimeIngestContext,
|
|
32
|
+
)
|
|
33
|
+
from firecube.ingestor.types.result_metrics import ResultMetrics
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class SpanRecorder:
|
|
37
|
+
"""Map engine lifecycle events into ChunkManager's WAL-backed control-plane records."""
|
|
38
|
+
|
|
39
|
+
def __init__(self, chunk_manager: ChunkManager):
|
|
40
|
+
self._chunk_manager = chunk_manager
|
|
41
|
+
self._log = logging.getLogger("firecube.ingestor.recording")
|
|
42
|
+
|
|
43
|
+
def register_run_started(
|
|
44
|
+
self,
|
|
45
|
+
*,
|
|
46
|
+
run_id: str,
|
|
47
|
+
product: str,
|
|
48
|
+
output_path: str,
|
|
49
|
+
output_format: str,
|
|
50
|
+
slice_meta: dict[str, Any],
|
|
51
|
+
slot_range: tuple[int, int] | None = None,
|
|
52
|
+
slot_group: str | None = None,
|
|
53
|
+
) -> None:
|
|
54
|
+
"""Record the engine-owned non-terminal start event for one run."""
|
|
55
|
+
if threading.current_thread() is not threading.main_thread():
|
|
56
|
+
raise ManifestError("Run start registration must occur on the main thread.")
|
|
57
|
+
meta = dict(slice_meta)
|
|
58
|
+
meta["run_id"] = run_id
|
|
59
|
+
self._chunk_manager.record_run_started(
|
|
60
|
+
product=product,
|
|
61
|
+
run_id=run_id,
|
|
62
|
+
output_path=output_path,
|
|
63
|
+
output_format=output_format,
|
|
64
|
+
size=0,
|
|
65
|
+
meta=meta,
|
|
66
|
+
slot_range=slot_range,
|
|
67
|
+
slot_group=slot_group,
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
def register_run(
|
|
71
|
+
self,
|
|
72
|
+
ctx: RuntimeIngestContext,
|
|
73
|
+
result: IngestResult,
|
|
74
|
+
run_id: str,
|
|
75
|
+
product: str,
|
|
76
|
+
slice_meta: dict[str, Any],
|
|
77
|
+
record_spans: bool = True,
|
|
78
|
+
slot_range: tuple[int, int] | None = None,
|
|
79
|
+
slot_group: str | None = None,
|
|
80
|
+
) -> None:
|
|
81
|
+
"""Record the terminal run event and any final span coverage through ChunkManager."""
|
|
82
|
+
_ = ctx
|
|
83
|
+
if threading.current_thread() is not threading.main_thread():
|
|
84
|
+
raise ManifestError("Run registration must occur on the main thread.")
|
|
85
|
+
|
|
86
|
+
if result.registered:
|
|
87
|
+
self._log.warning("Run %s is already registered, skipping.", run_id)
|
|
88
|
+
return
|
|
89
|
+
|
|
90
|
+
meta = dict(slice_meta)
|
|
91
|
+
coverage = _span_coverage_from_metrics(
|
|
92
|
+
result.metrics,
|
|
93
|
+
logger=self._log,
|
|
94
|
+
context="run registration",
|
|
95
|
+
)
|
|
96
|
+
time_min, time_max = _time_bounds_from_coverage(coverage)
|
|
97
|
+
if time_min:
|
|
98
|
+
meta["time_min"] = time_min
|
|
99
|
+
if time_max:
|
|
100
|
+
meta["time_max"] = time_max
|
|
101
|
+
|
|
102
|
+
storage_bytes: int = 0
|
|
103
|
+
storage_summary = result.metrics.storage
|
|
104
|
+
if storage_summary is not None:
|
|
105
|
+
storage_bytes = storage_summary.bytes
|
|
106
|
+
|
|
107
|
+
self._chunk_manager.record_run_terminal(
|
|
108
|
+
product=product,
|
|
109
|
+
run_id=run_id,
|
|
110
|
+
output_path=str(result.outputs.primary),
|
|
111
|
+
output_format=result.output_format or "unknown",
|
|
112
|
+
size=storage_bytes,
|
|
113
|
+
meta=meta,
|
|
114
|
+
status="complete",
|
|
115
|
+
slot_range=slot_range,
|
|
116
|
+
slot_group=slot_group,
|
|
117
|
+
)
|
|
118
|
+
post_terminal_events_recorded = False
|
|
119
|
+
prior_spans: list[Any] = []
|
|
120
|
+
|
|
121
|
+
if ctx.force_reingest:
|
|
122
|
+
prior_spans = _list_prior_active_spans_for_replacement(
|
|
123
|
+
self._chunk_manager,
|
|
124
|
+
product=product,
|
|
125
|
+
run_id=run_id,
|
|
126
|
+
slice_meta=meta,
|
|
127
|
+
)
|
|
128
|
+
if prior_spans and not coverage:
|
|
129
|
+
raise ManifestError(
|
|
130
|
+
f"force_reingest refusing to commit replacement for product={product!r}: "
|
|
131
|
+
f"prior_spans={len(prior_spans)} but new coverage is empty. "
|
|
132
|
+
"This would erase active coverage. "
|
|
133
|
+
f"Abandon the run first: firecube chunks runs abandon {run_id}"
|
|
134
|
+
)
|
|
135
|
+
|
|
136
|
+
if record_spans and coverage:
|
|
137
|
+
base_meta = dict(meta)
|
|
138
|
+
base_meta["run_id"] = run_id
|
|
139
|
+
for cov in coverage:
|
|
140
|
+
span_meta = dict(base_meta)
|
|
141
|
+
if cov.time_min:
|
|
142
|
+
span_meta["time_min"] = cov.time_min
|
|
143
|
+
if cov.time_max:
|
|
144
|
+
span_meta["time_max"] = cov.time_max
|
|
145
|
+
self._chunk_manager.record_span(
|
|
146
|
+
product=product,
|
|
147
|
+
run_id=str(run_id),
|
|
148
|
+
batch_id="single",
|
|
149
|
+
group=cov.group,
|
|
150
|
+
status="active",
|
|
151
|
+
coverage=cov,
|
|
152
|
+
meta=span_meta,
|
|
153
|
+
)
|
|
154
|
+
post_terminal_events_recorded = True
|
|
155
|
+
|
|
156
|
+
if ctx.force_reingest and prior_spans:
|
|
157
|
+
self._chunk_manager.record_replacement_committed( # pyright: ignore[reportAttributeAccessIssue]
|
|
158
|
+
product=product,
|
|
159
|
+
run_id=run_id,
|
|
160
|
+
replacing_run_id=run_id,
|
|
161
|
+
replaced_span_keys=[span.key for span in prior_spans],
|
|
162
|
+
)
|
|
163
|
+
post_terminal_events_recorded = True
|
|
164
|
+
|
|
165
|
+
if post_terminal_events_recorded:
|
|
166
|
+
_rewrite_terminal_run_metadata(self._chunk_manager, product=product, run_id=run_id)
|
|
167
|
+
|
|
168
|
+
result.registered = True
|
|
169
|
+
|
|
170
|
+
def register_run_failure(
|
|
171
|
+
self,
|
|
172
|
+
*,
|
|
173
|
+
run_id: str,
|
|
174
|
+
product: str,
|
|
175
|
+
output_path: str,
|
|
176
|
+
output_format: str,
|
|
177
|
+
slice_meta: dict[str, Any],
|
|
178
|
+
error: str,
|
|
179
|
+
) -> None:
|
|
180
|
+
"""Record a terminal run failure event through ChunkManager."""
|
|
181
|
+
if threading.current_thread() is not threading.main_thread():
|
|
182
|
+
raise ManifestError("Run failure registration must occur on the main thread.")
|
|
183
|
+
meta = dict(slice_meta)
|
|
184
|
+
meta["run_id"] = run_id
|
|
185
|
+
self._chunk_manager.record_run_failed(
|
|
186
|
+
product=product,
|
|
187
|
+
run_id=run_id,
|
|
188
|
+
output_path=output_path,
|
|
189
|
+
output_format=output_format,
|
|
190
|
+
size=0,
|
|
191
|
+
meta=meta,
|
|
192
|
+
error=error,
|
|
193
|
+
)
|
|
194
|
+
|
|
195
|
+
def record_batch_success(
|
|
196
|
+
self,
|
|
197
|
+
ctx: PluginContext,
|
|
198
|
+
batch: PipelineBatch,
|
|
199
|
+
result: PipelineResult,
|
|
200
|
+
slice_meta: dict[str, Any],
|
|
201
|
+
run_id: str,
|
|
202
|
+
product: str,
|
|
203
|
+
) -> None:
|
|
204
|
+
"""Record one successful batch as active or skipped span records."""
|
|
205
|
+
_ = ctx
|
|
206
|
+
base_meta = dict(slice_meta)
|
|
207
|
+
base_meta["run_id"] = run_id
|
|
208
|
+
|
|
209
|
+
coverage_list = _span_coverage_from_metrics(
|
|
210
|
+
result.metrics,
|
|
211
|
+
logger=self._log,
|
|
212
|
+
context=f"batch {batch.batch_id}",
|
|
213
|
+
)
|
|
214
|
+
if not coverage_list:
|
|
215
|
+
for group in batch.groups or ["unknown"]:
|
|
216
|
+
self._chunk_manager.record_span(
|
|
217
|
+
product=product,
|
|
218
|
+
run_id=run_id,
|
|
219
|
+
batch_id=batch.batch_id,
|
|
220
|
+
group=group,
|
|
221
|
+
status="skipped",
|
|
222
|
+
reason="No coverage generated",
|
|
223
|
+
meta=base_meta,
|
|
224
|
+
)
|
|
225
|
+
return
|
|
226
|
+
|
|
227
|
+
for cov in coverage_list:
|
|
228
|
+
meta = dict(base_meta)
|
|
229
|
+
if cov.time_min:
|
|
230
|
+
meta["time_min"] = cov.time_min
|
|
231
|
+
if cov.time_max:
|
|
232
|
+
meta["time_max"] = cov.time_max
|
|
233
|
+
self._chunk_manager.record_span(
|
|
234
|
+
product=product,
|
|
235
|
+
run_id=run_id,
|
|
236
|
+
batch_id=batch.batch_id,
|
|
237
|
+
group=cov.group,
|
|
238
|
+
status="active",
|
|
239
|
+
coverage=cov,
|
|
240
|
+
meta=meta,
|
|
241
|
+
)
|
|
242
|
+
|
|
243
|
+
def record_batch_failure(
|
|
244
|
+
self,
|
|
245
|
+
ctx: PluginContext,
|
|
246
|
+
batch: PipelineBatch,
|
|
247
|
+
error: str | None,
|
|
248
|
+
slice_meta: dict[str, Any],
|
|
249
|
+
run_id: str,
|
|
250
|
+
product: str,
|
|
251
|
+
) -> None:
|
|
252
|
+
"""Record one failed batch as failed span records."""
|
|
253
|
+
_ = ctx
|
|
254
|
+
self._log.error("Batch %s failed: %s", batch.batch_id, error)
|
|
255
|
+
|
|
256
|
+
base_meta = dict(slice_meta)
|
|
257
|
+
base_meta["run_id"] = run_id
|
|
258
|
+
for group in batch.groups or ["unknown"]:
|
|
259
|
+
self._chunk_manager.record_span(
|
|
260
|
+
product=product,
|
|
261
|
+
run_id=run_id,
|
|
262
|
+
batch_id=batch.batch_id,
|
|
263
|
+
group=group,
|
|
264
|
+
status="failed",
|
|
265
|
+
reason=str(error or "Unknown error"),
|
|
266
|
+
meta=base_meta,
|
|
267
|
+
)
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
def _parse_iso8601(value: str | None) -> datetime | None:
|
|
271
|
+
if not value:
|
|
272
|
+
return None
|
|
273
|
+
try:
|
|
274
|
+
if value.endswith("Z"):
|
|
275
|
+
value = value[:-1] + "+00:00"
|
|
276
|
+
return datetime.fromisoformat(value)
|
|
277
|
+
except Exception:
|
|
278
|
+
return None
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
def _time_bounds_from_coverage(
|
|
282
|
+
coverage: list[SpanCoverage] | None,
|
|
283
|
+
) -> tuple[str | None, str | None]:
|
|
284
|
+
if not coverage:
|
|
285
|
+
return None, None
|
|
286
|
+
|
|
287
|
+
min_dt: datetime | None = None
|
|
288
|
+
max_dt: datetime | None = None
|
|
289
|
+
for cov in coverage:
|
|
290
|
+
cov_min = _parse_iso8601(cov.time_min)
|
|
291
|
+
cov_max = _parse_iso8601(cov.time_max)
|
|
292
|
+
if cov_min and (min_dt is None or cov_min < min_dt):
|
|
293
|
+
min_dt = cov_min
|
|
294
|
+
if cov_max and (max_dt is None or cov_max > max_dt):
|
|
295
|
+
max_dt = cov_max
|
|
296
|
+
return (min_dt.isoformat() if min_dt else None, max_dt.isoformat() if max_dt else None)
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
def _list_prior_active_spans_for_replacement(
|
|
300
|
+
chunk_manager: ChunkManager,
|
|
301
|
+
*,
|
|
302
|
+
product: str,
|
|
303
|
+
run_id: str,
|
|
304
|
+
slice_meta: dict[str, Any],
|
|
305
|
+
) -> list[Any]:
|
|
306
|
+
"""Return current active spans for the same slice, excluding this run."""
|
|
307
|
+
query_meta = {
|
|
308
|
+
key: value
|
|
309
|
+
for key, value in slice_meta.items()
|
|
310
|
+
if key not in {"run_id", "time_min", "time_max"}
|
|
311
|
+
}
|
|
312
|
+
time_min = slice_meta.get("time_min")
|
|
313
|
+
time_max = slice_meta.get("time_max")
|
|
314
|
+
|
|
315
|
+
query_kwargs: dict[str, Any] = {
|
|
316
|
+
"product": product,
|
|
317
|
+
"chunk_type": "span",
|
|
318
|
+
"include_replaced": False,
|
|
319
|
+
}
|
|
320
|
+
if query_meta:
|
|
321
|
+
query_kwargs["meta"] = query_meta
|
|
322
|
+
if time_min and time_max:
|
|
323
|
+
query_kwargs["time_overlaps"] = (str(time_min), str(time_max))
|
|
324
|
+
|
|
325
|
+
return [
|
|
326
|
+
span
|
|
327
|
+
for span in chunk_manager.list_chunks(**query_kwargs)
|
|
328
|
+
if str((span.meta or {}).get("run_id", "") or "") != run_id
|
|
329
|
+
]
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
def _rewrite_terminal_run_metadata(
|
|
333
|
+
chunk_manager: ChunkManager,
|
|
334
|
+
*,
|
|
335
|
+
product: str,
|
|
336
|
+
run_id: str,
|
|
337
|
+
) -> None:
|
|
338
|
+
"""Re-finalize run metadata after post-terminal WAL appends."""
|
|
339
|
+
repo = chunk_manager.repo
|
|
340
|
+
writer = repo._writer(product, run_id, resume_existing=True)
|
|
341
|
+
writer.finalize(status="complete")
|
|
342
|
+
repo._writers.pop((product, run_id), None)
|
|
343
|
+
|
|
344
|
+
|
|
345
|
+
def _coverage_from_nested_mapping(metrics: Any) -> Any | None:
|
|
346
|
+
"""Locate span coverage in a metrics mapping.
|
|
347
|
+
|
|
348
|
+
Checks the batch-level top-level ``coverage`` key first, then the run-level
|
|
349
|
+
``zarr.coverage`` / ``pipeline.coverage`` nested locations that
|
|
350
|
+
:func:`merge_batch_metrics` and the pipeline summary populate. Works for
|
|
351
|
+
both plain dicts and the mapping-compatible :class:`ResultMetrics`.
|
|
352
|
+
"""
|
|
353
|
+
coverage = metrics.get("coverage")
|
|
354
|
+
if coverage:
|
|
355
|
+
return coverage
|
|
356
|
+
for key in ("zarr", "pipeline"):
|
|
357
|
+
candidate = metrics.get(key)
|
|
358
|
+
if isinstance(candidate, dict):
|
|
359
|
+
nested = candidate.get("coverage")
|
|
360
|
+
if nested:
|
|
361
|
+
return nested
|
|
362
|
+
return None
|
|
363
|
+
|
|
364
|
+
|
|
365
|
+
def _span_coverage_from_metrics(
|
|
366
|
+
metrics: ResultMetrics | dict[str, Any] | None,
|
|
367
|
+
*,
|
|
368
|
+
logger: logging.Logger | None = None,
|
|
369
|
+
context: str = "batch",
|
|
370
|
+
) -> list[SpanCoverage] | None:
|
|
371
|
+
"""Extract SpanCoverage objects from typed result metrics."""
|
|
372
|
+
coverage: Any | None = None
|
|
373
|
+
if metrics is None:
|
|
374
|
+
coverage = None
|
|
375
|
+
elif isinstance(metrics, ResultMetrics):
|
|
376
|
+
coverage = metrics.pipeline.coverage if metrics.pipeline else None
|
|
377
|
+
if not coverage:
|
|
378
|
+
# Run-level aggregation (merge_batch_metrics) stores coverage under
|
|
379
|
+
# ``metrics["zarr"]["coverage"]``; the typed ``pipeline.coverage``
|
|
380
|
+
# only carries per-batch coverage and is empty for run-level metrics
|
|
381
|
+
# (the pipeline field holds the run summary, which has no coverage).
|
|
382
|
+
# Fall back to the nested location so run registration sees it.
|
|
383
|
+
coverage = _coverage_from_nested_mapping(metrics)
|
|
384
|
+
elif isinstance(metrics, dict):
|
|
385
|
+
coverage = _coverage_from_nested_mapping(metrics)
|
|
386
|
+
|
|
387
|
+
if not coverage:
|
|
388
|
+
if logger:
|
|
389
|
+
logger.debug(
|
|
390
|
+
"No span coverage present in metrics during %s.",
|
|
391
|
+
context,
|
|
392
|
+
)
|
|
393
|
+
return None
|
|
394
|
+
|
|
395
|
+
spans: list[SpanCoverage] = []
|
|
396
|
+
for item in coverage:
|
|
397
|
+
if isinstance(item, SpanCoverage):
|
|
398
|
+
spans.append(item)
|
|
399
|
+
elif isinstance(item, dict) and "group" in item:
|
|
400
|
+
spans.append(
|
|
401
|
+
SpanCoverage(
|
|
402
|
+
group=item["group"],
|
|
403
|
+
arrays=list(item.get("arrays", [])),
|
|
404
|
+
time_index_ranges=(
|
|
405
|
+
list(item["time_index_ranges"]) if "time_index_ranges" in item else None
|
|
406
|
+
),
|
|
407
|
+
aligned=bool(item.get("aligned", True)),
|
|
408
|
+
state_array=item.get("state_array"),
|
|
409
|
+
state_deleted_value=int(item.get("state_deleted_value", 2)),
|
|
410
|
+
time_min=item.get("time_min"),
|
|
411
|
+
time_max=item.get("time_max"),
|
|
412
|
+
region_spec=item.get("region_spec"),
|
|
413
|
+
write_strategy=item.get("write_strategy"),
|
|
414
|
+
time_dim_name=item.get("time_dim_name"),
|
|
415
|
+
)
|
|
416
|
+
)
|
|
417
|
+
if spans:
|
|
418
|
+
return spans
|
|
419
|
+
|
|
420
|
+
if logger:
|
|
421
|
+
logger.debug(
|
|
422
|
+
"Coverage payload during %s contained no usable span records.",
|
|
423
|
+
context,
|
|
424
|
+
)
|
|
425
|
+
return None
|