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,804 @@
|
|
|
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-backed repository for the `.firecube/` control-plane root."""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import json
|
|
20
|
+
import logging
|
|
21
|
+
import re
|
|
22
|
+
import time
|
|
23
|
+
import uuid
|
|
24
|
+
from collections.abc import Callable, Generator
|
|
25
|
+
from contextlib import contextmanager
|
|
26
|
+
from dataclasses import dataclass, field
|
|
27
|
+
from datetime import UTC, datetime
|
|
28
|
+
from pathlib import Path
|
|
29
|
+
from typing import Any, cast
|
|
30
|
+
|
|
31
|
+
from firecube.core.controlplane._helpers import (
|
|
32
|
+
describe_control_plane,
|
|
33
|
+
)
|
|
34
|
+
from firecube.core.controlplane._helpers import (
|
|
35
|
+
open_controlplane_fs_cached as _open_controlplane_fs_cached,
|
|
36
|
+
)
|
|
37
|
+
from firecube.core.controlplane._projection import ManifestProjection
|
|
38
|
+
from firecube.core.controlplane._snapshot import (
|
|
39
|
+
read_latest_pointer,
|
|
40
|
+
)
|
|
41
|
+
from firecube.core.controlplane._wal_reader import WalReader
|
|
42
|
+
from firecube.core.controlplane._wal_writer import ManifestWalWriter
|
|
43
|
+
from firecube.core.controlplane.claims import FilesystemClaimService
|
|
44
|
+
from firecube.core.controlplane.events import RunEventWriter
|
|
45
|
+
from firecube.core.controlplane.metrics import (
|
|
46
|
+
record_wal_snapshot_rebuild,
|
|
47
|
+
)
|
|
48
|
+
from firecube.core.controlplane.repo_utils import (
|
|
49
|
+
deserialize_slot_group as _deserialize_slot_group, # noqa: F401
|
|
50
|
+
)
|
|
51
|
+
from firecube.core.controlplane.repo_utils import (
|
|
52
|
+
deserialize_slot_range as _deserialize_slot_range, # noqa: F401
|
|
53
|
+
)
|
|
54
|
+
from firecube.core.controlplane.types import (
|
|
55
|
+
CLAIMS_DIRNAME,
|
|
56
|
+
CONTROL_DIRNAME,
|
|
57
|
+
DEFAULT_RUN_STALE_THRESHOLD_S,
|
|
58
|
+
LATEST_POINTER,
|
|
59
|
+
RUNS_DIRNAME,
|
|
60
|
+
SCHEMA_VERSION,
|
|
61
|
+
SNAPSHOT_DIRNAME,
|
|
62
|
+
ChunkInfo,
|
|
63
|
+
ClaimInfo,
|
|
64
|
+
RunInfo,
|
|
65
|
+
SpanCoverage,
|
|
66
|
+
WriteDomain,
|
|
67
|
+
)
|
|
68
|
+
from firecube.core.errors import (
|
|
69
|
+
ManifestError,
|
|
70
|
+
)
|
|
71
|
+
from firecube.core.filesystem import StorageFilesystem, StorageFilesystemFull
|
|
72
|
+
from firecube.core.storage.binding import StorageBinding
|
|
73
|
+
from firecube.core.storage.uri import StorageUri
|
|
74
|
+
from firecube.core.uris import is_remote_target
|
|
75
|
+
|
|
76
|
+
log = logging.getLogger(__name__)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
@dataclass(slots=True)
|
|
80
|
+
class _RunEntriesCache:
|
|
81
|
+
entries_by_product: dict[str, list[dict[str, Any]]] = field(default_factory=dict)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
class _ControlRootResolver:
|
|
85
|
+
"""Derive product and `.firecube/` control-plane paths from the configured output base."""
|
|
86
|
+
|
|
87
|
+
def __init__(
|
|
88
|
+
self,
|
|
89
|
+
repo: ManifestRepository,
|
|
90
|
+
base_uri: StorageUri,
|
|
91
|
+
fs: StorageFilesystem,
|
|
92
|
+
) -> None:
|
|
93
|
+
self._repo = repo
|
|
94
|
+
self.base_uri = base_uri
|
|
95
|
+
self._fs = fs
|
|
96
|
+
|
|
97
|
+
def product_root(self, product: str) -> tuple[StorageUri, StorageUri]:
|
|
98
|
+
"""Return (filesystem_uri, canonical_uri) for a product root directory."""
|
|
99
|
+
product = str(product or "").strip("/")
|
|
100
|
+
if not product:
|
|
101
|
+
raise ManifestError("product must be non-empty")
|
|
102
|
+
from firecube.core.product import ensure_product_uri
|
|
103
|
+
|
|
104
|
+
if product == self._repo.binding.identity.product_name:
|
|
105
|
+
product_uri = self._repo.binding.identity.product_uri
|
|
106
|
+
else:
|
|
107
|
+
product_uri = StorageUri.parse(ensure_product_uri(self.base_uri.to_str(), product))
|
|
108
|
+
_fs, product_fs_uri = self._repo._get_fs(product_uri)
|
|
109
|
+
return product_fs_uri, product_uri
|
|
110
|
+
|
|
111
|
+
def __call__(self, product: str) -> tuple[StorageUri, StorageUri]:
|
|
112
|
+
product_fs_uri, product_uri = self.product_root(product)
|
|
113
|
+
return product_fs_uri.join(CONTROL_DIRNAME), product_uri.join(CONTROL_DIRNAME)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
class ManifestRepository:
|
|
117
|
+
def __init__(
|
|
118
|
+
self,
|
|
119
|
+
binding: StorageBinding,
|
|
120
|
+
workspace: Path,
|
|
121
|
+
*,
|
|
122
|
+
filesystem: StorageFilesystem | None = None,
|
|
123
|
+
run_stale_threshold_s: int = DEFAULT_RUN_STALE_THRESHOLD_S,
|
|
124
|
+
):
|
|
125
|
+
self.binding = binding
|
|
126
|
+
self.workspace = workspace
|
|
127
|
+
self._base_uri: StorageUri = binding.identity.product_uri.parent()
|
|
128
|
+
self.storage_config = self._storage_config_from_binding(binding)
|
|
129
|
+
self.workspace = workspace
|
|
130
|
+
self.run_stale_threshold_s = int(run_stale_threshold_s)
|
|
131
|
+
self._filesystem = filesystem
|
|
132
|
+
self._fs_cache: dict[tuple, StorageFilesystem] = {}
|
|
133
|
+
self._run_entries_cache: _RunEntriesCache | None = None
|
|
134
|
+
self.log = logging.getLogger(f"{__name__}.ManifestRepository")
|
|
135
|
+
self._writers: dict[tuple[str, str], RunEventWriter] = {}
|
|
136
|
+
self._resolver: _ControlRootResolver | None = None
|
|
137
|
+
self._fs: Any | None = None
|
|
138
|
+
self.claims: FilesystemClaimService | None = None
|
|
139
|
+
self._wal_reader: WalReader | None = None
|
|
140
|
+
self._binding_key: str | None = self._binding_key_for(binding)
|
|
141
|
+
self._wal_writer = ManifestWalWriter(self)
|
|
142
|
+
self._projection = ManifestProjection(self)
|
|
143
|
+
|
|
144
|
+
@property
|
|
145
|
+
def base_uri(self) -> str:
|
|
146
|
+
return self._base_uri.to_str()
|
|
147
|
+
|
|
148
|
+
def record_entry(self, product: str, entry: dict[str, Any]) -> None:
|
|
149
|
+
raise ManifestError(
|
|
150
|
+
"ChunkManager v2 no longer accepts arbitrary chunk-entry writes. Use record_run_started(), "
|
|
151
|
+
"record_span(), and record_run_terminal() instead."
|
|
152
|
+
)
|
|
153
|
+
|
|
154
|
+
@contextmanager
|
|
155
|
+
def run_entries_cache_scope(self) -> Generator[None, None, None]:
|
|
156
|
+
prev = self._run_entries_cache
|
|
157
|
+
self._run_entries_cache = _RunEntriesCache()
|
|
158
|
+
try:
|
|
159
|
+
yield
|
|
160
|
+
finally:
|
|
161
|
+
self._run_entries_cache = prev
|
|
162
|
+
|
|
163
|
+
def record_run_started(
|
|
164
|
+
self,
|
|
165
|
+
*,
|
|
166
|
+
product: str,
|
|
167
|
+
run_id: str,
|
|
168
|
+
output_path: str,
|
|
169
|
+
output_format: str,
|
|
170
|
+
size: int,
|
|
171
|
+
meta: dict[str, Any],
|
|
172
|
+
slot_range: tuple[int, int] | None = None,
|
|
173
|
+
slot_group: str | None = None,
|
|
174
|
+
) -> None:
|
|
175
|
+
self._wal_writer.record_run_started(
|
|
176
|
+
product=product,
|
|
177
|
+
run_id=run_id,
|
|
178
|
+
output_path=output_path,
|
|
179
|
+
output_format=output_format,
|
|
180
|
+
size=size,
|
|
181
|
+
meta=meta,
|
|
182
|
+
slot_range=slot_range,
|
|
183
|
+
slot_group=slot_group,
|
|
184
|
+
)
|
|
185
|
+
|
|
186
|
+
def record_run_terminal(
|
|
187
|
+
self,
|
|
188
|
+
*,
|
|
189
|
+
product: str,
|
|
190
|
+
run_id: str,
|
|
191
|
+
output_path: str,
|
|
192
|
+
output_format: str,
|
|
193
|
+
size: int,
|
|
194
|
+
meta: dict[str, Any],
|
|
195
|
+
status: str,
|
|
196
|
+
error: str | None = None,
|
|
197
|
+
slot_range: tuple[int, int] | None = None,
|
|
198
|
+
slot_group: str | None = None,
|
|
199
|
+
) -> None:
|
|
200
|
+
self._wal_writer.record_run_terminal(
|
|
201
|
+
product=product,
|
|
202
|
+
run_id=run_id,
|
|
203
|
+
output_path=output_path,
|
|
204
|
+
output_format=output_format,
|
|
205
|
+
size=size,
|
|
206
|
+
meta=meta,
|
|
207
|
+
status=status,
|
|
208
|
+
error=error,
|
|
209
|
+
slot_range=slot_range,
|
|
210
|
+
slot_group=slot_group,
|
|
211
|
+
)
|
|
212
|
+
|
|
213
|
+
def record_run_started_with_replacement(
|
|
214
|
+
self, *, product: str, run_id: str, replaces: list[str]
|
|
215
|
+
) -> None:
|
|
216
|
+
self._wal_writer.record_run_started_with_replacement(
|
|
217
|
+
product=product, run_id=run_id, replaces=replaces
|
|
218
|
+
)
|
|
219
|
+
|
|
220
|
+
def record_maintenance_started(
|
|
221
|
+
self, *, product: str, run_id: str, op: str, scope_meta: dict[str, Any] | None = None
|
|
222
|
+
) -> None:
|
|
223
|
+
self._wal_writer.record_maintenance_started(
|
|
224
|
+
product=product, run_id=run_id, op=op, scope_meta=scope_meta
|
|
225
|
+
)
|
|
226
|
+
|
|
227
|
+
def record_maintenance_completed(
|
|
228
|
+
self, *, product: str, run_id: str, op: str, scope_meta: dict[str, Any] | None = None
|
|
229
|
+
) -> None:
|
|
230
|
+
self._wal_writer.record_maintenance_completed(
|
|
231
|
+
product=product, run_id=run_id, op=op, scope_meta=scope_meta
|
|
232
|
+
)
|
|
233
|
+
|
|
234
|
+
def record_maintenance_failed(
|
|
235
|
+
self,
|
|
236
|
+
*,
|
|
237
|
+
product: str,
|
|
238
|
+
run_id: str,
|
|
239
|
+
op: str,
|
|
240
|
+
scope_meta: dict[str, Any] | None = None,
|
|
241
|
+
error: str,
|
|
242
|
+
) -> None:
|
|
243
|
+
self._wal_writer.record_maintenance_failed(
|
|
244
|
+
product=product, run_id=run_id, op=op, scope_meta=scope_meta, error=error
|
|
245
|
+
)
|
|
246
|
+
|
|
247
|
+
def record_replacement_committed(
|
|
248
|
+
self, *, product: str, run_id: str, replacing_run_id: str, replaced_span_keys: list[str]
|
|
249
|
+
) -> None:
|
|
250
|
+
self._wal_writer.record_replacement_committed(
|
|
251
|
+
product=product,
|
|
252
|
+
run_id=run_id,
|
|
253
|
+
replacing_run_id=replacing_run_id,
|
|
254
|
+
replaced_span_keys=replaced_span_keys,
|
|
255
|
+
)
|
|
256
|
+
|
|
257
|
+
def record_span_event(
|
|
258
|
+
self,
|
|
259
|
+
*,
|
|
260
|
+
product: str,
|
|
261
|
+
run_id: str,
|
|
262
|
+
batch_id: str,
|
|
263
|
+
group: str,
|
|
264
|
+
status: str,
|
|
265
|
+
reason: str | None = None,
|
|
266
|
+
coverage: SpanCoverage | None = None,
|
|
267
|
+
meta: dict[str, Any] | None = None,
|
|
268
|
+
) -> None:
|
|
269
|
+
self._wal_writer.record_span_event(
|
|
270
|
+
product=product,
|
|
271
|
+
run_id=run_id,
|
|
272
|
+
batch_id=batch_id,
|
|
273
|
+
group=group,
|
|
274
|
+
status=status,
|
|
275
|
+
reason=reason,
|
|
276
|
+
coverage=coverage,
|
|
277
|
+
meta=meta,
|
|
278
|
+
)
|
|
279
|
+
|
|
280
|
+
def record_schema_verification_event(
|
|
281
|
+
self,
|
|
282
|
+
*,
|
|
283
|
+
product: str,
|
|
284
|
+
run_id: str,
|
|
285
|
+
group: str,
|
|
286
|
+
plugin: str,
|
|
287
|
+
schema_hash: str,
|
|
288
|
+
verified_at: str,
|
|
289
|
+
expected_time_count: int,
|
|
290
|
+
meta: dict[str, Any] | None = None,
|
|
291
|
+
) -> None:
|
|
292
|
+
self._wal_writer.record_schema_verification_event(
|
|
293
|
+
product=product,
|
|
294
|
+
run_id=run_id,
|
|
295
|
+
group=group,
|
|
296
|
+
plugin=plugin,
|
|
297
|
+
schema_hash=schema_hash,
|
|
298
|
+
verified_at=verified_at,
|
|
299
|
+
expected_time_count=expected_time_count,
|
|
300
|
+
meta=meta,
|
|
301
|
+
)
|
|
302
|
+
|
|
303
|
+
def record_slot_index_model_event(
|
|
304
|
+
self,
|
|
305
|
+
*,
|
|
306
|
+
product: str,
|
|
307
|
+
run_id: str,
|
|
308
|
+
event_type: str,
|
|
309
|
+
identity_hash: str,
|
|
310
|
+
model_name: str,
|
|
311
|
+
group: str | None = None,
|
|
312
|
+
meta: dict[str, Any] | None = None,
|
|
313
|
+
) -> None:
|
|
314
|
+
self._wal_writer.record_slot_index_model_event(
|
|
315
|
+
product=product,
|
|
316
|
+
run_id=run_id,
|
|
317
|
+
event_type=event_type,
|
|
318
|
+
identity_hash=identity_hash,
|
|
319
|
+
model_name=model_name,
|
|
320
|
+
group=group,
|
|
321
|
+
meta=meta,
|
|
322
|
+
)
|
|
323
|
+
|
|
324
|
+
def discover_manifests(self) -> list[str]:
|
|
325
|
+
return self._projection.discover_manifests()
|
|
326
|
+
|
|
327
|
+
def parse_manifest(self, manifest_uri: str) -> Generator[ChunkInfo, None, None]:
|
|
328
|
+
yield from self._projection.parse_manifest(manifest_uri)
|
|
329
|
+
|
|
330
|
+
def list_runs(
|
|
331
|
+
self, *, product: str, status: str | None = None, non_terminal: bool = False
|
|
332
|
+
) -> list[RunInfo]:
|
|
333
|
+
return self._projection.list_runs(product=product, status=status, non_terminal=non_terminal)
|
|
334
|
+
|
|
335
|
+
def list_stale_runs(self, *, product: str) -> list[RunInfo]:
|
|
336
|
+
return [run for run in self.list_runs(product=product, non_terminal=True) if run.stale]
|
|
337
|
+
|
|
338
|
+
def abandon_run(
|
|
339
|
+
self, *, product: str, run_id: str, reason: str, meta: dict[str, Any] | None = None
|
|
340
|
+
) -> dict[str, Any]:
|
|
341
|
+
return self._wal_writer.abandon_run(
|
|
342
|
+
product=product, run_id=run_id, reason=reason, meta=meta
|
|
343
|
+
)
|
|
344
|
+
|
|
345
|
+
def list_chunks(
|
|
346
|
+
self,
|
|
347
|
+
pattern: str | None = None,
|
|
348
|
+
product: str | None = None,
|
|
349
|
+
before: datetime | None = None,
|
|
350
|
+
after: datetime | None = None,
|
|
351
|
+
chunk_type: str | None = None,
|
|
352
|
+
status: str | None = None,
|
|
353
|
+
include_replaced: bool = False,
|
|
354
|
+
meta: dict[str, Any] | None = None,
|
|
355
|
+
time_min_after: str | None = None,
|
|
356
|
+
time_max_before: str | None = None,
|
|
357
|
+
time_overlaps: tuple[str, str] | None = None,
|
|
358
|
+
filter_fn: Callable[[ChunkInfo], bool] | None = None,
|
|
359
|
+
) -> list[ChunkInfo]:
|
|
360
|
+
return self._projection.list_chunks(
|
|
361
|
+
pattern=pattern,
|
|
362
|
+
product=product,
|
|
363
|
+
before=before,
|
|
364
|
+
after=after,
|
|
365
|
+
chunk_type=chunk_type,
|
|
366
|
+
status=status,
|
|
367
|
+
include_replaced=include_replaced,
|
|
368
|
+
meta=meta,
|
|
369
|
+
time_min_after=time_min_after,
|
|
370
|
+
time_max_before=time_max_before,
|
|
371
|
+
time_overlaps=time_overlaps,
|
|
372
|
+
filter_fn=filter_fn,
|
|
373
|
+
)
|
|
374
|
+
|
|
375
|
+
def mark_chunks_replaced(
|
|
376
|
+
self, chunk_keys: list[str], product: str, replacement_timestamp: float
|
|
377
|
+
) -> dict[str, Any]:
|
|
378
|
+
return self._wal_writer.mark_chunks_replaced(chunk_keys, product, replacement_timestamp)
|
|
379
|
+
|
|
380
|
+
def remove_from_manifest(
|
|
381
|
+
self, manifest_uri: str, chunks_to_remove: list[ChunkInfo]
|
|
382
|
+
) -> tuple[int, int]:
|
|
383
|
+
return self._wal_writer.remove_from_manifest(chunks_to_remove)
|
|
384
|
+
|
|
385
|
+
def acquire_claim(self, *, product: str, domain: WriteDomain, owner_id: str):
|
|
386
|
+
if self.claims is None:
|
|
387
|
+
self._ensure_bound()
|
|
388
|
+
assert self.claims is not None
|
|
389
|
+
return self.claims.acquire(product=product, domain=domain, owner_id=owner_id)
|
|
390
|
+
|
|
391
|
+
def list_claims(self, *, product: str | None = None) -> list[ClaimInfo]:
|
|
392
|
+
if self.claims is None:
|
|
393
|
+
self._ensure_bound()
|
|
394
|
+
assert self.claims is not None
|
|
395
|
+
return self.claims.list_claims(product=product)
|
|
396
|
+
|
|
397
|
+
def list_stale_claims(self, *, product: str) -> list[ClaimInfo]:
|
|
398
|
+
if self.claims is None:
|
|
399
|
+
self._ensure_bound()
|
|
400
|
+
assert self.claims is not None
|
|
401
|
+
return self.claims.list_stale_claims(product=product)
|
|
402
|
+
|
|
403
|
+
def clear_claim(self, *, product: str, domain_id: str, force: bool = False) -> bool:
|
|
404
|
+
if self.claims is None:
|
|
405
|
+
self._ensure_bound()
|
|
406
|
+
assert self.claims is not None
|
|
407
|
+
return self.claims.clear_claim(product=product, domain_id=domain_id, force=force)
|
|
408
|
+
|
|
409
|
+
def rebuild_snapshot(self, product: str) -> dict[str, Any]:
|
|
410
|
+
_rebuild_t0 = time.time()
|
|
411
|
+
self._ensure_product_control_root(product)
|
|
412
|
+
self._assert_compaction_allowed(product)
|
|
413
|
+
if self._resolver is None:
|
|
414
|
+
raise ManifestError("Repository not bound — call bind() first")
|
|
415
|
+
if self._fs is None:
|
|
416
|
+
raise ManifestError("Repository not bound — call bind() first")
|
|
417
|
+
control_path, control_uri = self._resolver(product)
|
|
418
|
+
try:
|
|
419
|
+
self._fs.makedirs(control_path.join(SNAPSHOT_DIRNAME), exist_ok=True)
|
|
420
|
+
except Exception:
|
|
421
|
+
self.log.debug(
|
|
422
|
+
"makedirs failed for %s (non-fatal)", control_path.join(SNAPSHOT_DIRNAME)
|
|
423
|
+
)
|
|
424
|
+
|
|
425
|
+
is_remote = is_remote_target(control_uri.to_str())
|
|
426
|
+
lock_path = control_path.join(SNAPSHOT_DIRNAME, "compact.lock")
|
|
427
|
+
lock_token = uuid.uuid4().hex
|
|
428
|
+
lock_acquired = False
|
|
429
|
+
if is_remote:
|
|
430
|
+
self.log.debug(
|
|
431
|
+
"rebuild_snapshot for %s: skipping local lock (remote target — orchestration handles exclusion)",
|
|
432
|
+
product,
|
|
433
|
+
)
|
|
434
|
+
if not is_remote:
|
|
435
|
+
try:
|
|
436
|
+
lock_bytes = json.dumps({"token": lock_token, "created_at": time.time()}).encode(
|
|
437
|
+
"utf-8"
|
|
438
|
+
)
|
|
439
|
+
self._fs.atomic_writer.write_atomic(lock_path, lock_bytes)
|
|
440
|
+
lock_acquired = True
|
|
441
|
+
except FileExistsError:
|
|
442
|
+
return {"product": product, "skipped": True, "locked": True}
|
|
443
|
+
|
|
444
|
+
completed_runs = [
|
|
445
|
+
run
|
|
446
|
+
for run in self._list_run_entries(product)
|
|
447
|
+
if str(run.get("status", "")) == "complete"
|
|
448
|
+
]
|
|
449
|
+
records = self._project_records_from_runs(completed_runs)
|
|
450
|
+
completed_before = max(
|
|
451
|
+
(float(run.get("completed_at", 0.0) or 0.0) for run in completed_runs),
|
|
452
|
+
default=0.0,
|
|
453
|
+
)
|
|
454
|
+
generation = str(time.time_ns())
|
|
455
|
+
snapshot_path = control_path.join(SNAPSHOT_DIRNAME, f"snapshot-{generation}.jsonl")
|
|
456
|
+
snapshot_meta_path = control_path.join(SNAPSHOT_DIRNAME, f"snapshot-{generation}.meta.json")
|
|
457
|
+
latest_path = control_path.join(LATEST_POINTER)
|
|
458
|
+
|
|
459
|
+
try:
|
|
460
|
+
payload = "\n".join(
|
|
461
|
+
json.dumps(record, separators=(",", ":"))
|
|
462
|
+
for record in sorted(records.values(), key=lambda item: item.get("key", ""))
|
|
463
|
+
)
|
|
464
|
+
if payload:
|
|
465
|
+
payload += "\n"
|
|
466
|
+
with self._fs.open(snapshot_path, "w") as handle:
|
|
467
|
+
handle.write(payload)
|
|
468
|
+
meta = {
|
|
469
|
+
"schema_version": SCHEMA_VERSION,
|
|
470
|
+
"generation": generation,
|
|
471
|
+
"completed_before": completed_before,
|
|
472
|
+
"created_at": time.time(),
|
|
473
|
+
"records": len(records),
|
|
474
|
+
"product": product,
|
|
475
|
+
}
|
|
476
|
+
with self._fs.open(snapshot_meta_path, "w") as handle:
|
|
477
|
+
json.dump(meta, handle, separators=(",", ":"))
|
|
478
|
+
with self._fs.open(latest_path, "w") as handle:
|
|
479
|
+
json.dump(
|
|
480
|
+
{
|
|
481
|
+
"schema_version": SCHEMA_VERSION,
|
|
482
|
+
"generation": generation,
|
|
483
|
+
"snapshot_path": (
|
|
484
|
+
snapshot_path.path
|
|
485
|
+
if snapshot_path.protocol == "file"
|
|
486
|
+
else snapshot_path.to_str()
|
|
487
|
+
),
|
|
488
|
+
"snapshot_meta_path": (
|
|
489
|
+
snapshot_meta_path.path
|
|
490
|
+
if snapshot_meta_path.protocol == "file"
|
|
491
|
+
else snapshot_meta_path.to_str()
|
|
492
|
+
),
|
|
493
|
+
"completed_before": completed_before,
|
|
494
|
+
"product": product,
|
|
495
|
+
},
|
|
496
|
+
handle,
|
|
497
|
+
separators=(",", ":"),
|
|
498
|
+
)
|
|
499
|
+
record_wal_snapshot_rebuild(time.time() - _rebuild_t0)
|
|
500
|
+
self.log.debug("rebuild_snapshot for %s: %.2fs", product, time.time() - _rebuild_t0)
|
|
501
|
+
snapshot_uri = control_uri.join(
|
|
502
|
+
SNAPSHOT_DIRNAME, f"snapshot-{generation}.jsonl"
|
|
503
|
+
).to_str()
|
|
504
|
+
return {
|
|
505
|
+
"product": product,
|
|
506
|
+
"generation": generation,
|
|
507
|
+
"records": len(records),
|
|
508
|
+
"snapshot_path": snapshot_uri,
|
|
509
|
+
"locked": False,
|
|
510
|
+
"remote": is_remote,
|
|
511
|
+
}
|
|
512
|
+
finally:
|
|
513
|
+
if lock_acquired:
|
|
514
|
+
try:
|
|
515
|
+
with self._fs.open(lock_path, "r") as handle:
|
|
516
|
+
payload = json.load(handle)
|
|
517
|
+
if payload.get("token") == lock_token:
|
|
518
|
+
self._fs.rm(lock_path, recursive=False)
|
|
519
|
+
except Exception:
|
|
520
|
+
self.log.debug("lock cleanup failed for %s (non-fatal)", lock_path)
|
|
521
|
+
|
|
522
|
+
def get_product_root_uri(self, product: str) -> str:
|
|
523
|
+
self._ensure_bound()
|
|
524
|
+
if self._resolver is None:
|
|
525
|
+
raise ManifestError("Repository not bound — call bind() first")
|
|
526
|
+
_path, uri = self._resolver.product_root(product)
|
|
527
|
+
return uri.to_str()
|
|
528
|
+
|
|
529
|
+
def get_control_root_uri(self, product: str) -> str:
|
|
530
|
+
self._ensure_bound()
|
|
531
|
+
if self._resolver is None:
|
|
532
|
+
raise ManifestError("Repository not bound — call bind() first")
|
|
533
|
+
_path, uri = self._resolver(product)
|
|
534
|
+
return uri.to_str()
|
|
535
|
+
|
|
536
|
+
def get_latest_pointer_uri(self, product: str) -> str:
|
|
537
|
+
return describe_control_plane(product_uri=self.get_product_root_uri(product))[
|
|
538
|
+
"latest_pointer"
|
|
539
|
+
]
|
|
540
|
+
|
|
541
|
+
def _matches_pattern(self, key: str, pattern: str) -> bool:
|
|
542
|
+
regex_pattern = f"^{pattern.replace('*', '.*').replace('?', '.')}$"
|
|
543
|
+
try:
|
|
544
|
+
return bool(re.match(regex_pattern, key))
|
|
545
|
+
except re.error:
|
|
546
|
+
return False
|
|
547
|
+
|
|
548
|
+
def _get_fs(self, uri: str | StorageUri) -> tuple[StorageFilesystem, StorageUri]:
|
|
549
|
+
if not uri:
|
|
550
|
+
raise ManifestError("Invalid manifest URI")
|
|
551
|
+
root_uri = uri if isinstance(uri, StorageUri) else StorageUri.parse(str(uri))
|
|
552
|
+
if self._filesystem is not None:
|
|
553
|
+
return self._filesystem, root_uri
|
|
554
|
+
return _open_controlplane_fs_cached(
|
|
555
|
+
root_uri,
|
|
556
|
+
binding=self.binding,
|
|
557
|
+
cache=self._fs_cache,
|
|
558
|
+
)
|
|
559
|
+
|
|
560
|
+
@staticmethod
|
|
561
|
+
def _entry_to_uri(entry: Any, base_uri: StorageUri) -> StorageUri:
|
|
562
|
+
name = entry.get("name") if isinstance(entry, dict) else entry
|
|
563
|
+
if isinstance(name, StorageUri):
|
|
564
|
+
return name
|
|
565
|
+
raw = str(name or "")
|
|
566
|
+
if "://" in raw:
|
|
567
|
+
return StorageUri.parse(raw)
|
|
568
|
+
if base_uri.protocol == "file" and raw.startswith("/"):
|
|
569
|
+
return StorageUri.from_local_path(raw)
|
|
570
|
+
path = raw
|
|
571
|
+
if base_uri.authority and path.startswith(f"{base_uri.authority}/"):
|
|
572
|
+
path = path[len(base_uri.authority) + 1 :]
|
|
573
|
+
return StorageUri(protocol=base_uri.protocol, authority=base_uri.authority, path=path)
|
|
574
|
+
|
|
575
|
+
def _manifest_uri_for_product(self, product: str) -> str:
|
|
576
|
+
return self.get_control_root_uri(product)
|
|
577
|
+
|
|
578
|
+
def _product_from_manifest_uri(self, manifest_uri: str) -> str:
|
|
579
|
+
parts = StorageUri.parse(str(manifest_uri)).path.rstrip("/").split("/")
|
|
580
|
+
if parts and parts[-1] == CONTROL_DIRNAME:
|
|
581
|
+
return parts[-2]
|
|
582
|
+
return parts[-1]
|
|
583
|
+
|
|
584
|
+
def _ensure_bound(self) -> None:
|
|
585
|
+
if self._resolver is not None and self._fs is not None:
|
|
586
|
+
return
|
|
587
|
+
base_uri = self._base_uri or StorageUri.from_local_path(self.workspace.resolve())
|
|
588
|
+
if not base_uri.to_str():
|
|
589
|
+
raise ManifestError("ChunkManager requires an output base URI or workspace.")
|
|
590
|
+
fs, base_path = self._get_fs(base_uri)
|
|
591
|
+
self._resolver = _ControlRootResolver(self, base_path, fs)
|
|
592
|
+
self._fs = fs
|
|
593
|
+
self.claims = FilesystemClaimService(
|
|
594
|
+
fs=cast(StorageFilesystemFull, fs), control_root_resolver=self._resolver
|
|
595
|
+
)
|
|
596
|
+
self._wal_reader = WalReader(
|
|
597
|
+
fs=fs,
|
|
598
|
+
resolver=self._resolver,
|
|
599
|
+
log=self.log,
|
|
600
|
+
run_stale_threshold_s=self.run_stale_threshold_s,
|
|
601
|
+
)
|
|
602
|
+
|
|
603
|
+
def _writer(
|
|
604
|
+
self,
|
|
605
|
+
product: str,
|
|
606
|
+
run_id: str,
|
|
607
|
+
*,
|
|
608
|
+
resume_existing: bool = False,
|
|
609
|
+
slot_range: tuple[int, int] | None = None,
|
|
610
|
+
slot_group: str | None = None,
|
|
611
|
+
) -> RunEventWriter:
|
|
612
|
+
return self._wal_writer._writer(
|
|
613
|
+
product,
|
|
614
|
+
run_id,
|
|
615
|
+
resume_existing=resume_existing,
|
|
616
|
+
slot_range=slot_range,
|
|
617
|
+
slot_group=slot_group,
|
|
618
|
+
)
|
|
619
|
+
|
|
620
|
+
def _build_run_record(
|
|
621
|
+
self,
|
|
622
|
+
*,
|
|
623
|
+
run_id: str,
|
|
624
|
+
output_path: str,
|
|
625
|
+
output_format: str,
|
|
626
|
+
status: str,
|
|
627
|
+
size: int,
|
|
628
|
+
meta: dict[str, Any],
|
|
629
|
+
error: str | None = None,
|
|
630
|
+
slot_range: tuple[int, int] | None = None,
|
|
631
|
+
slot_group: str | None = None,
|
|
632
|
+
) -> dict[str, Any]:
|
|
633
|
+
return ManifestWalWriter._build_run_record(
|
|
634
|
+
run_id=run_id,
|
|
635
|
+
output_path=output_path,
|
|
636
|
+
output_format=output_format,
|
|
637
|
+
status=status,
|
|
638
|
+
size=size,
|
|
639
|
+
meta=meta,
|
|
640
|
+
error=error,
|
|
641
|
+
slot_range=slot_range,
|
|
642
|
+
slot_group=slot_group,
|
|
643
|
+
)
|
|
644
|
+
|
|
645
|
+
def _ensure_product_control_root(self, product: str) -> None:
|
|
646
|
+
self._ensure_bound()
|
|
647
|
+
if self._resolver is None:
|
|
648
|
+
raise ManifestError("Repository not bound — call bind() first")
|
|
649
|
+
if self._fs is None:
|
|
650
|
+
raise ManifestError("Repository not bound — call bind() first")
|
|
651
|
+
control_path, _control_uri = self._resolver(product)
|
|
652
|
+
if self._fs.exists(control_path):
|
|
653
|
+
return
|
|
654
|
+
try:
|
|
655
|
+
self._fs.makedirs(control_path, exist_ok=True)
|
|
656
|
+
except Exception:
|
|
657
|
+
self.log.debug("makedirs failed for %s (non-fatal)", control_path)
|
|
658
|
+
try:
|
|
659
|
+
self._fs.makedirs(control_path.join(RUNS_DIRNAME), exist_ok=True)
|
|
660
|
+
self._fs.makedirs(control_path.join(SNAPSHOT_DIRNAME), exist_ok=True)
|
|
661
|
+
self._fs.makedirs(control_path.join(CLAIMS_DIRNAME), exist_ok=True)
|
|
662
|
+
except Exception:
|
|
663
|
+
self.log.debug("makedirs failed for %s (non-fatal)", control_path)
|
|
664
|
+
|
|
665
|
+
def _product_control_exists(self, product: str) -> bool:
|
|
666
|
+
self._ensure_bound()
|
|
667
|
+
if self._resolver is None:
|
|
668
|
+
raise ManifestError("Repository not bound — call bind() first")
|
|
669
|
+
if self._fs is None:
|
|
670
|
+
raise ManifestError("Repository not bound — call bind() first")
|
|
671
|
+
control_path, _control_uri = self._resolver(product)
|
|
672
|
+
return bool(self._fs.exists(control_path))
|
|
673
|
+
|
|
674
|
+
def _load_current_state(self, product: str) -> dict[str, dict[str, Any]]:
|
|
675
|
+
return self._projection._load_current_state(product)
|
|
676
|
+
|
|
677
|
+
def _list_run_entries(self, product: str) -> list[dict[str, Any]]:
|
|
678
|
+
self._ensure_bound()
|
|
679
|
+
assert self._wal_reader is not None
|
|
680
|
+
if self._run_entries_cache is not None:
|
|
681
|
+
cached = self._run_entries_cache.entries_by_product.get(product)
|
|
682
|
+
if cached is not None:
|
|
683
|
+
return cached
|
|
684
|
+
result = self._wal_reader.list_run_entries(product)
|
|
685
|
+
self._run_entries_cache.entries_by_product[product] = result
|
|
686
|
+
return result
|
|
687
|
+
return self._wal_reader.list_run_entries(product)
|
|
688
|
+
|
|
689
|
+
def _read_run_events(self, product: str, run_entry: dict[str, Any]) -> list[dict[str, Any]]:
|
|
690
|
+
self._ensure_bound()
|
|
691
|
+
assert self._wal_reader is not None
|
|
692
|
+
return self._wal_reader.read_run_events(product, run_entry)
|
|
693
|
+
|
|
694
|
+
def _parse_iso_dt(self, value: Any) -> datetime | None:
|
|
695
|
+
if not isinstance(value, str):
|
|
696
|
+
return None
|
|
697
|
+
raw = value.strip()
|
|
698
|
+
if not raw:
|
|
699
|
+
return None
|
|
700
|
+
try:
|
|
701
|
+
if raw.endswith("Z"):
|
|
702
|
+
raw = raw[:-1] + "+00:00"
|
|
703
|
+
parsed = datetime.fromisoformat(raw)
|
|
704
|
+
except ValueError:
|
|
705
|
+
return None
|
|
706
|
+
if parsed.tzinfo is None:
|
|
707
|
+
return parsed
|
|
708
|
+
return parsed.astimezone(UTC).replace(tzinfo=None)
|
|
709
|
+
|
|
710
|
+
def _meta_time_overlaps(
|
|
711
|
+
self,
|
|
712
|
+
meta: dict[str, Any] | None,
|
|
713
|
+
start: str,
|
|
714
|
+
end: str,
|
|
715
|
+
) -> bool:
|
|
716
|
+
if not isinstance(meta, dict):
|
|
717
|
+
return False
|
|
718
|
+
chunk_start = self._parse_iso_dt(meta.get("time_min"))
|
|
719
|
+
chunk_end = self._parse_iso_dt(meta.get("time_max"))
|
|
720
|
+
window_start = self._parse_iso_dt(start)
|
|
721
|
+
window_end = self._parse_iso_dt(end)
|
|
722
|
+
if chunk_start is None or chunk_end is None or window_start is None or window_end is None:
|
|
723
|
+
return False
|
|
724
|
+
return chunk_start <= window_end and chunk_end >= window_start
|
|
725
|
+
|
|
726
|
+
def _load_history_records(self, product: str) -> list[dict[str, Any]]:
|
|
727
|
+
return self._projection._load_history_records(product)
|
|
728
|
+
|
|
729
|
+
def _project_records_from_runs(
|
|
730
|
+
self,
|
|
731
|
+
run_entries: list[dict[str, Any]],
|
|
732
|
+
*,
|
|
733
|
+
completed_before: float | None = None,
|
|
734
|
+
) -> dict[str, dict[str, Any]]:
|
|
735
|
+
return self._projection._project_records_from_runs(
|
|
736
|
+
run_entries,
|
|
737
|
+
completed_before=completed_before,
|
|
738
|
+
)
|
|
739
|
+
|
|
740
|
+
def _read_latest_pointer(self, product: str) -> dict[str, Any] | None:
|
|
741
|
+
self._ensure_bound()
|
|
742
|
+
assert self._fs is not None
|
|
743
|
+
return read_latest_pointer(product, fs=self._fs, resolver=self._resolver, log=self.log)
|
|
744
|
+
|
|
745
|
+
def _read_snapshot_records(self, latest: dict[str, Any]) -> list[dict[str, Any]]:
|
|
746
|
+
return self._projection._read_snapshot_records(latest)
|
|
747
|
+
|
|
748
|
+
def _run_info_from_entry(self, product: str, payload: dict[str, Any]) -> RunInfo:
|
|
749
|
+
return self._projection._run_info_from_entry(product, payload)
|
|
750
|
+
|
|
751
|
+
def _get_run_entry(self, *, product: str, run_id: str) -> dict[str, Any]:
|
|
752
|
+
for entry in self._list_run_entries(product):
|
|
753
|
+
if str(entry.get("run_id", "")) == run_id:
|
|
754
|
+
return entry
|
|
755
|
+
raise ManifestError(f"Run '{run_id}' not found for product '{product}'.")
|
|
756
|
+
|
|
757
|
+
def _assert_compaction_allowed(self, product: str) -> None:
|
|
758
|
+
claims = self.list_claims(product=product)
|
|
759
|
+
if claims:
|
|
760
|
+
rendered = ", ".join(
|
|
761
|
+
f"{claim.domain} (owner={claim.owner_id}, stale={claim.stale})" for claim in claims
|
|
762
|
+
)
|
|
763
|
+
raise ManifestError(
|
|
764
|
+
f"Cannot rebuild snapshot for {product}: blocking claims exist: {rendered}. "
|
|
765
|
+
"Clear claims explicitly first."
|
|
766
|
+
)
|
|
767
|
+
|
|
768
|
+
blocking_runs = [run for run in self.list_runs(product=product) if not run.is_terminal]
|
|
769
|
+
if blocking_runs:
|
|
770
|
+
rendered = ", ".join(
|
|
771
|
+
f"{run.run_id} (status={run.status}, updated_at={run.updated_at:.0f}, stale={run.stale})"
|
|
772
|
+
for run in blocking_runs
|
|
773
|
+
)
|
|
774
|
+
raise ManifestError(
|
|
775
|
+
f"Cannot rebuild snapshot for {product}: non-terminal runs exist: {rendered}. "
|
|
776
|
+
"Abandon stale runs explicitly first."
|
|
777
|
+
)
|
|
778
|
+
|
|
779
|
+
def close(self) -> None:
|
|
780
|
+
"""Flush all WAL writers and release bound filesystem resources."""
|
|
781
|
+
for writer in list(self._writers.values()):
|
|
782
|
+
try:
|
|
783
|
+
writer.flush()
|
|
784
|
+
except Exception:
|
|
785
|
+
self.log.warning("Failed to flush WAL writer on close (events may be lost)")
|
|
786
|
+
self._writers.clear()
|
|
787
|
+
self._fs_cache.clear()
|
|
788
|
+
self._resolver = None
|
|
789
|
+
self._fs = None
|
|
790
|
+
self._wal_reader = None
|
|
791
|
+
self.claims = None
|
|
792
|
+
|
|
793
|
+
def _binding_key_for(
|
|
794
|
+
self,
|
|
795
|
+
binding: StorageBinding,
|
|
796
|
+
) -> str:
|
|
797
|
+
"""Stable cache key for filesystem binding."""
|
|
798
|
+
ck = binding.cache_key()
|
|
799
|
+
return f"{ck.driver}:{ck.protocol}:{ck.authority or ''}:{ck.endpoint_url or ''}:{ck.region or ''}:{ck.credential_fingerprint or ''}"
|
|
800
|
+
|
|
801
|
+
def _storage_config_from_binding(self, binding: StorageBinding) -> Any:
|
|
802
|
+
from firecube.core.storage.session import storage_config_from_binding
|
|
803
|
+
|
|
804
|
+
return storage_config_from_binding(binding)
|