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,990 @@
|
|
|
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
|
+
"""ChunkManager facade for Firecube chunk state under the `.firecube/` control-plane root."""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
# pyright: reportCallIssue=false, reportAttributeAccessIssue=false
|
|
20
|
+
import json
|
|
21
|
+
import logging
|
|
22
|
+
import random
|
|
23
|
+
import time
|
|
24
|
+
from collections.abc import Callable, Generator, Iterable
|
|
25
|
+
from datetime import UTC, datetime
|
|
26
|
+
from pathlib import Path
|
|
27
|
+
from typing import Any
|
|
28
|
+
|
|
29
|
+
from firecube.core.config import StorageConfig
|
|
30
|
+
from firecube.core.controlplane.deletion import DeletionEngine
|
|
31
|
+
from firecube.core.controlplane.repo import ManifestRepository
|
|
32
|
+
from firecube.core.controlplane.types import (
|
|
33
|
+
EVENT_SLOT_INDEX_MODEL_RECORDED,
|
|
34
|
+
EVENT_SLOT_INDEX_MODEL_VERIFIED,
|
|
35
|
+
SLOT_INDEX_CURRENT_FILENAME,
|
|
36
|
+
SLOT_INDEX_DIRNAME,
|
|
37
|
+
AbandonSweepResult,
|
|
38
|
+
ChunkInfo,
|
|
39
|
+
ClaimInfo,
|
|
40
|
+
ClearSweepResult,
|
|
41
|
+
DeletionPlan,
|
|
42
|
+
RunInfo,
|
|
43
|
+
SlotIndexModelRecord,
|
|
44
|
+
SpanCoverage,
|
|
45
|
+
WriteDomain,
|
|
46
|
+
)
|
|
47
|
+
from firecube.core.errors import (
|
|
48
|
+
ClaimConflictError,
|
|
49
|
+
ManifestError,
|
|
50
|
+
SlotIndexModelClaimTimeoutError,
|
|
51
|
+
SlotIndexModelConflictError,
|
|
52
|
+
SlotIndexUnmanagedStoreError,
|
|
53
|
+
)
|
|
54
|
+
from firecube.core.filesystem import StorageFilesystem
|
|
55
|
+
from firecube.core.slot_index import (
|
|
56
|
+
SLOT_INDEX_MODEL_ATTR,
|
|
57
|
+
SLOT_INDEX_MODEL_IDENTITY_HASH_ATTR,
|
|
58
|
+
SlotIndexModel,
|
|
59
|
+
)
|
|
60
|
+
from firecube.core.storage.binding import StorageBinding
|
|
61
|
+
|
|
62
|
+
log = logging.getLogger(__name__)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _format_duration(seconds: float) -> str:
|
|
66
|
+
if seconds < 60:
|
|
67
|
+
return f"{seconds:.0f}s"
|
|
68
|
+
if seconds < 3600:
|
|
69
|
+
return f"{seconds / 60:.0f}m"
|
|
70
|
+
if seconds < 86400:
|
|
71
|
+
return f"{seconds / 3600:.0f}h {(seconds % 3600) / 60:.0f}m"
|
|
72
|
+
return f"{seconds / 86400:.0f}d {(seconds % 86400) / 3600:.0f}h"
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _slice_dedupe_key(product: str, span_meta: dict[str, Any]) -> tuple[str, Any, Any, Any] | None:
|
|
76
|
+
group = span_meta.get("group")
|
|
77
|
+
t_min = span_meta.get("time_min")
|
|
78
|
+
t_max = span_meta.get("time_max")
|
|
79
|
+
if group is None or t_min is None or t_max is None:
|
|
80
|
+
return None
|
|
81
|
+
return (product, group, t_min, t_max)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _dedupe_active_spans(chunks: list[ChunkInfo]) -> list[ChunkInfo]:
|
|
85
|
+
"""Drop duplicate active spans that cover the same slice during force-reingest in-flight.
|
|
86
|
+
|
|
87
|
+
During the brief window when a force-reingest run has emitted its new
|
|
88
|
+
span (``status="active"``) but has not yet emitted
|
|
89
|
+
``replacement_committed`` for the prior span, both spans appear active.
|
|
90
|
+
To avoid showing double coverage in the read models, this helper keeps
|
|
91
|
+
the span with the highest ``run_id`` per ``(product, group, time_min,
|
|
92
|
+
time_max)`` key and drops the others. ``run_id`` values are
|
|
93
|
+
UUID/timestamp strings, so lexicographic comparison is deterministic.
|
|
94
|
+
|
|
95
|
+
Non-span chunks and spans whose status is not ``"active"`` pass through
|
|
96
|
+
unchanged. Active spans missing any of ``group``/``time_min``/
|
|
97
|
+
``time_max`` in their meta also pass through (they cannot be grouped
|
|
98
|
+
into a slice key).
|
|
99
|
+
"""
|
|
100
|
+
winners: dict[tuple[str, Any, Any, Any], str] = {}
|
|
101
|
+
for chunk in chunks:
|
|
102
|
+
if chunk.chunk_type != "span" or chunk.status != "active":
|
|
103
|
+
continue
|
|
104
|
+
meta = chunk.meta or {}
|
|
105
|
+
key = _slice_dedupe_key(chunk.product, meta)
|
|
106
|
+
if key is None:
|
|
107
|
+
continue
|
|
108
|
+
run_id = str(meta.get("run_id", ""))
|
|
109
|
+
if winners.get(key, "") < run_id:
|
|
110
|
+
winners[key] = run_id
|
|
111
|
+
|
|
112
|
+
deduped: list[ChunkInfo] = []
|
|
113
|
+
for chunk in chunks:
|
|
114
|
+
if chunk.chunk_type != "span" or chunk.status != "active":
|
|
115
|
+
deduped.append(chunk)
|
|
116
|
+
continue
|
|
117
|
+
meta = chunk.meta or {}
|
|
118
|
+
key = _slice_dedupe_key(chunk.product, meta)
|
|
119
|
+
if key is None:
|
|
120
|
+
deduped.append(chunk)
|
|
121
|
+
continue
|
|
122
|
+
if winners.get(key) == str(meta.get("run_id", "")):
|
|
123
|
+
deduped.append(chunk)
|
|
124
|
+
return deduped
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
class ChunkManager:
|
|
128
|
+
"""Public facade for managing Firecube chunk state."""
|
|
129
|
+
|
|
130
|
+
__slots__ = ("_filesystem", "deletion_engine", "log", "product_name", "repo", "workspace")
|
|
131
|
+
|
|
132
|
+
def __init__(
|
|
133
|
+
self,
|
|
134
|
+
binding: StorageBinding,
|
|
135
|
+
workspace: Path | None = None,
|
|
136
|
+
*,
|
|
137
|
+
filesystem: StorageFilesystem | None = None,
|
|
138
|
+
time_dim_name: str = "timestamp",
|
|
139
|
+
) -> None:
|
|
140
|
+
import tempfile
|
|
141
|
+
|
|
142
|
+
if workspace is None:
|
|
143
|
+
workspace = Path(tempfile.mkdtemp(prefix="firecube_chunks_"))
|
|
144
|
+
|
|
145
|
+
self.workspace = workspace
|
|
146
|
+
self._filesystem = filesystem
|
|
147
|
+
self.repo = ManifestRepository(
|
|
148
|
+
binding=binding,
|
|
149
|
+
workspace=workspace,
|
|
150
|
+
filesystem=filesystem,
|
|
151
|
+
)
|
|
152
|
+
self.log = logging.getLogger(f"{__name__}.ChunkManager")
|
|
153
|
+
self.deletion_engine = DeletionEngine(
|
|
154
|
+
self.repo, filesystem=filesystem, time_dim_name=time_dim_name
|
|
155
|
+
)
|
|
156
|
+
|
|
157
|
+
@property
|
|
158
|
+
def base_uri(self) -> str:
|
|
159
|
+
"""Current base URI for product storage."""
|
|
160
|
+
return self.repo.base_uri
|
|
161
|
+
|
|
162
|
+
@property
|
|
163
|
+
def storage_config(self) -> Any | None:
|
|
164
|
+
"""Current storage configuration (e.g. S3 credentials)."""
|
|
165
|
+
return self.repo.storage_config
|
|
166
|
+
|
|
167
|
+
def get_product_root(self, product: str) -> str:
|
|
168
|
+
"""Return the resolved product root URI."""
|
|
169
|
+
return self.repo.get_product_root_uri(product)
|
|
170
|
+
|
|
171
|
+
def get_control_root(self, product: str) -> str:
|
|
172
|
+
"""Return the resolved .firecube/ control root URI."""
|
|
173
|
+
return self.repo.get_control_root_uri(product)
|
|
174
|
+
|
|
175
|
+
def get_latest_pointer(self, product: str) -> str:
|
|
176
|
+
"""Return the resolved LATEST.json pointer URI."""
|
|
177
|
+
return self.repo.get_latest_pointer_uri(product)
|
|
178
|
+
|
|
179
|
+
def close(self) -> None:
|
|
180
|
+
"""Close the underlying repository, flushing writers and releasing resources."""
|
|
181
|
+
if hasattr(self, "repo"):
|
|
182
|
+
self.repo.close()
|
|
183
|
+
|
|
184
|
+
def record_run_started(
|
|
185
|
+
self,
|
|
186
|
+
*,
|
|
187
|
+
product: str,
|
|
188
|
+
run_id: str,
|
|
189
|
+
output_path: str,
|
|
190
|
+
output_format: str,
|
|
191
|
+
size: int,
|
|
192
|
+
meta: dict[str, Any],
|
|
193
|
+
slot_range: tuple[int, int] | None = None,
|
|
194
|
+
slot_group: str | None = None,
|
|
195
|
+
) -> None:
|
|
196
|
+
"""Record the start of a new ingestion run."""
|
|
197
|
+
self.repo.record_run_started(
|
|
198
|
+
product=product,
|
|
199
|
+
run_id=run_id,
|
|
200
|
+
output_path=output_path,
|
|
201
|
+
output_format=output_format,
|
|
202
|
+
size=size,
|
|
203
|
+
meta=meta,
|
|
204
|
+
slot_range=slot_range,
|
|
205
|
+
slot_group=slot_group,
|
|
206
|
+
)
|
|
207
|
+
|
|
208
|
+
def record_run_terminal(
|
|
209
|
+
self,
|
|
210
|
+
*,
|
|
211
|
+
product: str,
|
|
212
|
+
run_id: str,
|
|
213
|
+
output_path: str,
|
|
214
|
+
output_format: str,
|
|
215
|
+
size: int,
|
|
216
|
+
meta: dict[str, Any],
|
|
217
|
+
status: str,
|
|
218
|
+
error: str | None = None,
|
|
219
|
+
slot_range: tuple[int, int] | None = None,
|
|
220
|
+
slot_group: str | None = None,
|
|
221
|
+
) -> None:
|
|
222
|
+
"""Record a terminal run state (complete/failed/abandoned)."""
|
|
223
|
+
self.repo.record_run_terminal(
|
|
224
|
+
product=product,
|
|
225
|
+
run_id=run_id,
|
|
226
|
+
output_path=output_path,
|
|
227
|
+
output_format=output_format,
|
|
228
|
+
size=size,
|
|
229
|
+
meta=meta,
|
|
230
|
+
status=status,
|
|
231
|
+
error=error,
|
|
232
|
+
slot_range=slot_range,
|
|
233
|
+
slot_group=slot_group,
|
|
234
|
+
)
|
|
235
|
+
|
|
236
|
+
def record_run_started_with_replacement(
|
|
237
|
+
self,
|
|
238
|
+
*,
|
|
239
|
+
product: str,
|
|
240
|
+
run_id: str,
|
|
241
|
+
replaces: list[str],
|
|
242
|
+
) -> None:
|
|
243
|
+
"""Record informational replacement intent for a run."""
|
|
244
|
+
self.repo.record_run_started_with_replacement(
|
|
245
|
+
product=product,
|
|
246
|
+
run_id=run_id,
|
|
247
|
+
replaces=replaces,
|
|
248
|
+
)
|
|
249
|
+
|
|
250
|
+
def record_replacement_committed(
|
|
251
|
+
self,
|
|
252
|
+
*,
|
|
253
|
+
product: str,
|
|
254
|
+
run_id: str,
|
|
255
|
+
replacing_run_id: str,
|
|
256
|
+
replaced_span_keys: list[str],
|
|
257
|
+
) -> None:
|
|
258
|
+
"""Record terminal replacement commit for prior spans."""
|
|
259
|
+
self.repo.record_replacement_committed(
|
|
260
|
+
product=product,
|
|
261
|
+
run_id=run_id,
|
|
262
|
+
replacing_run_id=replacing_run_id,
|
|
263
|
+
replaced_span_keys=replaced_span_keys,
|
|
264
|
+
)
|
|
265
|
+
|
|
266
|
+
def record_maintenance_started(
|
|
267
|
+
self,
|
|
268
|
+
*,
|
|
269
|
+
product: str,
|
|
270
|
+
run_id: str,
|
|
271
|
+
op: str,
|
|
272
|
+
scope_meta: dict[str, Any] | None = None,
|
|
273
|
+
) -> None:
|
|
274
|
+
"""Record the start of a maintenance run (delete/scrub/archive_restore)."""
|
|
275
|
+
self.repo.record_maintenance_started(
|
|
276
|
+
product=product,
|
|
277
|
+
run_id=run_id,
|
|
278
|
+
op=op,
|
|
279
|
+
scope_meta=scope_meta,
|
|
280
|
+
)
|
|
281
|
+
|
|
282
|
+
def record_maintenance_completed(
|
|
283
|
+
self,
|
|
284
|
+
*,
|
|
285
|
+
product: str,
|
|
286
|
+
run_id: str,
|
|
287
|
+
op: str,
|
|
288
|
+
scope_meta: dict[str, Any] | None = None,
|
|
289
|
+
) -> None:
|
|
290
|
+
"""Record terminal completion of a maintenance run."""
|
|
291
|
+
self.repo.record_maintenance_completed(
|
|
292
|
+
product=product,
|
|
293
|
+
run_id=run_id,
|
|
294
|
+
op=op,
|
|
295
|
+
scope_meta=scope_meta,
|
|
296
|
+
)
|
|
297
|
+
|
|
298
|
+
def record_maintenance_failed(
|
|
299
|
+
self,
|
|
300
|
+
*,
|
|
301
|
+
product: str,
|
|
302
|
+
run_id: str,
|
|
303
|
+
op: str,
|
|
304
|
+
scope_meta: dict[str, Any] | None = None,
|
|
305
|
+
error: str,
|
|
306
|
+
) -> None:
|
|
307
|
+
"""Record terminal failure of a maintenance run."""
|
|
308
|
+
self.repo.record_maintenance_failed(
|
|
309
|
+
product=product,
|
|
310
|
+
run_id=run_id,
|
|
311
|
+
op=op,
|
|
312
|
+
scope_meta=scope_meta,
|
|
313
|
+
error=error,
|
|
314
|
+
)
|
|
315
|
+
|
|
316
|
+
def record_run_failed(
|
|
317
|
+
self,
|
|
318
|
+
*,
|
|
319
|
+
product: str,
|
|
320
|
+
run_id: str,
|
|
321
|
+
output_path: str,
|
|
322
|
+
output_format: str,
|
|
323
|
+
size: int,
|
|
324
|
+
meta: dict[str, Any],
|
|
325
|
+
error: str,
|
|
326
|
+
) -> None:
|
|
327
|
+
"""Record a failed run (convenience wrapper for record_run_terminal with status=failed)."""
|
|
328
|
+
self.repo.record_run_terminal(
|
|
329
|
+
product=product,
|
|
330
|
+
run_id=run_id,
|
|
331
|
+
output_path=output_path,
|
|
332
|
+
output_format=output_format,
|
|
333
|
+
size=size,
|
|
334
|
+
meta=meta,
|
|
335
|
+
status="failed",
|
|
336
|
+
error=error,
|
|
337
|
+
)
|
|
338
|
+
|
|
339
|
+
def record_span(
|
|
340
|
+
self,
|
|
341
|
+
product: str,
|
|
342
|
+
run_id: str,
|
|
343
|
+
batch_id: str,
|
|
344
|
+
group: str,
|
|
345
|
+
status: str,
|
|
346
|
+
reason: str | None = None,
|
|
347
|
+
coverage: SpanCoverage | None = None,
|
|
348
|
+
meta: dict[str, Any] | None = None,
|
|
349
|
+
) -> None:
|
|
350
|
+
"""Record a span event (chunk write or state change) within a run."""
|
|
351
|
+
self.repo.record_span_event(
|
|
352
|
+
product=product,
|
|
353
|
+
run_id=run_id,
|
|
354
|
+
batch_id=batch_id,
|
|
355
|
+
group=group,
|
|
356
|
+
status=status,
|
|
357
|
+
reason=reason,
|
|
358
|
+
coverage=coverage,
|
|
359
|
+
meta=meta,
|
|
360
|
+
)
|
|
361
|
+
|
|
362
|
+
def record_schema_verification(
|
|
363
|
+
self,
|
|
364
|
+
product: str,
|
|
365
|
+
run_id: str,
|
|
366
|
+
group: str,
|
|
367
|
+
plugin: str,
|
|
368
|
+
schema_hash: str,
|
|
369
|
+
verified_at: str,
|
|
370
|
+
expected_time_count: int,
|
|
371
|
+
meta: dict[str, Any] | None = None,
|
|
372
|
+
) -> None:
|
|
373
|
+
"""Record a schema verification audit event for observability.
|
|
374
|
+
|
|
375
|
+
Audit records are NOT consulted for skip logic. Each pod writes its own
|
|
376
|
+
record (verified_at differs).
|
|
377
|
+
"""
|
|
378
|
+
self.repo.record_schema_verification_event(
|
|
379
|
+
product=product,
|
|
380
|
+
run_id=run_id,
|
|
381
|
+
group=group,
|
|
382
|
+
plugin=plugin,
|
|
383
|
+
schema_hash=schema_hash,
|
|
384
|
+
verified_at=verified_at,
|
|
385
|
+
expected_time_count=expected_time_count,
|
|
386
|
+
meta=meta,
|
|
387
|
+
)
|
|
388
|
+
|
|
389
|
+
def discover_manifests(self) -> list[str]:
|
|
390
|
+
"""Scan the workspace for products with control-plane roots."""
|
|
391
|
+
return self.repo.discover_manifests()
|
|
392
|
+
|
|
393
|
+
def parse_manifest(self, manifest_uri: str) -> Generator[ChunkInfo, None, None]:
|
|
394
|
+
"""Yield ChunkInfo records from a manifest URI."""
|
|
395
|
+
return self.repo.parse_manifest(manifest_uri)
|
|
396
|
+
|
|
397
|
+
def list_chunks(
|
|
398
|
+
self,
|
|
399
|
+
pattern: str | None = None,
|
|
400
|
+
product: str | None = None,
|
|
401
|
+
before: datetime | None = None,
|
|
402
|
+
after: datetime | None = None,
|
|
403
|
+
chunk_type: str | None = None,
|
|
404
|
+
status: str | None = None,
|
|
405
|
+
include_replaced: bool = False,
|
|
406
|
+
*,
|
|
407
|
+
meta: dict[str, Any] | None = None,
|
|
408
|
+
time_min_after: str | None = None,
|
|
409
|
+
time_max_before: str | None = None,
|
|
410
|
+
time_overlaps: tuple[str, str] | None = None,
|
|
411
|
+
filter_fn: Callable[[ChunkInfo], bool] | None = None,
|
|
412
|
+
) -> list[ChunkInfo]:
|
|
413
|
+
"""Return projected chunk records with optional filtering.
|
|
414
|
+
|
|
415
|
+
During a force-reingest run there is a brief window where both prior
|
|
416
|
+
spans and new spans have ``status="active"`` (before
|
|
417
|
+
``replacement_committed`` lands). To prevent double coverage in
|
|
418
|
+
callers, this method deduplicates active spans by
|
|
419
|
+
``(product, group, time_min, time_max)``: when multiple active spans
|
|
420
|
+
share the same slice key, only the span with the highest ``run_id``
|
|
421
|
+
(lexicographic order — UUID/timestamp run_ids make this
|
|
422
|
+
deterministic) is returned. Non-span records and
|
|
423
|
+
``replaced``/``failed`` spans pass through unchanged, so this is a
|
|
424
|
+
no-op outside the force-reingest in-flight window.
|
|
425
|
+
"""
|
|
426
|
+
chunks = self.repo.list_chunks(
|
|
427
|
+
pattern=pattern,
|
|
428
|
+
product=product,
|
|
429
|
+
before=before,
|
|
430
|
+
after=after,
|
|
431
|
+
chunk_type=chunk_type,
|
|
432
|
+
status=status,
|
|
433
|
+
include_replaced=include_replaced,
|
|
434
|
+
meta=meta,
|
|
435
|
+
time_min_after=time_min_after,
|
|
436
|
+
time_max_before=time_max_before,
|
|
437
|
+
time_overlaps=time_overlaps,
|
|
438
|
+
filter_fn=filter_fn,
|
|
439
|
+
)
|
|
440
|
+
return _dedupe_active_spans(chunks)
|
|
441
|
+
|
|
442
|
+
def mark_chunks_replaced(
|
|
443
|
+
self, chunk_keys: list[str], product: str, timestamp: float
|
|
444
|
+
) -> dict[str, Any]:
|
|
445
|
+
"""Mark chunk records as replaced with a timestamp."""
|
|
446
|
+
return self.repo.mark_chunks_replaced(chunk_keys, product, timestamp)
|
|
447
|
+
|
|
448
|
+
def list_runs(
|
|
449
|
+
self,
|
|
450
|
+
*,
|
|
451
|
+
product: str,
|
|
452
|
+
status: str | None = None,
|
|
453
|
+
non_terminal: bool = False,
|
|
454
|
+
) -> list[RunInfo]:
|
|
455
|
+
"""List runs for a product with optional status/terminal filtering."""
|
|
456
|
+
return self.repo.list_runs(product=product, status=status, non_terminal=non_terminal)
|
|
457
|
+
|
|
458
|
+
def time_coverage_summary(
|
|
459
|
+
self, product: str, *, meta: dict[str, Any] | None = None
|
|
460
|
+
) -> list[dict[str, Any]]:
|
|
461
|
+
"""Return per-group time range boundaries and span counts for a product.
|
|
462
|
+
|
|
463
|
+
Each entry is ``{"group": str, "time_min": str, "time_max": str,
|
|
464
|
+
"span_count": int, "total_timestamps_written": int}``, sorted by group.
|
|
465
|
+
Used by ``ResumeGuard`` for overlap detection and by operators for
|
|
466
|
+
coverage diagnostics.
|
|
467
|
+
|
|
468
|
+
Inherits the active-span dedupe from :meth:`list_chunks`: during a
|
|
469
|
+
force-reingest in-flight window, only the highest-``run_id`` active
|
|
470
|
+
span per slice contributes to the per-group totals, so coverage is
|
|
471
|
+
not double-counted.
|
|
472
|
+
"""
|
|
473
|
+
spans = self.list_chunks(
|
|
474
|
+
product=product, chunk_type="span", include_replaced=False, meta=meta
|
|
475
|
+
)
|
|
476
|
+
groups: dict[str, dict[str, Any]] = {}
|
|
477
|
+
for span in spans:
|
|
478
|
+
span_meta = span.meta or {}
|
|
479
|
+
group = span_meta.get("group", "unknown")
|
|
480
|
+
t_min = span_meta.get("time_min")
|
|
481
|
+
t_max = span_meta.get("time_max")
|
|
482
|
+
tw = 0
|
|
483
|
+
if isinstance(span.record, dict):
|
|
484
|
+
span_data = span.record.get("span", {})
|
|
485
|
+
if isinstance(span_data, dict):
|
|
486
|
+
tw = int(span_data.get("timestamps_written", 0))
|
|
487
|
+
|
|
488
|
+
if group not in groups:
|
|
489
|
+
groups[group] = {
|
|
490
|
+
"group": group,
|
|
491
|
+
"time_min": t_min,
|
|
492
|
+
"time_max": t_max,
|
|
493
|
+
"span_count": 0,
|
|
494
|
+
"total_timestamps_written": 0,
|
|
495
|
+
}
|
|
496
|
+
entry = groups[group]
|
|
497
|
+
entry["span_count"] += 1
|
|
498
|
+
entry["total_timestamps_written"] += tw
|
|
499
|
+
if t_min and (entry["time_min"] is None or t_min < entry["time_min"]):
|
|
500
|
+
entry["time_min"] = t_min
|
|
501
|
+
if t_max and (entry["time_max"] is None or t_max > entry["time_max"]):
|
|
502
|
+
entry["time_max"] = t_max
|
|
503
|
+
return sorted(groups.values(), key=lambda x: x["group"])
|
|
504
|
+
|
|
505
|
+
def abandon_run(
|
|
506
|
+
self,
|
|
507
|
+
*,
|
|
508
|
+
product: str,
|
|
509
|
+
run_id: str,
|
|
510
|
+
reason: str,
|
|
511
|
+
meta: dict[str, Any] | None = None,
|
|
512
|
+
) -> dict[str, Any]:
|
|
513
|
+
"""Mark a non-terminal run as abandoned (control-plane only, no data cleanup)."""
|
|
514
|
+
return self.repo.abandon_run(
|
|
515
|
+
product=product,
|
|
516
|
+
run_id=run_id,
|
|
517
|
+
reason=reason,
|
|
518
|
+
meta=meta,
|
|
519
|
+
)
|
|
520
|
+
|
|
521
|
+
def create_deletion_plan(
|
|
522
|
+
self,
|
|
523
|
+
pattern: str | None = None,
|
|
524
|
+
product: str | None = None,
|
|
525
|
+
before: datetime | None = None,
|
|
526
|
+
after: datetime | None = None,
|
|
527
|
+
chunk_type: str | None = None,
|
|
528
|
+
status: str | None = None,
|
|
529
|
+
include_metadata: bool = False,
|
|
530
|
+
*,
|
|
531
|
+
meta: dict[str, Any] | None = None,
|
|
532
|
+
filter_fn: Callable[[ChunkInfo], bool] | None = None,
|
|
533
|
+
) -> DeletionPlan:
|
|
534
|
+
"""Build a deletion plan for chunks matching the given criteria."""
|
|
535
|
+
return self.deletion_engine.create_deletion_plan(
|
|
536
|
+
pattern,
|
|
537
|
+
product,
|
|
538
|
+
before,
|
|
539
|
+
after,
|
|
540
|
+
chunk_type,
|
|
541
|
+
status,
|
|
542
|
+
include_metadata,
|
|
543
|
+
meta=meta,
|
|
544
|
+
filter_fn=filter_fn,
|
|
545
|
+
)
|
|
546
|
+
|
|
547
|
+
def execute_deletion(
|
|
548
|
+
self,
|
|
549
|
+
plan: DeletionPlan,
|
|
550
|
+
delete_storage: bool = True,
|
|
551
|
+
delete_manifest: bool = True,
|
|
552
|
+
storage_config: StorageConfig | None = None,
|
|
553
|
+
dry_run: bool = False,
|
|
554
|
+
) -> dict[str, Any]:
|
|
555
|
+
"""Execute a deletion plan, optionally removing storage and manifest entries."""
|
|
556
|
+
return self.deletion_engine.execute_deletion(
|
|
557
|
+
plan,
|
|
558
|
+
delete_storage,
|
|
559
|
+
delete_manifest,
|
|
560
|
+
storage_config,
|
|
561
|
+
dry_run,
|
|
562
|
+
)
|
|
563
|
+
|
|
564
|
+
def delete_spans(
|
|
565
|
+
self,
|
|
566
|
+
spans: Iterable[ChunkInfo],
|
|
567
|
+
*,
|
|
568
|
+
dry_run: bool = False,
|
|
569
|
+
force: bool = False,
|
|
570
|
+
update_manifest: bool = True,
|
|
571
|
+
update_state: bool = True,
|
|
572
|
+
time_dim_name: str | None = None,
|
|
573
|
+
) -> dict[str, Any]:
|
|
574
|
+
"""Delete specific span records with optional dry-run and manifest updates."""
|
|
575
|
+
return self.deletion_engine.delete_spans(
|
|
576
|
+
spans,
|
|
577
|
+
dry_run=dry_run,
|
|
578
|
+
force=force,
|
|
579
|
+
update_manifest=update_manifest,
|
|
580
|
+
update_state=update_state,
|
|
581
|
+
time_dim_name=time_dim_name,
|
|
582
|
+
)
|
|
583
|
+
|
|
584
|
+
def rebuild_snapshot(self, product: str) -> dict[str, Any]:
|
|
585
|
+
"""Compact completed-run WAL events into a snapshot for faster reads."""
|
|
586
|
+
return self.repo.rebuild_snapshot(product)
|
|
587
|
+
|
|
588
|
+
def snapshot_status(self, product: str) -> dict[str, Any]:
|
|
589
|
+
self.repo._ensure_bound()
|
|
590
|
+
latest = self.repo._read_latest_pointer(product)
|
|
591
|
+
if latest is None:
|
|
592
|
+
return {"exists": False}
|
|
593
|
+
|
|
594
|
+
cutoff = float(latest.get("completed_before", 0.0) or 0.0)
|
|
595
|
+
age_seconds = time.time() - cutoff if cutoff > 0 else 0.0
|
|
596
|
+
completed_before_iso: str | None = None
|
|
597
|
+
if cutoff > 0:
|
|
598
|
+
completed_before_iso = datetime.fromtimestamp(cutoff, tz=UTC).isoformat()
|
|
599
|
+
|
|
600
|
+
snapshot_meta_path = str(latest.get("snapshot_meta_path") or "")
|
|
601
|
+
records = 0
|
|
602
|
+
repo_fs = self.repo._fs
|
|
603
|
+
if snapshot_meta_path and repo_fs is not None:
|
|
604
|
+
try:
|
|
605
|
+
with repo_fs.open(snapshot_meta_path, "r") as fh:
|
|
606
|
+
meta = json.load(fh)
|
|
607
|
+
records = int(meta.get("records", meta.get("record_count", 0)))
|
|
608
|
+
except Exception:
|
|
609
|
+
records = 0
|
|
610
|
+
|
|
611
|
+
return {
|
|
612
|
+
"exists": True,
|
|
613
|
+
"completed_before": completed_before_iso,
|
|
614
|
+
"age_seconds": age_seconds,
|
|
615
|
+
"age_human": _format_duration(age_seconds),
|
|
616
|
+
"generation": latest.get("generation"),
|
|
617
|
+
"records": records,
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
def acquire_claim(self, *, product: str, domain: WriteDomain, owner_id: str):
|
|
621
|
+
"""Acquire an exclusive write claim for a domain."""
|
|
622
|
+
return self.repo.acquire_claim(product=product, domain=domain, owner_id=owner_id)
|
|
623
|
+
|
|
624
|
+
def list_claims(self, *, product: str | None = None):
|
|
625
|
+
"""List active write claims, optionally filtered by product."""
|
|
626
|
+
return self.repo.list_claims(product=product)
|
|
627
|
+
|
|
628
|
+
def list_stale_claims(self, *, product: str) -> list[ClaimInfo]:
|
|
629
|
+
"""List stale write claims for one product."""
|
|
630
|
+
return self.repo.list_stale_claims(product=product)
|
|
631
|
+
|
|
632
|
+
def list_stale_runs(self, *, product: str) -> list[RunInfo]:
|
|
633
|
+
"""List stale non-terminal runs for one product."""
|
|
634
|
+
return self.repo.list_stale_runs(product=product)
|
|
635
|
+
|
|
636
|
+
def clear_claim(self, *, product: str, domain_id: str, force: bool = False) -> bool:
|
|
637
|
+
"""Release a write claim by domain ID."""
|
|
638
|
+
return self.repo.clear_claim(product=product, domain_id=domain_id, force=force)
|
|
639
|
+
|
|
640
|
+
def clear_stale_claims(self, *, product: str, dry_run: bool = True) -> ClearSweepResult:
|
|
641
|
+
"""Bulk-clear stale write claims for a product with mutation-time re-check.
|
|
642
|
+
|
|
643
|
+
Between preview (``list_stale_claims``) and mutation (``clear_claim``),
|
|
644
|
+
a live pod could refresh a previously stale claim or another operator
|
|
645
|
+
could delete it. Each mutation re-reads the current state via
|
|
646
|
+
``list_claims`` and only clears domains that remain stale AND present;
|
|
647
|
+
live claims are never forcibly overridden. Errors are collected
|
|
648
|
+
per-claim, and the sweep continues past any single-claim race.
|
|
649
|
+
"""
|
|
650
|
+
result = ClearSweepResult()
|
|
651
|
+
stale = self.list_stale_claims(product=product)
|
|
652
|
+
result.previewed = [claim.domain for claim in stale]
|
|
653
|
+
if dry_run:
|
|
654
|
+
return result
|
|
655
|
+
for claim in stale:
|
|
656
|
+
fresh = self.list_claims(product=product)
|
|
657
|
+
current = next((c for c in fresh if c.domain == claim.domain), None)
|
|
658
|
+
if current is None:
|
|
659
|
+
result.skipped_missing.append(claim.domain)
|
|
660
|
+
continue
|
|
661
|
+
if not current.stale:
|
|
662
|
+
result.skipped_fresh.append(claim.domain)
|
|
663
|
+
continue
|
|
664
|
+
try:
|
|
665
|
+
cleared = self.clear_claim(product=product, domain_id=claim.domain, force=False)
|
|
666
|
+
except ClaimConflictError:
|
|
667
|
+
result.skipped_fresh.append(claim.domain)
|
|
668
|
+
continue
|
|
669
|
+
if cleared:
|
|
670
|
+
result.cleared.append(claim.domain)
|
|
671
|
+
else:
|
|
672
|
+
result.skipped_missing.append(claim.domain)
|
|
673
|
+
return result
|
|
674
|
+
|
|
675
|
+
def abandon_stale_runs(
|
|
676
|
+
self, *, product: str, reason: str, dry_run: bool = True
|
|
677
|
+
) -> AbandonSweepResult:
|
|
678
|
+
"""Bulk-abandon stale non-terminal runs for a product with mutation-time re-check.
|
|
679
|
+
|
|
680
|
+
Between preview (``list_stale_runs``) and mutation (``abandon_run``), a
|
|
681
|
+
live pod could refresh the run's heartbeat or the run could reach a
|
|
682
|
+
terminal status. Each mutation re-reads the current state via
|
|
683
|
+
``list_runs`` and only abandons runs that remain stale AND non-terminal.
|
|
684
|
+
Runs already terminal are recorded as ``skipped_already_terminal``;
|
|
685
|
+
runs whose heartbeat refreshed are recorded as ``skipped_fresh``. The
|
|
686
|
+
sweep continues past any single-run race so one live pod cannot block
|
|
687
|
+
cleanup of unrelated stale runs.
|
|
688
|
+
"""
|
|
689
|
+
result = AbandonSweepResult()
|
|
690
|
+
stale = self.list_stale_runs(product=product)
|
|
691
|
+
result.previewed = [run.run_id for run in stale]
|
|
692
|
+
if dry_run:
|
|
693
|
+
return result
|
|
694
|
+
for run in stale:
|
|
695
|
+
fresh = self.list_runs(product=product)
|
|
696
|
+
current = next((r for r in fresh if r.run_id == run.run_id), None)
|
|
697
|
+
if current is None or current.is_terminal:
|
|
698
|
+
result.skipped_already_terminal.append(run.run_id)
|
|
699
|
+
continue
|
|
700
|
+
if not current.stale:
|
|
701
|
+
result.skipped_fresh.append(run.run_id)
|
|
702
|
+
continue
|
|
703
|
+
try:
|
|
704
|
+
outcome = self.abandon_run(product=product, run_id=run.run_id, reason=reason)
|
|
705
|
+
except ManifestError:
|
|
706
|
+
result.skipped_already_terminal.append(run.run_id)
|
|
707
|
+
continue
|
|
708
|
+
if outcome.get("abandoned"):
|
|
709
|
+
result.abandoned.append(run.run_id)
|
|
710
|
+
else:
|
|
711
|
+
result.skipped_already_terminal.append(run.run_id)
|
|
712
|
+
return result
|
|
713
|
+
|
|
714
|
+
# ------------------------------------------------------------------
|
|
715
|
+
# Slot-index model: control-plane authoritative record + attrs mirror.
|
|
716
|
+
# ------------------------------------------------------------------
|
|
717
|
+
|
|
718
|
+
def get_slot_index_model(self, *, product: str) -> SlotIndexModelRecord | None:
|
|
719
|
+
"""Read the stored slot-index model record, or ``None`` if absent.
|
|
720
|
+
|
|
721
|
+
Returns ``None`` when the on-disk ``current.json`` is missing. Other
|
|
722
|
+
I/O errors propagate. ``ManifestError`` propagates on corruption (the
|
|
723
|
+
on-disk record's identity-hash cross-check is enforced by
|
|
724
|
+
:meth:`SlotIndexModelRecord.from_json_bytes`).
|
|
725
|
+
"""
|
|
726
|
+
control_root_uri = self.repo.get_control_root_uri(product)
|
|
727
|
+
_fs, control_root = self.repo._get_fs(control_root_uri)
|
|
728
|
+
current_json = control_root.join(SLOT_INDEX_DIRNAME).join(SLOT_INDEX_CURRENT_FILENAME)
|
|
729
|
+
try:
|
|
730
|
+
with _fs.open(current_json, "rb") as fh:
|
|
731
|
+
data = fh.read()
|
|
732
|
+
except FileNotFoundError:
|
|
733
|
+
return None
|
|
734
|
+
return SlotIndexModelRecord.from_json_bytes(data)
|
|
735
|
+
|
|
736
|
+
def ensure_slot_index_model(
|
|
737
|
+
self,
|
|
738
|
+
*,
|
|
739
|
+
product: str,
|
|
740
|
+
model: SlotIndexModel,
|
|
741
|
+
run_id: str,
|
|
742
|
+
max_retries: int = 5,
|
|
743
|
+
initial_backoff_s: float = 0.1,
|
|
744
|
+
) -> SlotIndexModelRecord:
|
|
745
|
+
"""Negotiate the slot-index model for ``product``, returning the persisted record.
|
|
746
|
+
|
|
747
|
+
Acquires the ``slot_index_model:current`` write claim and dispatches into
|
|
748
|
+
:meth:`_apply_slot_model_precedence`. Loser threads (``ClaimConflictError``)
|
|
749
|
+
inspect full convergence: ``current.json`` AND the zarr root identity-hash
|
|
750
|
+
attribute must both match the declared model before the loser emits
|
|
751
|
+
``EVENT_SLOT_INDEX_MODEL_VERIFIED`` and returns. CP-only match is the race
|
|
752
|
+
window state (winner mid-write) and is NOT a convergence signal; the loop
|
|
753
|
+
keeps retrying with jittered exponential backoff until the budget is spent.
|
|
754
|
+
"""
|
|
755
|
+
if not run_id:
|
|
756
|
+
raise ValueError("run_id must be non-empty")
|
|
757
|
+
domain = WriteDomain(product=product, category="slot_index_model", name="current")
|
|
758
|
+
backoff = float(initial_backoff_s)
|
|
759
|
+
for attempt in range(int(max_retries) + 1):
|
|
760
|
+
try:
|
|
761
|
+
with self.acquire_claim(product=product, domain=domain, owner_id=run_id):
|
|
762
|
+
return self._apply_slot_model_precedence(product, model, run_id)
|
|
763
|
+
except ClaimConflictError:
|
|
764
|
+
cp_record = self.get_slot_index_model(product=product)
|
|
765
|
+
if cp_record is not None and cp_record.identity_hash != model.identity_hash:
|
|
766
|
+
raise SlotIndexModelConflictError(
|
|
767
|
+
"concurrent write detected with incompatible model: "
|
|
768
|
+
f"stored={cp_record.identity_hash[:16]} "
|
|
769
|
+
f"declared={model.identity_hash[:16]}"
|
|
770
|
+
) from None
|
|
771
|
+
if cp_record is not None:
|
|
772
|
+
attrs_hash = self._read_slot_index_attrs_hash(product=product)
|
|
773
|
+
if attrs_hash == model.identity_hash:
|
|
774
|
+
self.repo.record_slot_index_model_event(
|
|
775
|
+
product=product,
|
|
776
|
+
run_id=run_id,
|
|
777
|
+
event_type=EVENT_SLOT_INDEX_MODEL_VERIFIED,
|
|
778
|
+
identity_hash=model.identity_hash,
|
|
779
|
+
model_name=model.name,
|
|
780
|
+
)
|
|
781
|
+
return cp_record
|
|
782
|
+
if attempt == int(max_retries):
|
|
783
|
+
raise SlotIndexModelClaimTimeoutError(
|
|
784
|
+
f"slot_index_model claim held for >{max_retries} retries; "
|
|
785
|
+
"full convergence (CP+attrs) not observed within retry budget"
|
|
786
|
+
) from None
|
|
787
|
+
time.sleep(backoff + random.random() * backoff)
|
|
788
|
+
backoff *= 2
|
|
789
|
+
raise SlotIndexModelClaimTimeoutError(
|
|
790
|
+
"exhausted retries without converging on slot_index_model"
|
|
791
|
+
)
|
|
792
|
+
|
|
793
|
+
def _apply_slot_model_precedence(
|
|
794
|
+
self, product: str, model: SlotIndexModel, run_id: str
|
|
795
|
+
) -> SlotIndexModelRecord:
|
|
796
|
+
"""Apply the 6-row fresh-store precedence matrix.
|
|
797
|
+
|
|
798
|
+
Must be called INSIDE the ``slot_index_model:current`` claim. The matrix
|
|
799
|
+
is the only legitimate writer of ``current.json`` and the reserved zarr
|
|
800
|
+
root attrs. Rows are enumerated explicitly to keep the decision table
|
|
801
|
+
auditable from the source:
|
|
802
|
+
|
|
803
|
+
+-----+----+-------+--------+-------------------------------------------+
|
|
804
|
+
| Row | CP | Attrs | Plugin | Action |
|
|
805
|
+
+=====+====+=======+========+===========================================+
|
|
806
|
+
| 1 | - | - | X | Write CP + mirror attrs + emit RECORDED |
|
|
807
|
+
| 2 | X | X | X | Happy path; emit VERIFIED |
|
|
808
|
+
| 3 | X | - | X | Crash-recovery: re-mirror attrs + VERIFIED|
|
|
809
|
+
| 4 | X | Y | X | Drift detected; refuse |
|
|
810
|
+
| 5 | X | * | Y | Plugin incompatibility; refuse |
|
|
811
|
+
| 6 | - | present| X | Unmanaged store; refuse |
|
|
812
|
+
+-----+----+-------+--------+-------------------------------------------+
|
|
813
|
+
"""
|
|
814
|
+
cp_record = self.get_slot_index_model(product=product)
|
|
815
|
+
attrs_hash = self._read_slot_index_attrs_hash(product=product)
|
|
816
|
+
|
|
817
|
+
if cp_record is None and attrs_hash is None:
|
|
818
|
+
# Row 1: fresh store, declare + mirror + RECORDED.
|
|
819
|
+
recorded_at = datetime.now(tz=UTC).isoformat()
|
|
820
|
+
new_record = SlotIndexModelRecord(
|
|
821
|
+
model=model,
|
|
822
|
+
identity_hash=model.identity_hash,
|
|
823
|
+
schema_version="v1",
|
|
824
|
+
recorded_at=recorded_at,
|
|
825
|
+
recorded_by_run_id=run_id,
|
|
826
|
+
)
|
|
827
|
+
control_root_uri = self.repo.get_control_root_uri(product)
|
|
828
|
+
_fs, control_root = self.repo._get_fs(control_root_uri)
|
|
829
|
+
slot_index_dir = control_root.join(SLOT_INDEX_DIRNAME)
|
|
830
|
+
current_json = slot_index_dir.join(SLOT_INDEX_CURRENT_FILENAME)
|
|
831
|
+
try:
|
|
832
|
+
_fs.makedirs(slot_index_dir, exist_ok=True)
|
|
833
|
+
except Exception:
|
|
834
|
+
self.log.debug(
|
|
835
|
+
"slot_index_model.makedirs_noop",
|
|
836
|
+
extra={"product": product, "path": str(slot_index_dir)},
|
|
837
|
+
)
|
|
838
|
+
_fs.atomic_writer.write_atomic(current_json, new_record.to_json_bytes())
|
|
839
|
+
self._mirror_attrs(product, new_record)
|
|
840
|
+
self.repo.record_slot_index_model_event(
|
|
841
|
+
product=product,
|
|
842
|
+
run_id=run_id,
|
|
843
|
+
event_type=EVENT_SLOT_INDEX_MODEL_RECORDED,
|
|
844
|
+
identity_hash=model.identity_hash,
|
|
845
|
+
model_name=model.name,
|
|
846
|
+
)
|
|
847
|
+
self.log.info(
|
|
848
|
+
"slot_index_model.recorded",
|
|
849
|
+
extra={
|
|
850
|
+
"product": product,
|
|
851
|
+
"identity_hash": model.identity_hash[:16],
|
|
852
|
+
"model_name": model.name,
|
|
853
|
+
},
|
|
854
|
+
)
|
|
855
|
+
return new_record
|
|
856
|
+
|
|
857
|
+
elif (
|
|
858
|
+
cp_record is not None
|
|
859
|
+
and cp_record.identity_hash == model.identity_hash
|
|
860
|
+
and attrs_hash == model.identity_hash
|
|
861
|
+
):
|
|
862
|
+
# Row 2: happy path; CP and attrs both already match.
|
|
863
|
+
self.repo.record_slot_index_model_event(
|
|
864
|
+
product=product,
|
|
865
|
+
run_id=run_id,
|
|
866
|
+
event_type=EVENT_SLOT_INDEX_MODEL_VERIFIED,
|
|
867
|
+
identity_hash=model.identity_hash,
|
|
868
|
+
model_name=model.name,
|
|
869
|
+
)
|
|
870
|
+
return cp_record
|
|
871
|
+
|
|
872
|
+
elif (
|
|
873
|
+
cp_record is not None
|
|
874
|
+
and cp_record.identity_hash == model.identity_hash
|
|
875
|
+
and attrs_hash is None
|
|
876
|
+
):
|
|
877
|
+
# Row 3: crash-recovery; CP is authoritative, re-mirror missing attrs.
|
|
878
|
+
self._mirror_attrs(product, cp_record)
|
|
879
|
+
self.repo.record_slot_index_model_event(
|
|
880
|
+
product=product,
|
|
881
|
+
run_id=run_id,
|
|
882
|
+
event_type=EVENT_SLOT_INDEX_MODEL_VERIFIED,
|
|
883
|
+
identity_hash=model.identity_hash,
|
|
884
|
+
model_name=model.name,
|
|
885
|
+
)
|
|
886
|
+
return cp_record
|
|
887
|
+
|
|
888
|
+
elif (
|
|
889
|
+
cp_record is not None
|
|
890
|
+
and cp_record.identity_hash == model.identity_hash
|
|
891
|
+
and attrs_hash != model.identity_hash
|
|
892
|
+
):
|
|
893
|
+
# Row 4: drift detected — attrs disagree with authoritative CP record.
|
|
894
|
+
raise SlotIndexModelConflictError(
|
|
895
|
+
"zarr root attrs have drifted from authoritative CP record: "
|
|
896
|
+
f"cp_hash={cp_record.identity_hash[:16]} "
|
|
897
|
+
f"attrs_hash={str(attrs_hash)[:16]!r}"
|
|
898
|
+
)
|
|
899
|
+
|
|
900
|
+
elif cp_record is not None and cp_record.identity_hash != model.identity_hash:
|
|
901
|
+
# Row 5: plugin declares an incompatible model (different algorithm/epoch).
|
|
902
|
+
raise SlotIndexModelConflictError(
|
|
903
|
+
"plugin declares incompatible slot-index model: "
|
|
904
|
+
f"stored={cp_record.identity_hash[:16]} "
|
|
905
|
+
f"declared={model.identity_hash[:16]}"
|
|
906
|
+
)
|
|
907
|
+
|
|
908
|
+
elif cp_record is None and attrs_hash is not None:
|
|
909
|
+
# Row 6: unmanaged store — root attrs exist without a control-plane record.
|
|
910
|
+
raise SlotIndexUnmanagedStoreError(
|
|
911
|
+
"zarr root has slot-model attrs but no control-plane record; "
|
|
912
|
+
"this is an unmanaged store — firecube will not adopt it"
|
|
913
|
+
)
|
|
914
|
+
|
|
915
|
+
else: # pragma: no cover - exhaustive guard
|
|
916
|
+
raise AssertionError(
|
|
917
|
+
f"unhandled slot-model precedence state: "
|
|
918
|
+
f"cp_record={cp_record!r} attrs_hash={attrs_hash!r}"
|
|
919
|
+
)
|
|
920
|
+
|
|
921
|
+
def _mirror_attrs(self, product: str, record: SlotIndexModelRecord) -> None:
|
|
922
|
+
"""Write the slot-index model attrs to the zarr root group.
|
|
923
|
+
|
|
924
|
+
Must be called INSIDE the ``slot_index_model:current`` claim. Bypasses
|
|
925
|
+
the reserved-root-attrs guard by design: the slot-index service is the
|
|
926
|
+
authoritative writer of these reserved root attrs, and the guard exists
|
|
927
|
+
to block external (user/plugin) code paths only.
|
|
928
|
+
"""
|
|
929
|
+
import zarr
|
|
930
|
+
from zarr.storage import LocalStore
|
|
931
|
+
|
|
932
|
+
from firecube.core.filesystem.store_factory import create_zarr_store
|
|
933
|
+
from firecube.core.uris import is_remote_target, local_path_from_target
|
|
934
|
+
|
|
935
|
+
product_root = str(self.get_product_root(product))
|
|
936
|
+
if is_remote_target(product_root):
|
|
937
|
+
storage_config = self.storage_config
|
|
938
|
+
if storage_config is None:
|
|
939
|
+
raise RuntimeError(
|
|
940
|
+
"storage_config is required to open the remote zarr root "
|
|
941
|
+
"for slot-index attr mirroring"
|
|
942
|
+
)
|
|
943
|
+
handle = create_zarr_store(uri=product_root, storage_config=storage_config, mode="a")
|
|
944
|
+
root = zarr.open_group(**handle.zarr_kwargs(), mode="a", zarr_format=3)
|
|
945
|
+
else:
|
|
946
|
+
store = LocalStore(str(local_path_from_target(product_root)))
|
|
947
|
+
root = zarr.open_group(store=store, mode="a", zarr_format=3)
|
|
948
|
+
root.attrs.update(
|
|
949
|
+
{
|
|
950
|
+
SLOT_INDEX_MODEL_ATTR: record.model.canonical_bytes().decode("utf-8"),
|
|
951
|
+
SLOT_INDEX_MODEL_IDENTITY_HASH_ATTR: record.identity_hash,
|
|
952
|
+
}
|
|
953
|
+
)
|
|
954
|
+
|
|
955
|
+
def _read_slot_index_attrs_hash(self, *, product: str) -> str | None:
|
|
956
|
+
"""Read the reserved identity-hash root attr, returning ``None`` if absent.
|
|
957
|
+
|
|
958
|
+
Returns ``None`` when the zarr root group is missing, unreadable, or
|
|
959
|
+
does not yet carry the reserved attr. The probe never raises, because
|
|
960
|
+
a missing or pre-existing-but-untouched store is the legitimate Row 1
|
|
961
|
+
fresh-store input — only a real I/O failure under the claim should
|
|
962
|
+
surface, and the precedence-matrix caller will see it during the
|
|
963
|
+
subsequent write step.
|
|
964
|
+
"""
|
|
965
|
+
import zarr
|
|
966
|
+
from zarr.storage import LocalStore
|
|
967
|
+
|
|
968
|
+
from firecube.core.filesystem.store_factory import create_zarr_store
|
|
969
|
+
from firecube.core.uris import is_remote_target, local_path_from_target
|
|
970
|
+
|
|
971
|
+
product_root = str(self.get_product_root(product))
|
|
972
|
+
try:
|
|
973
|
+
if is_remote_target(product_root):
|
|
974
|
+
storage_config = self.storage_config
|
|
975
|
+
if storage_config is None:
|
|
976
|
+
return None
|
|
977
|
+
handle = create_zarr_store(
|
|
978
|
+
uri=product_root, storage_config=storage_config, mode="r"
|
|
979
|
+
)
|
|
980
|
+
root = zarr.open_group(**handle.zarr_kwargs(), mode="r", zarr_format=3)
|
|
981
|
+
else:
|
|
982
|
+
local = local_path_from_target(product_root)
|
|
983
|
+
store = LocalStore(str(local))
|
|
984
|
+
root = zarr.open_group(store=store, mode="r", zarr_format=3)
|
|
985
|
+
except Exception:
|
|
986
|
+
return None
|
|
987
|
+
value = root.attrs.get(SLOT_INDEX_MODEL_IDENTITY_HASH_ATTR)
|
|
988
|
+
if value is None:
|
|
989
|
+
return None
|
|
990
|
+
return str(value)
|