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,836 @@
|
|
|
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
|
+
"""Base implementation for Firecube ingestors.
|
|
16
|
+
|
|
17
|
+
This module provides the `BaseIngestor` abstract base class which orchestrates
|
|
18
|
+
ingestion using composition of runtime services.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import contextlib
|
|
24
|
+
import inspect
|
|
25
|
+
import logging
|
|
26
|
+
from abc import ABC, abstractmethod
|
|
27
|
+
from collections.abc import Iterable, Mapping, Sequence
|
|
28
|
+
from pathlib import Path
|
|
29
|
+
from typing import Any, ClassVar, cast, get_type_hints
|
|
30
|
+
|
|
31
|
+
from firecube.core.controlplane import ChunkManager
|
|
32
|
+
from firecube.core.controlplane.metrics import collect_wal_metrics
|
|
33
|
+
from firecube.core.filesystem import collect_filesystem_metrics
|
|
34
|
+
from firecube.core.formats import discover_input_files
|
|
35
|
+
from firecube.core.intake import CatalogGroupInfo
|
|
36
|
+
from firecube.core.observability import create_ingestion_telemetry
|
|
37
|
+
from firecube.core.observability.metrics import TelemetryService
|
|
38
|
+
from firecube.core.product.identity import ProductIdentity
|
|
39
|
+
from firecube.core.storage.binding import StorageBinding
|
|
40
|
+
from firecube.core.storage.driver_config import StorageDriverConfig
|
|
41
|
+
from firecube.core.storage.uri import StorageUri
|
|
42
|
+
from firecube.ingestor.config.engine import SYSTEM_KEYS, EngineConfig, config_keys
|
|
43
|
+
from firecube.ingestor.contracts.interfaces import Ingestor, is_dataset_producer
|
|
44
|
+
from firecube.ingestor.errors import ConfigurationError
|
|
45
|
+
from firecube.ingestor.runtime.aggregation import merge_batch_metrics
|
|
46
|
+
from firecube.ingestor.runtime.base_hooks import BaseIngestorHookMixin
|
|
47
|
+
from firecube.ingestor.runtime.batching import BatchPlanHost, BatchPlanner
|
|
48
|
+
from firecube.ingestor.runtime.configure import (
|
|
49
|
+
ExecutionMode,
|
|
50
|
+
TierConfigurator,
|
|
51
|
+
determine_execution_mode,
|
|
52
|
+
ensure_chunk_manager_config,
|
|
53
|
+
ensure_run_id,
|
|
54
|
+
)
|
|
55
|
+
from firecube.ingestor.runtime.engine import run_sequential
|
|
56
|
+
from firecube.ingestor.runtime.parallel_execution_state import _ParallelExecutionState
|
|
57
|
+
from firecube.ingestor.runtime.parallel_run_id import derive_pod_run_id
|
|
58
|
+
from firecube.ingestor.runtime.preflight import preflight
|
|
59
|
+
from firecube.ingestor.runtime.recording import SpanRecorder
|
|
60
|
+
from firecube.ingestor.runtime.resume_guard import ResumeGuard
|
|
61
|
+
from firecube.ingestor.runtime.workspace import LocalSourceFile, WorkspaceManager
|
|
62
|
+
from firecube.ingestor.runtime.zarr.existing_cube_check import verify_dim_compatibility
|
|
63
|
+
from firecube.ingestor.templates.config import TemplateConfig
|
|
64
|
+
from firecube.ingestor.types.config import PluginConfig
|
|
65
|
+
from firecube.ingestor.types.context import (
|
|
66
|
+
IngestContext,
|
|
67
|
+
IngestResult,
|
|
68
|
+
PipelineBatch,
|
|
69
|
+
PipelineResult,
|
|
70
|
+
PipelineRunState,
|
|
71
|
+
PluginContext,
|
|
72
|
+
RuntimeFlags,
|
|
73
|
+
RuntimeIngestContext,
|
|
74
|
+
)
|
|
75
|
+
from firecube.ingestor.types.result_metrics import OutputPaths
|
|
76
|
+
from firecube.ingestor.validation import validate_config_collisions, validate_product_name_contract
|
|
77
|
+
|
|
78
|
+
__all__ = [
|
|
79
|
+
"BaseIngestor",
|
|
80
|
+
"LocalSourceFile",
|
|
81
|
+
]
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _output_storage_session(
|
|
85
|
+
ctx: IngestContext | RuntimeIngestContext | PluginContext,
|
|
86
|
+
) -> Any | None:
|
|
87
|
+
return storage.output if (storage := getattr(ctx, "storage", None)) is not None else None
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _output_product_name(
|
|
91
|
+
ctx: IngestContext | RuntimeIngestContext | PluginContext, default: str
|
|
92
|
+
) -> str:
|
|
93
|
+
if (session := _output_storage_session(ctx)) is not None:
|
|
94
|
+
return str(session.product.product_name)
|
|
95
|
+
return default
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _maybe_run_storage_preflight(
|
|
99
|
+
runtime_ctx: RuntimeIngestContext, engine_config: EngineConfig
|
|
100
|
+
) -> None:
|
|
101
|
+
if engine_config.skip_preflight:
|
|
102
|
+
return
|
|
103
|
+
|
|
104
|
+
storage = runtime_ctx.storage
|
|
105
|
+
if storage is not None and storage.output is not None:
|
|
106
|
+
preflight(storage.output)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _select_output_format_strategy(ingestor: Any, ctx: IngestContext) -> None:
|
|
110
|
+
"""Wire output-format-specific template config and batch strategy."""
|
|
111
|
+
if str(ctx.output_format or "").lower() != "tensogram":
|
|
112
|
+
return
|
|
113
|
+
if not is_dataset_producer(type(ingestor)):
|
|
114
|
+
raise ConfigurationError(
|
|
115
|
+
f"{type(ingestor).__name__} does not implement DatasetProducer; "
|
|
116
|
+
"cannot use output_format='tensogram'."
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
from firecube.ingestor.runtime.configure import TierConfigurator
|
|
120
|
+
from firecube.ingestor.templates.generic_tensogram import bind_tensogram_strategy
|
|
121
|
+
|
|
122
|
+
bind_tensogram_strategy(ingestor)
|
|
123
|
+
routed_ingestor = cast(Any, ingestor)
|
|
124
|
+
routed_ingestor._configurator = TierConfigurator(
|
|
125
|
+
routed_ingestor.template_config_class,
|
|
126
|
+
routed_ingestor.plugin_config_class,
|
|
127
|
+
plugin_name=routed_ingestor.name,
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _storage_uri_from_raw(raw: str) -> StorageUri:
|
|
132
|
+
if "://" in raw:
|
|
133
|
+
return StorageUri.parse(raw)
|
|
134
|
+
return StorageUri.from_local_path(Path(raw).expanduser().resolve())
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def _minimal_binding(product: str, fmt: str = "zarr") -> StorageBinding:
|
|
138
|
+
import tempfile
|
|
139
|
+
|
|
140
|
+
root = Path(tempfile.mkdtemp(prefix="firecube_controlplane_"))
|
|
141
|
+
uri = StorageUri.from_local_path((root / product).resolve())
|
|
142
|
+
return StorageBinding(
|
|
143
|
+
identity=ProductIdentity.from_uri(uri, fmt, product_name=product),
|
|
144
|
+
driver=StorageDriverConfig.from_storage_config_or_default(None),
|
|
145
|
+
)
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def _binding_from_context(ctx: IngestContext, product: str) -> StorageBinding:
|
|
149
|
+
session = _output_storage_session(ctx)
|
|
150
|
+
if session is not None:
|
|
151
|
+
return StorageBinding(identity=session.product, driver=session.driver)
|
|
152
|
+
|
|
153
|
+
fmt = ctx.output_format or "zarr"
|
|
154
|
+
if ctx.target:
|
|
155
|
+
return StorageBinding(
|
|
156
|
+
identity=ProductIdentity.from_uri(
|
|
157
|
+
_storage_uri_from_raw(str(ctx.target)),
|
|
158
|
+
fmt,
|
|
159
|
+
product_name=product,
|
|
160
|
+
),
|
|
161
|
+
driver=StorageDriverConfig.from_storage_config_or_default(None),
|
|
162
|
+
)
|
|
163
|
+
|
|
164
|
+
return _minimal_binding(product, fmt)
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
class BaseIngestor(BaseIngestorHookMixin, Ingestor, ABC):
|
|
168
|
+
"""Orchestrating base class for all Firecube ingestors.
|
|
169
|
+
|
|
170
|
+
``BaseIngestor`` is a composition facade. It wires together the runtime
|
|
171
|
+
services (batching, telemetry, recording, workspace, resume-guard) and
|
|
172
|
+
exposes a hook surface so that plugin authors only need to implement their
|
|
173
|
+
domain logic.
|
|
174
|
+
|
|
175
|
+
``run()`` delegates to:
|
|
176
|
+
- ``BatchPlanner`` — creates ``PipelineBatch`` objects from items.
|
|
177
|
+
- ``TierConfigurator`` — splits flat options into EngineConfig /
|
|
178
|
+
TemplateConfig / PluginConfig options tiers
|
|
179
|
+
(not storage layers; storage I/O wiring is
|
|
180
|
+
handled by StorageBinding / StorageSession).
|
|
181
|
+
- ``WorkspaceManager`` — manages per-run temp directories.
|
|
182
|
+
- ``ResumeGuard`` — enforces resume / overwrite safety.
|
|
183
|
+
- ``TelemetryService`` — wraps the telemetry sink with metric limits.
|
|
184
|
+
- ``SpanRecorder`` — writes manifest entries to ChunkManager.
|
|
185
|
+
- ``PipelineExecutor`` — parallel or sequential batch dispatch.
|
|
186
|
+
|
|
187
|
+
Hooks fall into three categories. The class-variable ``name`` is also
|
|
188
|
+
required.
|
|
189
|
+
|
|
190
|
+
MUST override (abstract):
|
|
191
|
+
``_process_batch(batch, ctx) -> PipelineResult``
|
|
192
|
+
Core per-batch logic. Called in worker threads for parallel mode.
|
|
193
|
+
Templates (``GenericZarrIngestor``, ``GenericParquetIngestor``)
|
|
194
|
+
implement this; plain plugins may override it directly.
|
|
195
|
+
|
|
196
|
+
SHOULD override (return empty defaults; rarely correct without change):
|
|
197
|
+
``discover_source_files(ctx) -> Iterable``
|
|
198
|
+
Discovers source files to be batched (paths/URIs/objects) using
|
|
199
|
+
configured include patterns.
|
|
200
|
+
|
|
201
|
+
CAN override (optional lifecycle / metadata hooks):
|
|
202
|
+
``filter_item(item, ctx) -> bool``
|
|
203
|
+
Per-item filter applied before batching. Default: keep all.
|
|
204
|
+
``item_size_bytes(item) -> int | None``
|
|
205
|
+
Used for batch size estimation. Default: ``None``.
|
|
206
|
+
``get_batch_groups(items, ctx) -> list[str]``
|
|
207
|
+
Derives logical output groups for a batch. Default: ``['default']``.
|
|
208
|
+
``slice_meta_keys() -> list[str]``
|
|
209
|
+
Option keys that identify a logical "slice" (used for resume
|
|
210
|
+
conflict detection). Default: empty list.
|
|
211
|
+
``validation_group(ctx) -> str | None``
|
|
212
|
+
Zarr group path used by the resume-guard's ``validate_zarr`` check.
|
|
213
|
+
``on_pipeline_start(ctx, state)``
|
|
214
|
+
Called once before any batch runs. Useful for shared resource
|
|
215
|
+
setup (e.g. persistent DuckDB schema).
|
|
216
|
+
``on_batch_success(ctx, state, batch, result)``
|
|
217
|
+
Called on the main thread after each successful batch.
|
|
218
|
+
``on_batch_failure(ctx, state, batch, result)``
|
|
219
|
+
Called on the main thread after each failed batch.
|
|
220
|
+
DO NOT override (framework internals):
|
|
221
|
+
``run(ctx)`` — top-level orchestration entry point.
|
|
222
|
+
``_create_batches`` — delegates to ``BatchPlanner``.
|
|
223
|
+
``finalize_pipeline`` — delegates to ``PipelineExecutor``.
|
|
224
|
+
"""
|
|
225
|
+
|
|
226
|
+
PRODUCT_NAME: ClassVar[str]
|
|
227
|
+
# Firecube's append/index dimension name as written into the Zarr store.
|
|
228
|
+
# Default 'timestamp' preserves back-compat.
|
|
229
|
+
# Plugins override on their subclass for CF-1.8 conventional naming (e.g.
|
|
230
|
+
# `time_dim_name: ClassVar[str] = 'time'`). NOT a config-tier field,
|
|
231
|
+
# NOT exposed via --option.
|
|
232
|
+
time_dim_name: ClassVar[str] = "timestamp"
|
|
233
|
+
name: str
|
|
234
|
+
|
|
235
|
+
# Configuration Tiers (Declarative)
|
|
236
|
+
template_config_class: type[TemplateConfig] | None = None
|
|
237
|
+
plugin_config_class: type[PluginConfig] | None = None
|
|
238
|
+
|
|
239
|
+
# Instance Configs (Populated in run())
|
|
240
|
+
engine_config: EngineConfig
|
|
241
|
+
template_config: TemplateConfig | None = None
|
|
242
|
+
plugin_config: PluginConfig | None = None
|
|
243
|
+
|
|
244
|
+
def __init_subclass__(cls, **kwargs: Any) -> None:
|
|
245
|
+
"""Build-time validation for ingestor class contracts."""
|
|
246
|
+
super().__init_subclass__(**kwargs)
|
|
247
|
+
|
|
248
|
+
validate_product_name_contract(cls)
|
|
249
|
+
|
|
250
|
+
e_keys = config_keys(EngineConfig)
|
|
251
|
+
t_keys = config_keys(cls.template_config_class) if cls.template_config_class else set()
|
|
252
|
+
p_keys = config_keys(cls.plugin_config_class) if cls.plugin_config_class else set()
|
|
253
|
+
|
|
254
|
+
if t_keys and p_keys:
|
|
255
|
+
validate_config_collisions(
|
|
256
|
+
dict.fromkeys(t_keys),
|
|
257
|
+
dict.fromkeys(p_keys),
|
|
258
|
+
scope=f"Template/Plugin Config in {cls.__name__}",
|
|
259
|
+
)
|
|
260
|
+
|
|
261
|
+
if t_keys:
|
|
262
|
+
validate_config_collisions(
|
|
263
|
+
dict.fromkeys(e_keys),
|
|
264
|
+
dict.fromkeys(t_keys),
|
|
265
|
+
scope=f"Engine/Template Config in {cls.__name__}",
|
|
266
|
+
)
|
|
267
|
+
|
|
268
|
+
if p_keys:
|
|
269
|
+
validate_config_collisions(
|
|
270
|
+
dict.fromkeys(e_keys),
|
|
271
|
+
dict.fromkeys(p_keys),
|
|
272
|
+
scope=f"Engine/Plugin Config in {cls.__name__}",
|
|
273
|
+
)
|
|
274
|
+
|
|
275
|
+
def _resolve_time_dim_name(self) -> str:
|
|
276
|
+
return type(self).time_dim_name
|
|
277
|
+
|
|
278
|
+
@classmethod
|
|
279
|
+
def describe_options(cls) -> dict[str, list[str]]:
|
|
280
|
+
"""Return structured options documentation for CLI introspection."""
|
|
281
|
+
resolved_product_name = getattr(cls, "PRODUCT_NAME", None)
|
|
282
|
+
if isinstance(resolved_product_name, str) and resolved_product_name:
|
|
283
|
+
product_name_entry: list[str] = [resolved_product_name]
|
|
284
|
+
else:
|
|
285
|
+
product_name_entry = []
|
|
286
|
+
info = {
|
|
287
|
+
"Product Name": product_name_entry,
|
|
288
|
+
"Engine Options": sorted(config_keys(EngineConfig)),
|
|
289
|
+
"System Keys": sorted(SYSTEM_KEYS),
|
|
290
|
+
}
|
|
291
|
+
if cls.template_config_class:
|
|
292
|
+
info["Template Options"] = sorted(config_keys(cls.template_config_class))
|
|
293
|
+
if cls.plugin_config_class:
|
|
294
|
+
info["Plugin Options"] = sorted(config_keys(cls.plugin_config_class))
|
|
295
|
+
return info
|
|
296
|
+
|
|
297
|
+
def __init__(self, *, name: str | None = None, chunk_manager: ChunkManager | None = None):
|
|
298
|
+
self.name = name or getattr(self, "name", self.__class__.__name__.lower())
|
|
299
|
+
self._log = logging.getLogger(f"firecube.ingestor.{self.name}")
|
|
300
|
+
|
|
301
|
+
if chunk_manager is not None:
|
|
302
|
+
self._chunk_manager = chunk_manager
|
|
303
|
+
self._owns_chunk_manager = False
|
|
304
|
+
else:
|
|
305
|
+
self._chunk_manager = ChunkManager(binding=_minimal_binding(self.name))
|
|
306
|
+
self._owns_chunk_manager = True
|
|
307
|
+
|
|
308
|
+
# Composition
|
|
309
|
+
self._workspace = WorkspaceManager(self.name)
|
|
310
|
+
self._configurator = TierConfigurator(
|
|
311
|
+
self.template_config_class,
|
|
312
|
+
self.plugin_config_class,
|
|
313
|
+
plugin_name=self.name,
|
|
314
|
+
)
|
|
315
|
+
self._batch_planner = BatchPlanner()
|
|
316
|
+
self._span_recorder = SpanRecorder(self._chunk_manager)
|
|
317
|
+
|
|
318
|
+
self._firecube_engine = None # Lazy init for PipelineExecutor
|
|
319
|
+
self._parallel_execution_state: _ParallelExecutionState | None = None
|
|
320
|
+
# True once ensure_slot_index_model has succeeded in this pod process;
|
|
321
|
+
# fast-path in _ensure_slot_index_model_at_startup.
|
|
322
|
+
self._slot_index_model_stamped: bool = False
|
|
323
|
+
|
|
324
|
+
# --- BatchPlanHost Protocol Implementation ---
|
|
325
|
+
|
|
326
|
+
@property
|
|
327
|
+
def batch_id_prefix(self) -> str:
|
|
328
|
+
return f"{self.name}_"
|
|
329
|
+
|
|
330
|
+
def discover_source_files(self, ctx: PluginContext) -> Iterable[Any]:
|
|
331
|
+
"""Discover source files matching include patterns."""
|
|
332
|
+
if not ctx.source:
|
|
333
|
+
raise ConfigurationError(
|
|
334
|
+
"--input-data is required when using default source discovery. "
|
|
335
|
+
"Override discover_source_files() in your plugin if your "
|
|
336
|
+
"ingestor does not consume an input path."
|
|
337
|
+
)
|
|
338
|
+
includes = None
|
|
339
|
+
if getattr(self, "engine_config", None) is not None:
|
|
340
|
+
includes = getattr(self.engine_config, "include_patterns", None)
|
|
341
|
+
|
|
342
|
+
files = discover_input_files(
|
|
343
|
+
ctx.source,
|
|
344
|
+
storage_config=self._chunk_manager.storage_config,
|
|
345
|
+
preferred_globs=includes,
|
|
346
|
+
recursive=True,
|
|
347
|
+
)
|
|
348
|
+
|
|
349
|
+
if not files:
|
|
350
|
+
self._log.warning("No input files found in %s", ctx.source)
|
|
351
|
+
return iter(())
|
|
352
|
+
|
|
353
|
+
self._log.info("Found %d files", len(files))
|
|
354
|
+
return files
|
|
355
|
+
|
|
356
|
+
def filter_item(self, item: Any, ctx: PluginContext) -> bool:
|
|
357
|
+
"""Filter items before batching (Hook). Default: True."""
|
|
358
|
+
return True
|
|
359
|
+
|
|
360
|
+
def item_size_bytes(self, item: Any) -> int | None:
|
|
361
|
+
"""Estimate size of an item in bytes."""
|
|
362
|
+
try:
|
|
363
|
+
return Path(item).stat().st_size
|
|
364
|
+
except (TypeError, OSError):
|
|
365
|
+
return None
|
|
366
|
+
|
|
367
|
+
def get_batch_groups(self, items: Sequence[Any], ctx: PluginContext) -> list[str]:
|
|
368
|
+
"""Return logical write groups for a batch of items (Hook). Default: ['default']."""
|
|
369
|
+
return ["default"]
|
|
370
|
+
|
|
371
|
+
def batch_setup(self, ctx: PluginContext) -> None:
|
|
372
|
+
"""Hook for per-batch setup (e.g. DB connections). Cooperatively calls super."""
|
|
373
|
+
# Intentional cooperative super call:
|
|
374
|
+
# this base can be used with or without mixins defining batch_setup().
|
|
375
|
+
# Missing parent hook is treated as a no-op by design.
|
|
376
|
+
super_setup = getattr(super(), "batch_setup", None)
|
|
377
|
+
if callable(super_setup):
|
|
378
|
+
super_setup(ctx)
|
|
379
|
+
|
|
380
|
+
def batch_teardown(self, ctx: PluginContext) -> None:
|
|
381
|
+
"""Hook for per-batch cleanup. Cooperatively calls super."""
|
|
382
|
+
# Same cooperative behavior as batch_setup(); keep teardown chain optional.
|
|
383
|
+
super_teardown = getattr(super(), "batch_teardown", None)
|
|
384
|
+
if callable(super_teardown):
|
|
385
|
+
super_teardown(ctx)
|
|
386
|
+
|
|
387
|
+
def prepare_batch_data(self, batch: PipelineBatch, ctx: PluginContext) -> dict[str, Any] | None:
|
|
388
|
+
"""Optional hook to prepare data (e.g. load files to DB) before group iteration."""
|
|
389
|
+
return None
|
|
390
|
+
|
|
391
|
+
def cleanup_batch_data(self, batch: PipelineBatch, ctx: PluginContext) -> None:
|
|
392
|
+
"""Optional hook to clean up batch data (e.g. drop table rows)."""
|
|
393
|
+
|
|
394
|
+
def _materialize(self, source: Any) -> Path:
|
|
395
|
+
return self._workspace.materialize(source)
|
|
396
|
+
|
|
397
|
+
# --- PipelineHost Implementation (Facade) ---
|
|
398
|
+
|
|
399
|
+
def _create_batches(
|
|
400
|
+
self, ctx: RuntimeIngestContext, batch_size: int
|
|
401
|
+
) -> Iterable[PipelineBatch]:
|
|
402
|
+
"""Delegate batch creation to BatchPlanner."""
|
|
403
|
+
host = cast(BatchPlanHost, self)
|
|
404
|
+
for batch in self._batch_planner.create_batches(host, PluginContext(ctx), batch_size):
|
|
405
|
+
self._verify_existing_cube_batch_groups(ctx, batch.groups)
|
|
406
|
+
yield batch
|
|
407
|
+
|
|
408
|
+
def _verify_existing_cube_batch_groups(
|
|
409
|
+
self, ctx: RuntimeIngestContext, group_paths: Sequence[str]
|
|
410
|
+
) -> None:
|
|
411
|
+
if str(ctx.output_format or "").lower() != "zarr":
|
|
412
|
+
return
|
|
413
|
+
verify_dim_compatibility(
|
|
414
|
+
target_uri=str(ctx.target or ""),
|
|
415
|
+
declared_dim=self._resolve_time_dim_name(),
|
|
416
|
+
group_paths=group_paths,
|
|
417
|
+
storage_config=self._chunk_manager.storage_config,
|
|
418
|
+
)
|
|
419
|
+
|
|
420
|
+
def finalize_pipeline(self, ctx: RuntimeIngestContext, state: PipelineRunState) -> IngestResult:
|
|
421
|
+
"""Delegate finalization to PipelineExecutor."""
|
|
422
|
+
if self._firecube_engine:
|
|
423
|
+
return self._firecube_engine.finalize(ctx, state, self)
|
|
424
|
+
|
|
425
|
+
# Fallback if engine not permanently attached (e.g. strict sequential usage or tests)
|
|
426
|
+
from firecube.ingestor.runtime.engine import PipelineExecutor
|
|
427
|
+
|
|
428
|
+
return PipelineExecutor().finalize(ctx, state, self)
|
|
429
|
+
|
|
430
|
+
# --- Execution Logic ---
|
|
431
|
+
|
|
432
|
+
@abstractmethod
|
|
433
|
+
def _process_batch(self, batch: PipelineBatch, ctx: PluginContext) -> PipelineResult:
|
|
434
|
+
"""Process a single batch. Called in worker threads for parallel execution.
|
|
435
|
+
|
|
436
|
+
Subclasses must implement this. Templates (``GenericZarrIngestor``,
|
|
437
|
+
``GenericParquetIngestor``) provide a default implementation via their
|
|
438
|
+
own ``build_dataset`` abstract hook.
|
|
439
|
+
"""
|
|
440
|
+
|
|
441
|
+
@staticmethod
|
|
442
|
+
def default_aggregate_metrics(
|
|
443
|
+
ctx: RuntimeIngestContext, state: PipelineRunState
|
|
444
|
+
) -> dict[str, Any]:
|
|
445
|
+
"""Default aggregate helper for plugins that do not need custom policy."""
|
|
446
|
+
return merge_batch_metrics(ctx, state)
|
|
447
|
+
|
|
448
|
+
def _aggregate_metrics(
|
|
449
|
+
self, ctx: RuntimeIngestContext, state: PipelineRunState
|
|
450
|
+
) -> Mapping[str, Any]:
|
|
451
|
+
"""Use standard batch-metric merge policy unless plugin overrides it."""
|
|
452
|
+
return merge_batch_metrics(ctx, state)
|
|
453
|
+
|
|
454
|
+
def _verify_schema_at_pod_startup(self, ctx: PluginContext) -> None:
|
|
455
|
+
"""Optional lifecycle hook for per-pod schema verification before planning."""
|
|
456
|
+
_ = ctx
|
|
457
|
+
return
|
|
458
|
+
|
|
459
|
+
def _ensure_slot_index_model_at_startup(self, ctx: PluginContext) -> None:
|
|
460
|
+
"""Optional lifecycle hook for per-pod slot-index model negotiation."""
|
|
461
|
+
_ = ctx
|
|
462
|
+
return
|
|
463
|
+
|
|
464
|
+
def _validate_context_hook_signatures(self) -> None:
|
|
465
|
+
"""Enforce PluginContext vs RuntimeIngestContext boundary on subclass hooks.
|
|
466
|
+
|
|
467
|
+
Plugin-facing hooks must annotate ``ctx: PluginContext``.
|
|
468
|
+
Runtime-finalization hooks must annotate ``ctx: RuntimeIngestContext``.
|
|
469
|
+
Mismatches raise ConfigurationError with the expected type.
|
|
470
|
+
"""
|
|
471
|
+
|
|
472
|
+
checks: dict[str, type[Any]] = {
|
|
473
|
+
"discover_source_files": PluginContext,
|
|
474
|
+
"filter_item": PluginContext,
|
|
475
|
+
"batch_setup": PluginContext,
|
|
476
|
+
"batch_teardown": PluginContext,
|
|
477
|
+
"prepare_batch_data": PluginContext,
|
|
478
|
+
"cleanup_batch_data": PluginContext,
|
|
479
|
+
"build_dataset": PluginContext,
|
|
480
|
+
"get_batch_groups": PluginContext,
|
|
481
|
+
"get_zarr_config": PluginContext,
|
|
482
|
+
"output_relpath": PluginContext,
|
|
483
|
+
"write_parquet": PluginContext,
|
|
484
|
+
"prepare_duckdb_schema": PluginContext,
|
|
485
|
+
"slice_meta": PluginContext,
|
|
486
|
+
"validation_group": PluginContext,
|
|
487
|
+
"on_pipeline_start": PluginContext,
|
|
488
|
+
"on_batch_success": PluginContext,
|
|
489
|
+
"on_batch_failure": PluginContext,
|
|
490
|
+
"_process_batch": PluginContext,
|
|
491
|
+
"_aggregate_metrics": RuntimeIngestContext,
|
|
492
|
+
"finalize_pipeline": RuntimeIngestContext,
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
cls = type(self)
|
|
496
|
+
for method_name, expected_ctx_type in checks.items():
|
|
497
|
+
method = getattr(cls, method_name, None)
|
|
498
|
+
if method is None or not callable(method):
|
|
499
|
+
continue
|
|
500
|
+
|
|
501
|
+
try:
|
|
502
|
+
sig = inspect.signature(method)
|
|
503
|
+
except (TypeError, ValueError):
|
|
504
|
+
continue
|
|
505
|
+
if "ctx" not in sig.parameters:
|
|
506
|
+
continue
|
|
507
|
+
|
|
508
|
+
try:
|
|
509
|
+
hints = get_type_hints(method)
|
|
510
|
+
except Exception:
|
|
511
|
+
hints = {}
|
|
512
|
+
ctx_hint = hints.get("ctx")
|
|
513
|
+
if ctx_hint is None:
|
|
514
|
+
continue
|
|
515
|
+
|
|
516
|
+
if expected_ctx_type is PluginContext and ctx_hint is RuntimeIngestContext:
|
|
517
|
+
raise ConfigurationError(
|
|
518
|
+
f"{cls.__name__}.{method_name} must use PluginContext, not RuntimeIngestContext."
|
|
519
|
+
)
|
|
520
|
+
if expected_ctx_type is RuntimeIngestContext and ctx_hint is PluginContext:
|
|
521
|
+
raise ConfigurationError(
|
|
522
|
+
f"{cls.__name__}.{method_name} must use RuntimeIngestContext, not PluginContext."
|
|
523
|
+
)
|
|
524
|
+
|
|
525
|
+
# --- Orchestration ---
|
|
526
|
+
|
|
527
|
+
def run(self, ctx: IngestContext) -> IngestResult:
|
|
528
|
+
"""Orchestrate the ingestion.
|
|
529
|
+
|
|
530
|
+
The caller-provided ``IngestContext`` is treated as immutable input.
|
|
531
|
+
Runtime-only fields are carried in an internal ``RuntimeIngestContext``
|
|
532
|
+
clone for the duration of this run.
|
|
533
|
+
|
|
534
|
+
``ctx.storage.output`` must already be populated by the caller
|
|
535
|
+
(CLI/SDK boundary) before invoking ``run()``; this method does not
|
|
536
|
+
auto-construct sessions.
|
|
537
|
+
"""
|
|
538
|
+
if ctx.storage is None or ctx.storage.output is None:
|
|
539
|
+
raise ValueError(
|
|
540
|
+
"IngestContext.storage.output must be set before calling run(). "
|
|
541
|
+
"Use ProductResolver + StorageBinding + StorageSession to build "
|
|
542
|
+
"the context at the CLI/SDK boundary."
|
|
543
|
+
)
|
|
544
|
+
|
|
545
|
+
self._validate_context_hook_signatures()
|
|
546
|
+
|
|
547
|
+
_select_output_format_strategy(self, ctx)
|
|
548
|
+
|
|
549
|
+
product = _output_product_name(ctx, self.name)
|
|
550
|
+
if self._owns_chunk_manager:
|
|
551
|
+
self._chunk_manager = ChunkManager(binding=_binding_from_context(ctx, product))
|
|
552
|
+
self._span_recorder = SpanRecorder(self._chunk_manager)
|
|
553
|
+
ensure_chunk_manager_config(self._chunk_manager, ctx)
|
|
554
|
+
self._workspace._storage_config = self._chunk_manager.storage_config
|
|
555
|
+
|
|
556
|
+
# 1. Workspace Cleanup/Setup
|
|
557
|
+
workspace_root = self._workspace.setup(ctx)
|
|
558
|
+
|
|
559
|
+
# Resolve run_id without mutating the caller context.
|
|
560
|
+
_base_run_id = ensure_run_id(ctx=ctx, plugin_name=self.name)
|
|
561
|
+
|
|
562
|
+
# Create a runtime-enriched copy for use within this run.
|
|
563
|
+
runtime_ctx = RuntimeIngestContext.from_ingest_context(
|
|
564
|
+
ctx,
|
|
565
|
+
run_id=_base_run_id,
|
|
566
|
+
temp_root=workspace_root,
|
|
567
|
+
materializer=self._materialize,
|
|
568
|
+
)
|
|
569
|
+
runtime_plugin_ctx = PluginContext(runtime_ctx)
|
|
570
|
+
|
|
571
|
+
# 2. Configuration
|
|
572
|
+
self.engine_config, self.template_config, self.plugin_config = self._configurator.configure(
|
|
573
|
+
runtime_ctx
|
|
574
|
+
)
|
|
575
|
+
runtime_ctx.flags = RuntimeFlags(
|
|
576
|
+
force_reingest=bool(self.engine_config.force_reingest),
|
|
577
|
+
incremental=bool(self.engine_config.incremental),
|
|
578
|
+
dry_run=bool(self.engine_config.dry_run),
|
|
579
|
+
)
|
|
580
|
+
|
|
581
|
+
# 2a. Derive slot-aware run_id for parallel mode (single-pod returns base unchanged).
|
|
582
|
+
run_id = derive_pod_run_id(
|
|
583
|
+
_base_run_id,
|
|
584
|
+
self.engine_config.slot_start,
|
|
585
|
+
self.engine_config.slot_end,
|
|
586
|
+
slot_group=self.engine_config.slot_group,
|
|
587
|
+
)
|
|
588
|
+
if run_id != _base_run_id:
|
|
589
|
+
runtime_ctx.run_id = run_id
|
|
590
|
+
runtime_ctx.identity.run_id = run_id
|
|
591
|
+
runtime_ctx.options["run_id"] = run_id
|
|
592
|
+
runtime_plugin_ctx = PluginContext(runtime_ctx)
|
|
593
|
+
|
|
594
|
+
_maybe_run_storage_preflight(runtime_ctx, self.engine_config)
|
|
595
|
+
|
|
596
|
+
# 3. Telemetry Setup
|
|
597
|
+
# BaseIngestor always uses TelemetryService for unified limits
|
|
598
|
+
if getattr(runtime_ctx, "telemetry", None) is None:
|
|
599
|
+
runtime_ctx.telemetry = create_ingestion_telemetry(
|
|
600
|
+
plugin=self.name,
|
|
601
|
+
product=product,
|
|
602
|
+
output_format=str(runtime_ctx.output_format or ""),
|
|
603
|
+
write_mode=str(getattr(self.engine_config, "write_mode", "")),
|
|
604
|
+
run_id=run_id,
|
|
605
|
+
base_meta=self.slice_meta(runtime_plugin_ctx),
|
|
606
|
+
)
|
|
607
|
+
assert runtime_ctx.telemetry is not None
|
|
608
|
+
|
|
609
|
+
telemetry_service = TelemetryService(runtime_ctx.telemetry, self.name)
|
|
610
|
+
telemetry_service.start(run_id)
|
|
611
|
+
fs_metrics = wal_metrics = None
|
|
612
|
+
resume_guard: ResumeGuard | None = None
|
|
613
|
+
execution_result = IngestResult(
|
|
614
|
+
outputs=OutputPaths(primary=str(runtime_ctx.target or "")),
|
|
615
|
+
output_format=str(runtime_ctx.output_format or ""),
|
|
616
|
+
)
|
|
617
|
+
run_started_recorded = False
|
|
618
|
+
|
|
619
|
+
try:
|
|
620
|
+
ingest_span = (
|
|
621
|
+
runtime_ctx.telemetry.span(
|
|
622
|
+
"firecube.ingest",
|
|
623
|
+
attributes={
|
|
624
|
+
"firecube.run_id": run_id,
|
|
625
|
+
"firecube.plugin": self.name,
|
|
626
|
+
"firecube.product": product,
|
|
627
|
+
},
|
|
628
|
+
)
|
|
629
|
+
or contextlib.nullcontext()
|
|
630
|
+
)
|
|
631
|
+
with (
|
|
632
|
+
collect_filesystem_metrics() as fs_metrics,
|
|
633
|
+
collect_wal_metrics() as wal_metrics,
|
|
634
|
+
ingest_span,
|
|
635
|
+
):
|
|
636
|
+
# 3.5 Capability gate for slot-range parallel ingestion
|
|
637
|
+
from firecube.ingestor.runtime.parallel_gate import validate_parallel_capability
|
|
638
|
+
|
|
639
|
+
parallel_global_schema = validate_parallel_capability(
|
|
640
|
+
ingestor=self,
|
|
641
|
+
slot_start=self.engine_config.slot_start,
|
|
642
|
+
slot_end=self.engine_config.slot_end,
|
|
643
|
+
ctx=runtime_plugin_ctx,
|
|
644
|
+
slot_group=self.engine_config.slot_group,
|
|
645
|
+
)
|
|
646
|
+
self._parallel_execution_state = (
|
|
647
|
+
_ParallelExecutionState(global_expected=parallel_global_schema)
|
|
648
|
+
if parallel_global_schema is not None
|
|
649
|
+
else None
|
|
650
|
+
)
|
|
651
|
+
|
|
652
|
+
# 4. Resume Guard
|
|
653
|
+
slot_range_for_record: tuple[int, int] | None = (
|
|
654
|
+
(self.engine_config.slot_start, self.engine_config.slot_end)
|
|
655
|
+
if self.engine_config.slot_start is not None
|
|
656
|
+
and self.engine_config.slot_end is not None
|
|
657
|
+
else None
|
|
658
|
+
)
|
|
659
|
+
guard = ResumeGuard(
|
|
660
|
+
plugin_name=self.name,
|
|
661
|
+
chunk_manager=self._chunk_manager,
|
|
662
|
+
log=self._log,
|
|
663
|
+
slice_meta_keys=self.slice_meta_keys(),
|
|
664
|
+
)
|
|
665
|
+
resume_guard = guard
|
|
666
|
+
guard.enforce(
|
|
667
|
+
ctx=runtime_ctx,
|
|
668
|
+
product=product,
|
|
669
|
+
slice_meta=self.slice_meta(runtime_plugin_ctx),
|
|
670
|
+
slot_range=slot_range_for_record,
|
|
671
|
+
slot_group=self.engine_config.slot_group,
|
|
672
|
+
validation_group=self.validation_group(runtime_plugin_ctx),
|
|
673
|
+
)
|
|
674
|
+
|
|
675
|
+
# 5. Runtime control-plane lifecycle
|
|
676
|
+
slice_meta = self.slice_meta(runtime_plugin_ctx)
|
|
677
|
+
slice_meta.setdefault("plugin", self.name)
|
|
678
|
+
self._span_recorder.register_run_started(
|
|
679
|
+
run_id=run_id,
|
|
680
|
+
product=product,
|
|
681
|
+
output_path=str(runtime_ctx.target or ""),
|
|
682
|
+
output_format=str(runtime_ctx.output_format or ""),
|
|
683
|
+
slice_meta=slice_meta,
|
|
684
|
+
slot_range=slot_range_for_record,
|
|
685
|
+
slot_group=self.engine_config.slot_group,
|
|
686
|
+
)
|
|
687
|
+
run_started_recorded = True
|
|
688
|
+
|
|
689
|
+
self._ensure_slot_index_model_at_startup(runtime_plugin_ctx)
|
|
690
|
+
self._verify_schema_at_pod_startup(runtime_plugin_ctx)
|
|
691
|
+
|
|
692
|
+
# 6. Planning
|
|
693
|
+
mode = determine_execution_mode(self.engine_config)
|
|
694
|
+
# Sequential falls back to pipeline with 0/1 workers pattern or distinct loop?
|
|
695
|
+
# Using BatchPlanner for both ensuring consistency.
|
|
696
|
+
|
|
697
|
+
if self._firecube_engine is None:
|
|
698
|
+
from firecube.ingestor.runtime.engine import PipelineExecutor
|
|
699
|
+
|
|
700
|
+
self._firecube_engine = PipelineExecutor()
|
|
701
|
+
|
|
702
|
+
execution_result: IngestResult
|
|
703
|
+
|
|
704
|
+
if mode == ExecutionMode.PIPELINE:
|
|
705
|
+
execution_result = self._firecube_engine.run_pipeline(runtime_ctx, host=self)
|
|
706
|
+
else:
|
|
707
|
+
state = run_sequential(
|
|
708
|
+
ctx=runtime_ctx,
|
|
709
|
+
host=self,
|
|
710
|
+
product=product,
|
|
711
|
+
batch_size=int(self.engine_config.pipeline_batch_size),
|
|
712
|
+
engine_config=self.engine_config,
|
|
713
|
+
log=self._log,
|
|
714
|
+
)
|
|
715
|
+
if not state.batches:
|
|
716
|
+
execution_result = IngestResult(
|
|
717
|
+
outputs=OutputPaths(primary=str(runtime_ctx.target)),
|
|
718
|
+
output_format=str(runtime_ctx.output_format or ""),
|
|
719
|
+
metrics={},
|
|
720
|
+
)
|
|
721
|
+
else:
|
|
722
|
+
execution_result = self._firecube_engine.finalize(runtime_ctx, state, self)
|
|
723
|
+
|
|
724
|
+
# 7. Output completion (storage write + manifest)
|
|
725
|
+
#
|
|
726
|
+
# Original ordering (buggy for staged uploads):
|
|
727
|
+
# if not execution_result.registered:
|
|
728
|
+
# self._span_recorder.register_run(...)
|
|
729
|
+
# execution_result = self._firecube_engine.complete_output(...)
|
|
730
|
+
#
|
|
731
|
+
# That let the WAL record terminal status="complete" before the
|
|
732
|
+
# staged upload actually finished. If complete_output() then raised,
|
|
733
|
+
# the exception path skipped register_run_failure() because
|
|
734
|
+
# execution_result.registered was already True.
|
|
735
|
+
execution_result = self._firecube_engine.complete_output(
|
|
736
|
+
execution_result, runtime_ctx, host=self
|
|
737
|
+
)
|
|
738
|
+
|
|
739
|
+
# 8. Recording
|
|
740
|
+
if not execution_result.registered:
|
|
741
|
+
self._span_recorder.register_run(
|
|
742
|
+
ctx=runtime_ctx,
|
|
743
|
+
result=execution_result,
|
|
744
|
+
run_id=run_id,
|
|
745
|
+
product=product,
|
|
746
|
+
slice_meta=slice_meta,
|
|
747
|
+
record_spans=not execution_result.spans_recorded,
|
|
748
|
+
)
|
|
749
|
+
|
|
750
|
+
return execution_result
|
|
751
|
+
except Exception as exc:
|
|
752
|
+
# Record a terminal failure only for runs that reached "started" but
|
|
753
|
+
# never reached a terminal WAL event. After the output-completion
|
|
754
|
+
# reorder above, execution_result.registered becomes True only after
|
|
755
|
+
# both complete_output() and register_run() succeed.
|
|
756
|
+
if run_started_recorded and not execution_result.registered:
|
|
757
|
+
with contextlib.suppress(Exception):
|
|
758
|
+
slice_meta = self.slice_meta(runtime_plugin_ctx)
|
|
759
|
+
slice_meta.setdefault("plugin", self.name)
|
|
760
|
+
self._span_recorder.register_run_failure(
|
|
761
|
+
run_id=run_id,
|
|
762
|
+
product=product,
|
|
763
|
+
output_path=str(runtime_ctx.target or ""),
|
|
764
|
+
output_format=str(runtime_ctx.output_format or ""),
|
|
765
|
+
slice_meta=slice_meta,
|
|
766
|
+
error=str(exc),
|
|
767
|
+
)
|
|
768
|
+
execution_result.registered = True
|
|
769
|
+
raise
|
|
770
|
+
|
|
771
|
+
finally:
|
|
772
|
+
# Teardown
|
|
773
|
+
should_cleanup = (
|
|
774
|
+
self.engine_config.cleanup_workspace if hasattr(self, "engine_config") else False
|
|
775
|
+
)
|
|
776
|
+
with contextlib.suppress(Exception):
|
|
777
|
+
# Engine finalization populates metrics["pipeline"] via
|
|
778
|
+
# compute_run_summary(); emit that canonical run snapshot once.
|
|
779
|
+
pipeline_summary = getattr(execution_result, "metrics", {}).get("pipeline")
|
|
780
|
+
if isinstance(pipeline_summary, dict):
|
|
781
|
+
if fs_metrics is not None:
|
|
782
|
+
pipeline_summary.update(fs_metrics.as_summary())
|
|
783
|
+
if wal_metrics is not None:
|
|
784
|
+
pipeline_summary.update(wal_metrics.as_summary())
|
|
785
|
+
if resume_guard is not None and resume_guard.last_metrics is not None:
|
|
786
|
+
pipeline_summary.update(resume_guard.last_metrics.as_summary())
|
|
787
|
+
telemetry_service.emit_run_metrics(pipeline_summary)
|
|
788
|
+
with contextlib.suppress(Exception):
|
|
789
|
+
telemetry_service.flush()
|
|
790
|
+
self._workspace.teardown(cleanup_dir=bool(should_cleanup))
|
|
791
|
+
|
|
792
|
+
def ingest(self, ctx: IngestContext) -> IngestResult:
|
|
793
|
+
"""Compatibility entry point matching the public ``Ingestor`` protocol."""
|
|
794
|
+
return self.run(ctx)
|
|
795
|
+
|
|
796
|
+
def catalog_group_info(
|
|
797
|
+
self, group: str, store_uri: str, storage_config: Any | None = None
|
|
798
|
+
) -> CatalogGroupInfo | None:
|
|
799
|
+
"""Optionally annotate or hide one discovered catalog group."""
|
|
800
|
+
_ = (group, store_uri, storage_config)
|
|
801
|
+
return None
|
|
802
|
+
|
|
803
|
+
def resolve_output_uri(
|
|
804
|
+
self, ctx: RuntimeIngestContext | PluginContext, write_mode: str = "staged"
|
|
805
|
+
) -> str:
|
|
806
|
+
"""Resolve the canonical output URI (Dataset Directory) for this run."""
|
|
807
|
+
from firecube.core.product import resolve_dataset_target, write_mode_policy
|
|
808
|
+
from firecube.core.uris import is_remote_target
|
|
809
|
+
|
|
810
|
+
product = _output_product_name(ctx, self.name)
|
|
811
|
+
policy = write_mode_policy(write_mode)
|
|
812
|
+
if policy.resolves_to_workspace:
|
|
813
|
+
return resolve_dataset_target(product, write_mode=policy, temp_root=ctx.temp_root)
|
|
814
|
+
|
|
815
|
+
session = _output_storage_session(ctx)
|
|
816
|
+
if session is None:
|
|
817
|
+
raise ConfigurationError(
|
|
818
|
+
"Direct write mode requires ctx.storage.output to resolve the product root."
|
|
819
|
+
)
|
|
820
|
+
product_uri = session.product.product_uri
|
|
821
|
+
direct_base_uri = product_uri.parent().to_str()
|
|
822
|
+
expected_output_uri = product_uri.to_str()
|
|
823
|
+
control_plane_output_uri = self._chunk_manager.get_product_root(product)
|
|
824
|
+
if str(control_plane_output_uri).rstrip("/") != str(expected_output_uri).rstrip("/"):
|
|
825
|
+
raise ConfigurationError(
|
|
826
|
+
"ChunkManager control-plane is bound to a different output root than direct write "
|
|
827
|
+
f"resolution (expected={expected_output_uri}, bound={control_plane_output_uri})."
|
|
828
|
+
)
|
|
829
|
+
if is_remote_target(str(direct_base_uri)) and not is_remote_target(
|
|
830
|
+
str(expected_output_uri)
|
|
831
|
+
):
|
|
832
|
+
raise ConfigurationError(
|
|
833
|
+
"Direct write mode resolved a remote output base into a local workspace path; "
|
|
834
|
+
f"expected remote output under {direct_base_uri}, got {expected_output_uri}."
|
|
835
|
+
)
|
|
836
|
+
return expected_output_uri
|